How to fix a Webflow form that shows JSON after submission

How to fix a Webflow form webhook that shows JSON with a Webflow Cloud route

Learn how to build a Webflow Cloud route that forwards form data to your webhook and redirects browsers.

How to fix a Webflow form webhook that shows JSON with a Webflow Cloud route

Ismail Ajagbe
Technical Author
View author profile
Ismail Ajagbe
Technical Author
View author profile
Table of contents

A custom form action makes the browser render your endpoint's JSON, and a Webflow Cloud route can send visitors to a thank-you page instead.

Imagine that you pointed a Webflow form's custom action at your own endpoint. The data arrived, but the visitor got a white page reading {"ok":true}. A custom action hands the submit back to the browser, which treats it as navigation and renders whatever the endpoint returns.

It also bypasses Webflow's form handling, so the submission is no longer stored in Webflow and no notification email goes out.

If you can keep Webflow's handling, leave the form sending to Webflow and add a Webhook destination in its settings instead; Webflow then stores the submission and shows the success message itself.

When confirmation has to wait for your server, a Route Handler on Webflow Cloud can redirect browsers to a thank-you page and respond to your page script with JSON.

What do you need to fix a Webflow form that shows JSON in Webflow Cloud?

Webflow Cloud runs from the free Starter site plan, but mounting the app on a custom domain needs Premium or higher.

Have these ready before the first deploy:

  • Webflow Cloud environment: A Webflow site with Webflow Cloud enabled and an environment to deploy into; Premium or higher if the app is mounted on a custom domain.
  • Next.js 15+ app: An app on Next.js 15 or higher, already connected to the environment and deploying; you create app/api/submit/route.ts during the build if it doesn't exist yet.
  • npm only: Webflow's bring-your-own-app docs say it plainly: "Currently, Webflow Cloud supports only the npm package manager." Use npm install; a pnpm add or yarn add step does not work.
  • Downstream webhook URL: The URL of the receiver you already forward to, such as a CRM endpoint or a Slack incoming webhook, plus any token it expects.
  • Published thank-you page: A live page on the Webflow site at a path you control, such as /thank-you, where the redirect lands after a native submit.
  • Paid plan for page code: The script in step 4 lives in page custom code, which needs a paid Site plan or a paid Workspace.

With the receiver URL and the thank-you page live; the build hinges on one route that can tell a browser from a script.

5 steps to fix a Webflow form webhook returning JSON in Webflow Cloud

The build is a single Route Handler that forwards the form fields to your webhook, then answers a native browser submit with a 303 redirect and a fetch caller with JSON, based on the Accept header.

Step 1 confirms how the form submits today, and steps 2 to 4 build the route and connect the form and page script to it. Step 5 checks both responses with curl.

1. Confirm the form submits natively to its current endpoint

Submit the form once with the browser's developer tools open on the Network tab and watch what happens to the address bar. If the URL changes to the endpoint's path and the page body is JSON, the browser performed a full navigation and rendered the response as a document.

In the Network tab, the submit request is typed as a document rather than a fetch or XHR (XMLHttpRequest), and its request content type is application/x-www-form-urlencoded. I look for that pairing because it identifies a native submit.

Next, select the form in the Designer and look under Send to in its Settings panel for a Custom action with its URL and method. The method should be POST.

A custom action is the cause. Webflow's form docs say a custom action bypasses Webflow's built-in form handling entirely, so submissions are not sent to or stored in Webflow, and it cannot be combined with Webflow or email notifications.

webflow.js only takes over the submit for forms that send to Webflow, so with a custom action the browser posts natively and shows whatever the endpoint returns. You now know two facts the rest of the steps rely on: the browser is navigating, and the endpoint answers with JSON no matter who submits.

2. Branch the route on how it was called

Create (or replace) the Route Handler to return a 303 redirect for a native browser submit and JSON for the page script, using the Accept header. Browsers typically send an Accept header that lists text/html first on a native submit; the page script sends accept: application/json.

The route parses form-encoded or JSON bodies, checks them, forwards the fields to the downstream webhook, and then picks a response type.

A 303 is the status meant for the post-redirect-get pattern: the browser follows it with a GET, so a reload on the thank-you page never resubmits the form.

I keep the redirect base in an environment variable rather than deriving it from the incoming request, because I don't want to depend on the origin the Worker sees matching the public domain; an explicit SITE_URL pins the redirect to the visitor's domain.

This route is a public relay, so add rate limiting before you rely on it; the honeypot only filters lazy bots.

Route Handler Implementation

Create or replace app/api/submit/route.ts with this handler:

import { NextResponse } from 'next/server';

type Fields = Record<string, string>;

async function readFields(request: Request): Promise<Fields> {
  const fields: Fields = {};
  try {
    const contentType = request.headers.get('content-type') ?? '';
    if (contentType.includes('application/json')) {
      const body: unknown = await request.json();
      if (typeof body !== 'object' || body === null || Array.isArray(body)) return fields;
      for (const [key, value] of Object.entries(body)) {
        if (typeof value === 'string') fields[key] = value;
      }
      return fields;
    }
    const form = await request.formData();
    for (const [key, value] of form.entries()) {
      if (typeof value === 'string') fields[key] = value;
    }
  } catch {
    // An unreadable body falls through as no fields, which fails the email check.
  }
  return fields;
}

export async function POST(request: Request) {
  const wantsJson = (request.headers.get('accept') ?? '').includes('application/json');
  const siteUrl = process.env.SITE_URL;
  const webhookUrl = process.env.DOWNSTREAM_WEBHOOK_URL;

  if (!siteUrl || !webhookUrl) {
    return NextResponse.json({ ok: false, error: 'Route is not configured' }, { status: 500 });
  }

  const fields = await readFields(request);

  // Honeypot: humans never see the "website" field, so a filled value means a bot.
  if (fields.website) {
    return wantsJson
      ? NextResponse.json({ ok: true })
      : NextResponse.redirect(new URL('/thank-you', siteUrl), 303);
  }

  if (!fields.email) {
    return wantsJson
      ? NextResponse.json({ ok: false, error: 'Email is required' }, { status: 400 })
      : NextResponse.redirect(new URL('/form-error', siteUrl), 303);
  }

  let forwarded = false;
  try {
    const forward = await fetch(webhookUrl, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify(fields),
      signal: AbortSignal.timeout(10000),
    });
    forwarded = forward.ok;
  } catch {
    forwarded = false;
  }

  if (!forwarded) {
    return wantsJson
      ? NextResponse.json({ ok: false, error: 'Webhook rejected the submission' }, { status: 502 })
      : NextResponse.redirect(new URL('/form-error', siteUrl), 303);
  }

  return wantsJson
    ? NextResponse.json({ ok: true })
    : NextResponse.redirect(new URL('/thank-you', siteUrl), 303);
}

The route is a public relay to your webhook, so it validates the fields it expects and silently drops any submission that fills the hidden website field. Add that field in the Designer as a text input named website and hide it with display: none, and keep payloads small by sending only the fields the receiver needs.

The route reads the keys email and website, so check that the Email input submits under the key email; a renamed field such as Email-2 sends every submit to /form-error.

The file has no export const runtime = 'edge' line. Webflow Cloud deploys Next.js through the OpenNext Cloudflare adapter, and that adapter does not support the Next.js edge runtime target, so the directive ships a broken build.

The route now returns a redirect to a native submit and JSON to a script, and the same forwarding logic serves both.

3. Set the environment variables and the redirect target

Add SITE_URL and DOWNSTREAM_WEBHOOK_URL in the settings for the Webflow Cloud environment the app deploys into. SITE_URL is the published origin of the site, such as https://www.example.com, with no trailing path. DOWNSTREAM_WEBHOOK_URL is the receiver you forward to, and if that URL carries a token, store it as a secret.

Webflow's environment variables docs state that "Both secret and non-secret environment variables are available to your application's build process and to the deployed application at runtime," and secrets are redacted from build logs.

For local runs, I recommend setting the same two keys in a .env.local file at the project root. Then publish the pages the route redirects to: a thank-you page at /thank-you and an error page at /form-error, both plain Webflow pages built in the Designer. The paths in the route and the published page slugs must match exactly, including case.

Both keys now reach the build and the running route, so the guard at the top of the handler stops returning its configuration error once the app is redeployed.

4. Intercept the submit in the page's custom code

Add a script to the form's page so the submit happens through fetch and the visitor stays on the page. I recommend this path when you want an in-place success message. Give the form the HTML ID contact-form.

The script reuses the success and error message elements that sit inside the Form Block, so you edit that copy in the Designer instead of building a separate element. The script calls the route with accept: application/json, which makes the route return JSON instead of a redirect, then hides the form and shows the Success state.

The mount path is written into the URL by hand because this code lives on a Webflow page, outside the app, where nothing injects it for you.

Page Custom Code Script

Add the script to the form's page before the closing body tag, changing /app to your app's mount path:

<script>
  (function () {
    var form = document.getElementById('contact-form');
    if (!form) return;
    var wrapper = form.closest('.w-form');
    var done = wrapper && wrapper.querySelector('.w-form-done');
    var fail = wrapper && wrapper.querySelector('.w-form-fail');

    form.addEventListener('submit', async function (event) {
      event.preventDefault();
      event.stopImmediatePropagation();
      var body = Object.fromEntries(new FormData(form).entries());
      var button = form.querySelector('[type="submit"]');
      if (button) button.disabled = true;
      if (fail) fail.style.display = 'none';

      try {
        var response = await fetch('/app/api/submit', {
          method: 'POST',
          headers: { 'content-type': 'application/json', accept: 'application/json' },
          body: JSON.stringify(body)
        });
        if (!response.ok) throw new Error('Submit failed');
        form.style.display = 'none';
        if (done) {
          done.style.display = 'block';
          done.setAttribute('tabindex', '-1');
          done.focus();
        }
      } catch (err) {
        if (button) button.disabled = false;
        if (fail) {
          fail.style.display = 'block';
          fail.setAttribute('tabindex', '-1');
          fail.focus();
        }
      }
    }, true);
  })();
</script>

The listener runs in the capture phase and calls stopImmediatePropagation(), so no later submit listener runs, including the delegated listener webflow.js keeps on the page. With a custom action set, webflow.js leaves the submit to the browser anyway, so this matters only if the form still sends to Webflow.

Moving focus to the message lets screen readers announce the result, as Webflow's own handler does. The early return protects pages that reuse the same custom code without the form: there, the script does nothing instead of throwing.

The script expects .w-form, .w-form-done and .w-form-fail to identify the Form Block wrapper and its success and error messages, so check them against your published markup and recheck them if it changes.

Then point the form itself at the route: in the form's Settings panel, under Send to, replace the Webflow and Email notifications destinations with a Custom action set to /app/api/submit (your mount path followed by /api/submit) and the POST method.

Visitors without scripting now post natively to the route and get the 303 redirect, while visitors with scripting stay on the page. After the app deploys, submitting the form hides it and shows the Form Block's success message in place, with the address bar unchanged.

5. Deploy and prove both response paths

Deploy the app to Webflow Cloud, then test the route from the command line so you are checking the server's behavior rather than the browser's interpretation of it. These two requests differ only in body encoding and the Accept header, which is exactly the difference the route branches on.

Run the native-submit shape first and read the status line and the Location header:

curl -i -X POST https://www.example.com/app/api/submit \
  -d "name=Test" -d "email=test@example.com"

You should see a 303 status and a Location header pointing at https://www.example.com/thank-you.

Now send the shape the page script sends:

curl -i -X POST https://www.example.com/app/api/submit \
  -H "content-type: application/json" \
  -H "accept: application/json" \
  -d '{"name":"Test","email":"test@example.com"}'

This one should return a 200 with the body {"ok":true}. If either request produces the other's response, I would inspect the Accept check in the route first.

Finally, confirm the downstream receiver logged two submissions, one per request. With both checks passing, a successful submission on the live site leads to a thank-you page or an in-place success message rather than a page of JSON.

What causes a Webflow form webhook to show JSON instead of a success message?

Most failures come from a browser receiving a machine response or a fetch missing the app's mount path. The rest come from build issues and missing configuration in the deployed environment.

The address bar changes to the route path and the page shows {"ok":true}

Cause: The form submitted natively, and the route returned JSON to a browser. A browser treats a form POST as navigation and renders whatever comes back as the new document; a JSON body has no markup, so it renders as text.

This is the default outcome whenever a form action points at an endpoint written for a webhook consumer, and it has nothing to do with the submission failing: by the time you see the JSON, the downstream webhook has already received the fields.

Fix: I recommend making the route return a 303 redirect for browser submits, using the Accept-header branch in the route. Then check the page script is actually running: if the form's ID in the Designer does not match contact-form, the script exits early, and the form falls back to native navigation.

Open the Network tab and confirm the submit request is typed as fetch rather than document. If it still shows as a document, the ID mismatch is almost always the reason.

The fetch call returns 404 on the live site, but the route works under next dev

Cause: The request URL is missing the app's mount path. On Webflow Cloud, the app is mounted under a base path on the site, and that path is injected at build time rather than read from next.config.

Client-side fetch calls are not rewritten: Webflow's bring-your-own-app docs state that "Client-side fetch calls must manually include the base path."

Locally, with no mount path, /api/submit resolves, so the bug only surfaces after deployment.

Fix: For the script on the Webflow page, write the full mounted path by hand, as the page script does with /app/api/submit. For any form rendered inside the Next.js app itself, add a NEXT_PUBLIC_BASE_PATH variable set to your mount path, since Webflow Cloud does not create it, and prefix the fetch URL with it so the same component works locally and deployed.

Confirm the fix by requesting the mounted path directly with curl; a 404 there means the path is wrong, while a 405 usually means the path resolves but the request method is not POST.

The Webflow Cloud build fails on a route file that runs under next dev

Cause: The usual trigger is an export const runtime = 'edge' line in a route file. The word "edge" means two different things, and Webflow's docs use both.

Webflow Cloud runs apps on Cloudflare Workers, an edge platform, and the bring-your-own-app page describes that: "Webflow Cloud deploys your app using the Edge runtime, enabling fast, globally distributed hosting." The same page still tells you to add the Next.js edge runtime directive to API routes.

The route can run on the Workers platform without that directive. The directive selects the separate Next.js edge runtime target, which the OpenNext Cloudflare adapter Webflow Cloud uses to deploy Next.js does not support. Adding the line therefore breaks the build even when a developer follows the docs faithfully.

Fix: Delete the directive from every route file and redeploy. The route already runs on the Workers platform without it. If the app uses middleware, keep it in middleware.ts rather than a Next 16 proxy.ts, because Webflow's framework customization docs state that "Node.js runtime middleware isn't supported. Only Edge runtime middleware works on the Workers runtime."

The route returns 500 on Webflow Cloud, but the same submit succeeds locally

Cause: SITE_URL or DOWNSTREAM_WEBHOOK_URL is defined in your local .env.local and not in the Webflow Cloud environment, so the guard at the top of the route returns the configuration error.

The two sets of variables are independent: values on your machine never reach the deployed Worker, so you need to set them in both places. This is the first thing I check when a route works locally and fails deployed.

Fix: Add both variables to the environment the app deploys into, then redeploy so the build picks them up. Next, hit the route with the JSON-shaped curl request to read the error message in the body; the configuration guard returns JSON regardless of the Accept header.

If the body says the webhook rejected the submission rather than that the route is not configured, the variables are present, and the problem has moved to the downstream receiver, which is where to look next.

What you can build next with Webflow Cloud and Webflow forms

The same handler can send each submission to a second receiver without touching the form in the Designer again, or validate a field against an external service before forwarding, the way a phone validation route checks numbers. Once every form on the site posts to a route you control, you no longer decide only the response.

For deeper customization beyond a single form route, Webflow's developer docs cover Webflow Cloud environments and limits.

Frequently asked questions

Does the redirect have to be a 303, or does a 302 work?

Yes, a 302 also works in browsers because the Fetch Standard turns the POST into a GET, although a 303 is preferred. After all, it explicitly states that intent for every client.

Will this work with an Astro app on Webflow Cloud?

Yes. The logic is the same in an Astro server endpoint: forward the parsed body, then check the Accept header to choose between a redirect and JSON. Only the response helper changes; Astro's endpoint returns a standard Response with a 303 status and a Location header.

What if bots ignore the honeypot and keep posting to the route?

Check the Origin header in the route. Browsers submitting from your site send your site's origin, so the route can drop any POST whose Origin does not match SITE_URL. Some scripted bots omit the header, though a determined one can forge it. As with the honeypot, return the normal success response so the bot learns nothing.

Why does the page script fail on the webflow.io staging domain?

The script posts to a relative path on whichever domain served the page. If the app is mounted only on your custom domain, the same relative path on webflow.io probably has no app behind it, so the request fails. Test where the app is mounted; switching to an absolute URL makes the request cross-origin, which needs CORS headers.


Last Updated
October 19, 2026
Category

Related articles


verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logoreddit logothe new york times logoideo logoupwork logodiscord logo
verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logoreddit logothe new york times logoideo logoupwork logodiscord logo

Get started for free

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.

Get started — it’s free
Watch demo

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.