An n8n workflow behind a Webflow Cloud route gives every submission a destination, so the CMS item and the team alert both fire the moment the form posts.
A native Webflow form handles a general contact or newsletter signup without any help. The work piles up after that. Someone copies the entry into a CMS collection, pings the team, adds the contact to a list, and nobody is sure which submissions were handled and which were skimmed and forgotten.
A form the site records and a form your own code owns behave differently once the button is pressed. When the submission passes through a Route Handler you wrote, you decide what gets validated and what gets signed before anything is forwarded, and n8n takes it from there.
A single POST route in a Next.js app on Webflow Cloud accepts submissions from a form component, signs the payload with a shared secret, and hands it to an n8n Webhook node.
What do you need to run an n8n form workflow in Webflow?
You need six prerequisites for this build.
Have these items ready before you configure the n8n workflow and Webflow Cloud app:
- Webflow site on any plan: Webflow Cloud ships from the free Starter tier; Premium matters only when the app mounts on your custom domain.
- n8n instance over HTTPS: Self-hosted or n8n Cloud, as long as the instance is publicly reachable and you can create and activate workflows.
- Node.js and npm: Webflow Cloud supports only npm, so every install in this build uses
npm install. - App repository: Keep the scaffold and route code together in the repository you use for this build.
- Webflow API access: n8n uses this when the workflow makes requests to Webflow's REST API. Keep the access details out of the app.
- Shared secret string: Any long random value that the route uses to sign each payload and n8n uses to verify it.
Webflow Cloud is available from the free Starter site plan, while mounting the finished app on a custom domain requires Premium or higher. With these prerequisites in place, the build hinges on creating an active n8n webhook before configuring the app to send submissions to it.
6 steps to automate Webflow CMS and form workflows with n8n in Webflow Cloud
An active n8n workflow supplies the production URL that every later step depends on. The Next.js Route Handler validates and signs the submission before sending it to that webhook with APIs supported by Cloudflare Workers.
Build the receiving workflow first, then configure, code, deploy, and test the Webflow Cloud app that forwards each form submission.
1. Build the n8n workflow that receives submissions
Build the receiving n8n workflow first. The route depends on an active Webhook node's production URL, so n8n must be listening before you can test anything on the Webflow side.
n8n's documentation covers the Webhook node settings used here: in n8n, add a Webhook node as the trigger, set the HTTP method to POST, give it a path you will recognize in logs (something like webflow-forms), and use the raw body option so the bytes n8n hashes are the exact bytes the route signed.
Set the node to respond immediately; a visitor should never wait on a CMS request to get an acknowledgment.
Follow the trigger with a Code node that reads the X-Signature-256 header and recomputes an HMAC-SHA256 of the raw body with the shared secret. Use a timing-safe comparison for the signatures; === is unsuitable. Stop execution when they differ.
Verifying the signature and freshness
After the signature passes, reject a receivedAt value outside a short freshness window so a captured body and signature cannot be replayed later. Everything after that gate is the automation you actually wanted: a node that writes to Webflow's CMS.
Worth knowing before you build this by hand: n8n ships a first-party Webflow action node covering item create, delete, get, get-all and update, plus a Webflow Trigger node, and both accept either an API access token or OAuth2. On n8n Cloud, that credential is a browser click rather than a token you store and rotate yourself.
Use the built-in node unless you need something it does not cover, in which case an HTTP Request node against Webflow's REST API is the escape hatch, a node that posts to your team chat, whatever the workflow requires. I map each form field for the CMS step in the HTTP Request node's JSON body, so the route passes only submission data through the app.
Keep the CMS API step and the notification on separate branches so a chat outage never blocks the CMS branch.
Activate the workflow, then copy the production URL from the Webhook node for the Webflow Cloud environment. The test URL listens only while the node is open in the editor, so a route pointed at it works during a demo and stops as soon as you close the tab. You should now have an active production webhook URL ready for the Webflow Cloud environment.
2. Scaffold the Next.js app with npm
Scaffold a stock Next.js app with npm and without added adapters or packages. Use a Next.js version supported by Webflow Cloud and build it with npm alone, so a repository carrying a pnpm-lock.yaml or yarn.lock is already off the supported path before the first commit.
After scaffolding the supported app, run these from its project directory:
cd webflow-n8n-forms
npm run dev
You now have an App Router project using a supported Next.js version and npm. Keep next.config unchanged. Webflow Cloud injects the mount path at build time and deploys through the OpenNext Cloudflare adapter on its own, and the bring-your-own-app page puts it plainly: "No adapter, no base path, no wrangler.json."
With the dev server running, localhost:3000 shows the default Next.js page, and the project is ready to push to its remote repository.
3. Create the Webflow Cloud project and set its environment variables
Create a Webflow Cloud project for the site, connect the app repository, and create an environment with a mount path like/app. The environment's mount path determines what the form fetches later, while its variables supply the deployed app's configuration. Webflow Cloud makes secret and non-secret variables available to the build process and to the deployed application at runtime.
The environment variables panel shows each variable alongside the secret toggle. The secret toggle distinguishes the shared signing value from configuration that can appear in the deployed browser bundle.
Add the following variables to the Webflow Cloud environment:
- N8N_WEBHOOK_URL: Use the production Webhook URL, including its path; put the test URL in
.env.localfor local work. - N8N_SHARED_SECRET: Use the same long random HMAC string in both systems, mark it secret, and mirror it in
.env.local. - ALLOWED_ORIGIN: Set the site's scheme and host, with no path, for Origin checks; omit it locally to accept localhost.
- NEXT_PUBLIC_BASE_PATH: Match the environment mount path exactly, such as
/app; leave it empty locally becausenext devserves from root.
You should now have the production webhook, shared signing secret, allowed origin, and browser-visible mount path configured before creating the Route Handler.
4. Write the Route Handler that signs and forwards to n8n
Write a plain Web-standard handler with no edge directive, as required by OpenNext's Cloudflare adapter. The bring-your-own-app docs still carry a line telling you to add export const runtime = 'edge' to API routes, and that line ships a broken build: Webflow Cloud deploys Next.js through the OpenNext Cloudflare adapter, which does not support the Next.js edge runtime.
The confusion comes from one word meaning two things. Webflow Cloud runs on Cloudflare Workers, an edge platform, while runtime = 'edge' selects the Next.js edge runtime target, a separate thing OpenNext cannot compile. A Route Handler with no runtime export lands on Workers regardless.
This browser-accessible POST route lacks authentication and rate limiting. It also leaves n8n executions, CMS writes, notifications, and resulting spend uncapped. The Origin check and honeypot provide limited filtering.
A non-browser client can forge the header and repeatedly submit an empty honeypot. Before treating the route as production-ready, add a documented, platform-compatible rate limit and a cap on executions or CMS writes. Add an authenticated gate as well if access to the form is private.
Secret-leakage check: N8N_SHARED_SECRET is server-only; NEXT_PUBLIC_BASE_PATH is intentionally browser-visible and must never contain a credential. OAuth state is inapplicable because this build contains no OAuth authorization flow.
Create app/api/forms/route.ts with this content:
// app/api/forms/route.ts
import { NextResponse } from 'next/server';
type Payload = Record<string, string>;
async function hmacHex(secret: string, message: string): Promise<string> {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(message));
return Array.from(new Uint8Array(signature))
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('');
}
export async function POST(request: Request) {
const webhookUrl = process.env.N8N_WEBHOOK_URL;
const secret = process.env.N8N_SHARED_SECRET;
const allowedOrigin = process.env.ALLOWED_ORIGIN;
if (!webhookUrl || !secret) {
return NextResponse.json({ error: 'Automation is not configured' }, { status: 500 });
}
const origin = request.headers.get('origin');
if (allowedOrigin && origin !== allowedOrigin) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
let data: Payload;
try {
const parsed: unknown = await request.json();
if (
typeof parsed !== 'object' ||
parsed === null ||
Array.isArray(parsed) ||
!Object.values(parsed).every((value) => typeof value === 'string')
) {
return NextResponse.json(
{ error: 'Body must be an object with string fields' },
{ status: 400 },
);
}
data = parsed as Payload;
} catch {
return NextResponse.json({ error: 'Body must be JSON' }, { status: 400 });
}
// Honeypot: real visitors never see this field, so a value means a bot.
if (data.website) {
return NextResponse.json({ ok: true });
}
if (!data.email || !data.email.includes('@')) {
return NextResponse.json({ error: 'A valid email is required' }, { status: 400 });
}
const body = JSON.stringify({
...data,
source: 'webflow-cloud',
receivedAt: new Date().toISOString(),
});
const signature = await hmacHex(secret, body);
let upstream: Response;
try {
upstream = await fetch(webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Signature-256': signature,
},
body,
});
} catch {
return NextResponse.json(
{ error: 'Automation did not accept the submission' },
{ status: 502 },
);
}
if (!upstream.ok) {
return NextResponse.json(
{ error: 'Automation did not accept the submission' },
{ status: 502 },
);
}
return NextResponse.json({ ok: true });
}
The handler signs each request and leaves verification to n8n, so there is no constant-time comparison in this file: nothing here compares a signature. Signing needs only crypto.subtle, which behaves the same in both places; the verifying side lives in n8n, where Node is Node.
The honeypot field and the Origin check implement the limited filters described above. With n8n active and the dev server running, curl -X POST localhost:3000/api/forms -H 'Content-Type: application/json' -d '{"email":"test@example.com","name":"Test"}' returns {"ok":true} and a new execution appears in n8n.
5. Add the form component that posts through the mount path
Configure the mounted app's form to post to ${basePath}/api/forms. A bare /api/forms request goes to the site's root.
Webflow Cloud handles the mount path for server-side routing at build time, but a fetch that runs in the browser goes exactly where you tell it, and the bring-your-own-app docs put it plainly: "Client-side fetch calls must manually include the base path." Under next dev the base path is empty and /api/forms works, which is exactly how this bug hides until deploy.
The client component that posts the fields, app/components/LeadForm.tsx, looks like this:
// app/components/LeadForm.tsx
'use client';
import { useState, type FormEvent } from 'react';
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? '';
export function LeadForm() {
const [state, setState] = useState<'idle' | 'sending' | 'sent' | 'failed'>('idle');
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setState('sending');
const form = event.currentTarget;
const payload = Object.fromEntries(new FormData(form).entries());
try {
// The mount path matters: a bare /api/forms reaches the parent
// Webflow site, not this app.
const res = await fetch(`${basePath}/api/forms`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
setState(res.ok ? 'sent' : 'failed');
if (res.ok) form.reset();
} catch {
setState('failed');
}
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="name">Name</label>
<input id="name" name="name" type="text" required />
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
<label htmlFor="message">Message</label>
<textarea id="message" name="message" required />
<input
name="website"
type="text"
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
style={{ display: 'none' }}
/>
<button type="submit" disabled={state === 'sending'}>
{state === 'sending' ? 'Sending' : 'Send'}
</button>
{state === 'sent' && <p>Thanks, your message is on its way.</p>}
{state === 'failed' && <p>Something went wrong. Please try again.</p>}
</form>
);
}
Import LeadForm into app/page.tsx in place of the boilerplate. The hidden website input is the honeypot the route checks; it sits outside the tab order and off the accessibility tree.
People leave it empty, while a form-scraping bot usually fills it. NEXT_PUBLIC_ values are inlined at build time, which is why you have to set the variable in the Webflow Cloud environment before the build runs.
Submitting locally should show the success line and produce an n8n execution carrying the name, email, and message.
6. Deploy and test the workflow end to end
Commit and push, then build the environment in Webflow Cloud. Build logs redact the secret value. A log that shows N8N_SHARED_SECRET as redacted confirms the expected behavior. When the build completes, open the app at its mount path (/app) and submit the form with a real email address.
Start in the browser's Network tab because its results show whether the request reached the route. A POST to /app/api/forms should return 200.
If the prefix is missing, correct the base path configuration used by the form component. Then trace the request through the n8n executions list and confirm that the downstream CMS branch completed successfully.
Mounting the app on your custom domain requires Premium or higher on the site plan. Once that flips, ALLOWED_ORIGIN must match the custom domain's origin, or the route will return 403 to your own visitors.
You should now have a submission that travels from a page on your domain, through a route you own, into an n8n execution, and out to a CMS step and a notification.
What causes an n8n form workflow to fail on Webflow Cloud?
A deployed 404 usually points to the mount path, while a 500 points to environment variables. Build errors usually point to a runtime directive or an unsupported Node built-in.
Start with the HTTP status or build error because it narrows the failure before you change any configuration.
The build breaks right after you add the route
Cause: The project probably contains runtime = 'edge'. Webflow Cloud runs on Cloudflare Workers, but that does not mean the Route Handler should select the separate Next.js edge runtime target.
Webflow Cloud deploys Next.js through the OpenNext Cloudflare adapter, which does not support that target, so the directive can break compilation even though the destination is an edge platform.
Fix: Search the whole project for runtime = 'edge', including any stray route, layout, or page. Remove every copy, keep the handler Web-standard, and redeploy. A Route Handler with no runtime export still lands on Workers through the platform's adapter.
Submitting the form returns 404 on the mounted app, but the same form works under next dev
Cause: The browser request is probably missing the Webflow Cloud mount path. Under next dev, the base path is empty and /api/forms reaches the local route, but the deployed browser sends a bare request to the site's root instead of the mounted app. Server-side routing doesn't fix a client-side fetch after deployment.
Fix: Check the request in the network tab. If it does not begin with /app/api/forms, set process.env.NEXT_PUBLIC_BASE_PATH to match the mount path exactly, with a leading slash and no trailing slash. Trigger a new build because NEXT_PUBLIC_ values are inlined when the bundle is compiled, and keep the mount path and variable synchronized manually.
The route returns 500 in production, and n8n never records an execution
Cause: A required route variable is missing from the Webflow Cloud environment, so the execution fails. Variables belong to the environment you entered them in, and a value in .env.local exists only on your machine because that file never deploys. The route surfaces any upstream rejection as a 502, so a 500 points to configuration on the Webflow Cloud side and a 502 points to n8n.
Fix: Confirm both variable names match the code character for character, including case. Secrets are redacted from build logs, so you cannot use the log to inspect the stored value; when in doubt, generate a new secret, enter it in both Webflow Cloud and the n8n Code node, and redeploy.
For a 502 response, confirm the workflow is active, and the webhook setting uses the production URL from the Webhook node. Then resubmit and watch the executions list populate.
Deploy fails with a module resolution error for node:fs, though next dev runs clean
Cause: Webflow Cloud pins a Workers compatibility date that does not provide node:fs, so an import in the route or one of its dependencies cannot resolve in the deployed build. Local next dev runs on Node, where node:fs always exists, so every local test passes. node:path does resolve on Webflow Cloud, which makes the failure look arbitrary, even though it's a fixed line in the runtime.
Fix: Remove the filesystem dependency entirely. A Route Handler that forwards to n8n can keep templates in code, while persistent data stays downstream in n8n or Webflow's CMS.
If a third-party package is the culprit, swap it for one built for Web-standard runtimes, and check its imports before you install it. Then test against a real deploy, because neither TypeScript nor lint will flag a runtime that lacks a module the code imports.
What you can build next with n8n and Webflow
Once submissions pass through a route you control, the same shape also supports scheduled n8n runs that work with CMS content and forms that send data to several tools. Webflow's composable CMS has REST APIs, so n8n can use them in a workflow, and the Route Handler stays the signed, validated front door for anything a visitor can trigger.
I treat the route as the boundary for visitor-triggered workflows and let n8n own the downstream branches.
Keep the route for the parts that need your own validation or signing. For the connection details between the two platforms, see the Webflow and n8n integration.
Frequently asked questions
What happens if n8n is down when someone submits the form?
No submission is retained. If n8n is unavailable, you receive a 502 response, and the form shows "Something went wrong. Please try again." The route stores nothing, so the submission disappears after the visitor leaves. For submissions you cannot lose, add a retry mechanism or put a queue before the n8n workflow instead of relying on the route for storage.
Can n8n work with the Webflow CMS without the Webflow Cloud app?
Yes. For a CMS-only automation, you can omit the Webflow Cloud app and let n8n call Webflow's REST API directly. That keeps the route reserved for browser-triggered submissions that need validation or signing. It also means scheduled content jobs do not depend on the app's mount path, Origin check, or form component.
Should I rename middleware.ts to proxy.ts?
No. Keep middleware.ts for this deployment. The renamed proxy convention runs on the Node.js runtime, while Webflow Cloud's framework requirements specify Edge runtime middleware for Workers and exclude Node.js runtime middleware. Renaming the file would select middleware that the deployed platform cannot execute, even though Route Handlers themselves must omit the Next.js edge runtime directive.




