A Next.js Route Handler on Webflow Cloud hands every form submission straight to HubSpot, with the field mapping in your repo under version control.
The usual Webflow-to-HubSpot setup routes every submission through an automation tool like Zapier. When a form is submitted, an automated workflow triggers and sends data to HubSpot based on a previously configured field mapping.
When a designer renames an input in the Designer, the external map can fall out of sync and stop sending that field to HubSpot. The mapping lives outside the repo and outside the deploy that changed the form.
Webflow Cloud lets you put that hop inside the site itself. You build a Next.js Route Handler, mounted on the same domain as your Webflow pages, that receives the form post and forwards it to the submission endpoint HubSpot gives you for the form, after a check or two.
The field mapping becomes a TypeScript function in a repo so that you can coordinate changes to field names in the Designer with the corresponding mapping deployment.
What do you need to connect a Webflow form to HubSpot in Webflow?
You need six prerequisites, including Webflow Cloud, which is available from the free Starter site plan up; mounting the app to a custom domain requires Premium or higher.
Confirm the project, account, runtime, mount path, and environment access before you start:
- A Webflow site with Webflow Cloud available: Webflow Cloud is included from the free Starter site plan up, and mounting the app to a custom domain requires Premium or higher.
- A HubSpot account with a form you own: You need its portal ID, form GUID and the exact internal property names, because HubSpot rejects a submission that includes a field the form doesn't define, rather than ignoring a value whose input name doesn't match.
- A supported Node.js version and npm: Webflow Cloud's docs specify the supported Node.js version, and npm is the only package manager it supports; pnpm and yarn commands do not work there.
- A supported Next.js project: Bring-your-own-app deployments require a supported Next.js version so an older project may need an upgrade first.
- A mount path decision: Choose the path where the app will live on your site, such as
/forms, before configuring the browser script. - Access to the Webflow Cloud environment settings: Environment variables, including the HubSpot credential, are set per environment, so agree early on who holds the token and who can rotate it.
With these prerequisites in place, you can build the route around HubSpot's property names, submission endpoint, and the mount path your Webflow form will call.
6 steps to send a Webflow form to HubSpot in Webflow Cloud
Sending the form requires six steps: prepare HubSpot, scaffold the app, write the handler, configure its environment, connect the Webflow form, and test the mounted route end to end.
Work through the HubSpot and application setup before connecting the published form.
1. Create the HubSpot form and collect its submission endpoint
Build the HubSpot form with exactly the properties you want a Webflow submission to fill, then record its submission endpoint and each property's internal name. HubSpot matches submitted values by internal property name.
A label like "First Name" is irrelevant to the integration; the internal name is what you will type into the Webflow input's name attribute later. If HubSpot's endpoint requires a private app token in your account's setup, generate one now and store it somewhere the right people can reach.
HubSpot does not hand you a URL; you build one from the portal ID and form GUID, and you pick between two:
POST https://api.hsforms.com/submissions/v3/integration/submit/{portalId}/{formGuid}
POST https://api.hsforms.com/submissions/v3/integration/secure/submit/{portalId}/{formGuid}
This difference isn't a property of your account. The plain path takes no credentials; the secure path requires a private app access token with the forms scope, which HubSpot currently documents. Pick the secure path unless you have a reason not to, and set the token accordingly rather than treating it as optional.
The chosen URL becomes HUBSPOT_SUBMIT_URL, and the token becomes HUBSPOT_TOKEN. I use the request body shape from HubSpot's own Forms API reference, and the handler keeps that shape in one function, so a change there is a single edit.
You should now have an endpoint URL and a list of internal property names, including email. If the endpoint requires a token, you should also have that token.
2. Scaffold the Next.js app for Webflow Cloud
Create a fresh Next.js project on the version Webflow Cloud supports, selecting the App Router and TypeScript, and leave the runtime configuration alone:
npx create-next-app@latest hubspot-bridge --typescript --eslint --app --no-src-dir
cd hubspot-bridge
npm run dev
Pin the flags rather than answering prompts, and accept the src/ directory; this changes every file path in this guide and the @/ import alias along with them.
You now have an app serving on your machine, with an app directory ready for a route.
The bring-your-own-app page says Webflow Cloud deploys your app using the Edge runtime for fast, globally distributed hosting, and the same page then tells you to add the Next.js edge runtime directive to your API routes.
The word "edge" means two different things in those two places. The first refers to Cloudflare Workers, the platform your app runs on. The second refers to the Next.js edge runtime target, a separate build mode that the OpenNext Cloudflare adapter does not support.
Adding export const runtime = 'edge' ships a broken build. Your app is already on an edge platform without it. Leave the directive out of every file, and you'll have a deployable scaffold.
3. Write the Route Handler that forwards to HubSpot
Create a public route that screens requests before they reach HubSpot. The sample checks the origin and filters bots with a honeypot. It also maps Webflow fields and adds an optional bearer token. The handler imports nothing beyond next/server and uses no Node built-in modules.
This avoids module-specific Workers compatibility issues. The origin check matters because without it, anyone who finds the path can push junk into your CRM through your credentials. A browser sets that header for you; anything driving the route from a terminal can set it to whatever it likes.
Treat the allowlist as a filter for casual junk. It does not authenticate clients.
Security warning: This sample has no inbound authentication, rate limit, request or body-size limit, or CRM-write cap. The origin allowlist and honeypot are only filters and can be bypassed by direct clients.
Before using the route in production, add rate limiting through supported middleware.ts and enforce request-size and CRM-write limits appropriate to your deployment.
Create the file at app/api/hubspot/route.ts with this content:
import { NextResponse } from 'next/server';
type Incoming = {
fields?: Record<string, string>;
pageUri?: string;
pageName?: string;
};
function allowedOrigin(origin: string): boolean {
const allowed = (process.env.ALLOWED_ORIGINS ?? '')
.split(',')
.map((o) => o.trim())
.filter(Boolean);
return allowed.length === 0 || allowed.includes(origin);
}
// Keep HubSpot's body shape in one place.
// Only send fields the HubSpot form actually defines: an unknown
// field makes HubSpot reject the whole submission with a 400 and
// FIELD_NOT_IN_FORM_DEFINITION, so forwarding the form wholesale is
// how this breaks in production.
const HUBSPOT_FIELDS = ['firstname', 'lastname', 'email', 'message'] as const;
function toHubSpotPayload(body: Incoming, hutk?: string) {
const submitted = body.fields ?? {};
const fields = HUBSPOT_FIELDS
.filter((name) => submitted[name] !== undefined && submitted[name] !== '')
.map((name) => ({ name, value: submitted[name] }));
return {
fields,
context: {
// hutk ties the submission to the visitor's HubSpot tracking
// session and is what lets HubSpot deduplicate the contact.
...(hutk ? { hutk } : {}),
pageUri: body.pageUri,
pageName: body.pageName,
},
};
}
export async function POST(request: Request) {
const origin = request.headers.get('origin') ?? '';
if (!allowedOrigin(origin)) {
return NextResponse.json({ ok: false, error: 'origin not allowed' }, { status: 403 });
}
let body: Incoming;
try {
body = (await request.json()) as Incoming;
} catch {
return NextResponse.json({ ok: false, error: 'invalid json' }, { status: 400 });
}
// Honeypot: real visitors never fill the hidden "website" field.
if (body.fields?.website) {
return NextResponse.json({ ok: true });
}
if (!body.fields?.email) {
return NextResponse.json({ ok: false, error: 'email required' }, { status: 400 });
}
const url = process.env.HUBSPOT_SUBMIT_URL;
if (!url) {
return NextResponse.json({ ok: false, error: 'HUBSPOT_SUBMIT_URL not set' }, { status: 500 });
}
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (process.env.HUBSPOT_TOKEN) {
headers.Authorization = `Bearer ${process.env.HUBSPOT_TOKEN}`;
}
const upstream = await fetch(url, {
method: 'POST',
headers,
// The route is same-origin with the page, so the browser sends
// the HubSpot tracking cookie along with the POST.
body: JSON.stringify(
toHubSpotPayload(
body,
request.headers
.get('cookie')
?.match(/(?:^|;\s*)hubspotutk=([^;]+)/)?.[1],
),
),
});
if (!upstream.ok) {
console.error('HubSpot rejected submission', upstream.status, await upstream.text());
return NextResponse.json({ ok: false, error: 'upstream rejected' }, { status: 502 });
}
return NextResponse.json({ ok: true });
}
The handler accepts a JSON body from your own site. It returns 200 to bots so they learn nothing. When HubSpot declines, the handler returns 502 and logs the reason to the console. The honeypot returns success on purpose; a 400 tells a bot author which field tripped the filter.
Restart npm run dev and POST a test body with curl to http://localhost:3000/api/hubspot; with no environment variables set yet, you should see the 500 naming HUBSPOT_SUBMIT_URL, which confirms the route is wired.
4. Set the HubSpot environment variables in Webflow Cloud
Set the HubSpot credential in the Cloud environment. Webflow's environment variables docs state that "Both secret and non-secret environment variables are available to your application's build process (for example, for auth-framework configuration) and remain available to the deployed application at runtime." Secret values are redacted from build logs.
For local work, put the same names in .env.local, which Next.js reads under npm run dev and which stays out of git by default.
Set these four environment variable names for the deployment:
- HUBSPOT_SUBMIT_URL: The submission endpoint HubSpot gave you for this form. It is form-specific, so a second form needs a second variable or a second route.
- HUBSPOT_TOKEN: Set this optional secret only if your HubSpot endpoint requires a private app token; the handler then adds a bearer header.
- ALLOWED_ORIGINS: A comma-separated list of the exact origins your Webflow pages are served from, such as
https://www.example.com. Leaving it empty turns off the check, which is acceptable locally only. - NEXT_PUBLIC_BASE_PATH: Set this to the mount path for any page inside the app that fetches its own routes. The platform injects the base path at build time and does not rewrite client fetches.
You now have the endpoint, optional credential, origin allowlist, and client-side base path configured for the deployment. After you save, the environment lists all four names, the token value is hidden, and a redeploy picks them up at build and runtime.
5. Point the Webflow form at the mounted route
Add a stable form selector and input names that match HubSpot. If your Webflow setup supports custom attributes, ensure the rendered form tag has a custom attribute named data-hubspot with the value true.
If it doesn't, adapt the script's selector to a hook your setup supports. Set each input's name to the internal property name you recorded in HubSpot; the email input must be named email.
Include an extra hidden text input named website; that is the honeypot the handler checks. If custom attributes are available, ensure the element that shows your error message has the custom attribute data-hubspot-error, since the script looks for that attribute when a post fails. Otherwise, adapt the error lookup to a hook your setup supports.
If you use the custom-attribute approach, confirm the attribute is present on the rendered Form element. With the attribute set on the form, the page script can find it.
Then add this script through a page-level custom-code mechanism available in your Webflow setup:
<script>
(function () {
var MOUNT_PATH = '/forms'; // your Webflow Cloud mount path
var form = document.querySelector('form[data-hubspot="true"]');
if (!form) return;
form.addEventListener('submit', function (event) {
event.preventDefault();
event.stopImmediatePropagation();
var fields = {};
new FormData(form).forEach(function (value, key) {
fields[key] = String(value);
});
fetch(MOUNT_PATH + '/api/hubspot', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fields: fields,
pageUri: window.location.href,
pageName: document.title
})
})
.then(function (res) {
if (!res.ok) throw new Error('submit failed');
window.location.assign('/thank-you');
})
.catch(function () {
var fail = form.parentElement.querySelector('[data-hubspot-error]');
if (fail) fail.style.display = 'block';
});
}, true);
})();
</script>
The listener is registered in the capture phase and calls stopImmediatePropagation, so it runs before the page's default submit handling and stops it. MOUNT_PATH must prefix the route: the bring-your-own-app page states that "Client-side fetch calls must manually include the base path", and a fetch to /api/hubspot without the prefix hits the Webflow site and misses your app.
Replace /thank-you with your real success page. Publish the site, and the form should now intercept its own submit; opening the browser's Network tab and submitting shows a POST to /forms/api/hubspot and prevents a page reload.
6. Deploy the app and test a submission end to end
Push the app, deploy it to the Webflow Cloud environment where you set the variables, and confirm it is mounted at the path the script uses. Test the route before touching the form. A direct request to the mounted route isolates app failures from form failures.
Send a request that mimics the script, with the Origin header your allowlist expects:
curl -i -X POST https://www.example.com/forms/api/hubspot \
-H "Content-Type: application/json" \
-H "Origin: https://www.example.com" \
-d '{"fields":{"email":"test@example.com"},"pageUri":"https://www.example.com/contact","pageName":"Contact"}'
A 200 with {"ok":true} means the handler reached HubSpot and HubSpot accepted the body; a 502 means the handler ran, but HubSpot declined and the console.error line emits HubSpot's status and response text for that request. Now submit the real form on the published page and look for the contact in HubSpot.
I like to also submit once with the honeypot filled through the Network tab's edit-and-resend to confirm the 200 comes back with nothing created in HubSpot. When both checks pass, you have a form that posts to your CRM through code you control, with no automation account in between.
What causes a Webflow form-to-HubSpot submission to fail?
Failures usually come from an unsupported runtime directive, a missing mounted-route prefix, mismatched production environment variables, or a form selector and listener that never intercept the submission. These problems can show up as failed builds, live 404s, authorization errors, or page reloads.
Match the visible symptom to the cause and fix below.
The Webflow Cloud build fails while npm run build succeeds locally
Cause: The repository may contain export const runtime = 'edge' in a Route Handler or another copied file. Webflow Cloud deploys the application on Cloudflare Workers, but the Next.js edge runtime directive selects a separate build target that the OpenNext Cloudflare adapter doesn't support.
A scaffold or copied snippet can carry the directive into a file you did not inspect, which explains why checking only the HubSpot handler may miss it
Fix: Search the entire repository for export const runtime = 'edge' rather than inspecting one route. Remove every occurrence of the directive, leave the rest of the runtime configuration alone, and redeploy the app to Webflow Cloud. The application remains on the Workers edge platform without that export.
A successful Cloud build and a reachable mounted route confirm that the unsupported Next.js runtime target is no longer part of the deployment.
Submissions return 404 on the live site but 200 under npm run dev
Cause: The live request is probably missing the Webflow Cloud mount path. Local development serves the handler directly at /api/hubspot, while the deployed application lives below the selected path, such as /forms. Webflow Cloud does not rewrite client-side fetch calls to include that base path.
As a result, a live fetch to /api/hubspot reaches the Webflow site instead of the mounted app and returns 404.
Fix: Inspect the POST URL in the browser's Network tab and confirm that it starts with the configured mount path. Set MOUNT_PATH to that exact prefix, so the example request becomes /forms/api/hubspot, then republish the Webflow site.
For pages inside the app that fetch their own routes, use NEXT_PUBLIC_BASE_PATH. A live 200 from the prefixed URL confirms that the browser is reaching the deployed Route Handler rather than the Webflow page layer.
HubSpot returns 401 or 403 in production, but the same curl succeeds from your laptop
Cause: A live-only 401 or 403 points to an environment mismatch. The token or endpoint may exist only in .env.local, belong to a different Webflow Cloud environment, or be empty in the deployed environment.
Local Next.js development reads .env.local, but the deployed handler reads the values configured for its Webflow Cloud environment. An empty HUBSPOT_TOKEN also causes the handler to send no Authorization header.
Fix: Compare the deployed HUBSPOT_SUBMIT_URL and HUBSPOT_TOKEN values with the local names, correct any missing or mismatched values, and redeploy so the build and runtime receive them. If the same live curl still fails, inspect the status and response body emitted by console.error.
A persisting 401 means the token value itself is wrong or expired, so rotate it in HubSpot and update the secret in the correct Webflow Cloud environment.
The page reloads on submit and nothing reaches the route
Cause: The browser script can't find the form or can't take control of its submit event. A missing data-hubspot attribute makes document.querySelector('form[data-hubspot="true"]') returns nothing and causes the script to exit.
A script that doesn't load on the published page, or a listener changed from the capture-phase setup, can also leave the page's default submit handling in control and trigger a reload.
Fix: Run the selector in the browser console and confirm that it returns the intended form. Verify that the page includes the script, that the listener is registered with true for the capture phase, and that it still calls preventDefault and stopImmediatePropagation.
Republish the site and hard-refresh the page to load the current code. A POST to the mounted route in the Network tab, with no immediate page reload, confirms that the listener intercepted the submission.
What you can build next with HubSpot and Webflow
Marketers edit the page visually in Webflow while your team deploys the Route Handler on Webflow Cloud and keeps the credential in one environment.
The same handler pattern extends to anything HubSpot accepts over HTTP. Webflow's CMS now carries expanded REST APIs, so the app that forwards a submission can also read CMS content and pass relevant context to HubSpot alongside the fields.
For deeper customization beyond what a single Route Handler handles natively, Webflow's developer docs cover the Webflow Cloud runtime and the CMS APIs.
Frequently asked questions
Can I add middleware to rate-limit or block the route?
Yes, you can, with a file named middleware.ts. Webflow's framework customization docs state that Webflow Cloud supports Edge runtime middleware on the Workers runtime; Node.js runtime middleware cannot run there. Newer Next.js versions rename middleware to proxy, but proxy runs on the Node runtime and cannot opt into Edge, so a renamed proxy.ts cannot run on Webflow Cloud.
Does node:fs work for writing submissions to a log file?
You cannot write submissions to a log file with node:fs on Webflow Cloud. node:fs requires a newer Workers compatibility date than Webflow Cloud currently uses. You can use node:path if you need path handling. For a durable record, you can instead forward a copy of each submission to a store that speaks HTTP.
Can I use Astro or Vite with Webflow Cloud?
Yes, you can use Astro or Vite with Webflow Cloud. Webflow Cloud supports Next.js 15 or higher, Astro 6 or 7, and Vite 6.1 or higher with React, Vue, Svelte or vanilla JavaScript. All of them need Node.js 22 or later locally, and npm is currently the only supported package manager. The handler logic stays the same: check the origin, then forward the mapped body to HubSpot. Only the file location and export signature change per framework.
Can I verify a signed request with timingSafeEqual on this route?
On the deployed app, yes: timingSafeEqual exists on the Workers runtime. It isn't available in local next dev, so a comparison that passes in production throws on your laptop. If you add a signature check, put the comparison behind a small helper and test the deployed route with curl rather than trusting local results.




