A quote form on your own Webflow site lets buyers ask for a price on products that never belonged in a cart, and sends each request where sales already work.
Some products don't have a price you can put in a cart. Examples include configurable equipment or client-scoped work. The buyer wants to describe what they need and get a number back, but checkout asks for payment when a conversation should start.
In this guide, we build a setup that keeps the product pages in the Designer and adds a quote form as a small Next.js app on Webflow Cloud, mounted at /quote on the same site.
Each product page links to it with the product's slug, and a Route Handler validates the request on the server and forwards it as JSON to the system your sales team already uses, giving the buyer a reference number to quote back.
What do you need to create a quote request form in Webflow?
You need four things for this setup: a Webflow site, Next.js 15 or higher with npm, an HTTPS endpoint, and Designer access. A custom-domain mount requires Premium or higher.
Confirm each of these before you scaffold the project:
- Webflow site on Starter or higher: Webflow Cloud is included from the free Starter plan; mounting to a custom domain needs Premium or higher.
- Next.js 15 or higher with npm: The project must meet the Next.js 15 floor and install with npm, since Webflow Cloud does not support pnpm or yarn.
- An HTTPS endpoint that accepts JSON: A CRM inbound webhook or automation tool your sales team already triages, since the handler forwards each quote instead of storing it.
- Designer edit access: You need it to add a "Request a quote" button to each product page and publish the site once the app is mounted.
Pick the mount path before you write any code. This build uses /quote, and both the app's client code and the Designer links have to match it.
How to create a quote request form in Webflow Cloud
I built this as a Next.js app mounted at /quote. Its Route Handler validates each submission on Cloudflare Workers and forwards it to your sales system, and a button on each product page links in.
The steps run from scaffolding to the published link, with the Workers-specific choices called out where they change the code.
1. Scaffold the Next.js project with npm
Scaffold a Next.js 15 project with npm and leave the generated config untouched. Webflow Cloud deploys Next.js through its OpenNext Cloudflare adapter and injects the mount path at build time. You do not need to add a base path, an output mode, or an adapter to next.config.
Webflow Cloud supports only npm, so commit package-lock.json and no other lockfile. I pass the npm flag explicitly, even on machines where pnpm is the default, because Webflow Cloud supports only npm, and I would rather not find out after a push that the project was installed with pnpm or yarn.
Run the generator from your project's directory:
npx create-next-app@15 quote-form --typescript --app --use-npm --no-src-dir
cd quote-form
You now have a project with package-lock.json at the root and an app directory, which is where the form page and the Route Handler go.
2. Build the form page and client component
Replace the generated app/page.tsx with a page that reads the product identifier from the URL and renders the form. The page itself is a server component that awaits searchParams (a Promise in Next.js 15) and hands the product slug to a client component, so the form prefills without any client-side URL parsing.
The client component posts JSON to the Route Handler and prefixes the request path with NEXT_PUBLIC_BASE_PATH. Webflow Cloud injects the mount path at build time, but the docs say client-side fetch calls must include the base path themselves.
A browser request to /api/quote from a page at /quote would miss the handler unless the path includes the mount. A hidden honeypot field rounds out the form; bots fill it, people never see it.
The server component page
Start with the server component at app/page.tsx:
import QuoteForm from './quote-form';
type PageProps = {
searchParams: Promise<{ product?: string }>;
};
export default async function QuotePage({ searchParams }: PageProps) {
const { product } = await searchParams;
return (
<main>
<h1>Request a quote</h1>
<QuoteForm product={product ?? ''} />
</main>
);
}
That gives you a page at the mount path that reads ?product=some-slug and passes it down. The client component carries the submit logic.
The interactive form component
Save the form as app/quote-form.tsx:
'use client';
import { useState, type FormEvent } from 'react';
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? '';
type Status = 'idle' | 'sending' | 'sent' | 'error';
export default function QuoteForm({ product }: { product: string }) {
const [status, setStatus] = useState<Status>('idle');
const [reference, setReference] = useState('');
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setStatus('sending');
const form = new FormData(event.currentTarget);
const payload = {
product: form.get('product'),
quantity: Number(form.get('quantity')),
name: form.get('name'),
email: form.get('email'),
notes: form.get('notes'),
website: form.get('website'),
};
try {
const response = await fetch(`${basePath}/api/quote`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
setStatus('error');
return;
}
const data = (await response.json()) as { reference: string };
setReference(data.reference);
setStatus('sent');
} catch {
setStatus('error');
}
}
if (status === 'sent') {
return <p>Thanks. Your quote reference is {reference}. Expect a reply by email.</p>;
}
return (
<form onSubmit={handleSubmit}>
<label>
Product
<input name="product" defaultValue={product} readOnly />
</label>
<label>
Quantity
<input name="quantity" type="number" min="1" defaultValue="1" required />
</label>
<label>
Name
<input name="name" required />
</label>
<label>
Email
<input name="email" type="email" required />
</label>
<label>
Notes
<textarea name="notes" rows={4} />
</label>
<div style={{ display: 'none' }} aria-hidden="true">
<input name="website" tabIndex={-1} autoComplete="off" />
</div>
<button type="submit" disabled={status === 'sending'}>
{status === 'sending' ? 'Sending...' : 'Request quote'}
</button>
{status === 'error' && <p>Something went wrong. Please try again.</p>}
</form>
);
}
Running npm run dev and opening the local development URL with ?product=steel-frame-200 should render the form with the product field already filled. Submitting it fails at this point because the Route Handler at app/api/quote/route.ts does not exist yet.
3. Write the Route Handler that validates and forwards
Create the Route Handler by adding a POST handler at app/api/quote/route.ts that rejects bad input, drops honeypot hits, and forwards the clean payload to the endpoint in QUOTE_WEBHOOK_URL.
Validation happens here rather than in the browser because the browser is the one place you cannot trust; a required input attribute stops nobody who posts JSON directly.
The honeypot branch returns the same response shape as a real submission, with a generated reference, without forwarding anything. The handler also caps field lengths and accepts only a slug-shaped product, so oversized or arbitrary text never reaches your sales system.
The handler forwards instead of storing because node:fs is not available at the compatibility date Webflow Cloud pins, and the right destination for a quote is the system where someone will respond to it.
Keep the file free of any runtime directive; the handler already runs at the edge because Webflow Cloud deploys it to Cloudflare Workers.
The Route Handler code
The handler in full:
import { NextResponse } from 'next/server';
type QuotePayload = {
product?: unknown;
quantity?: unknown;
name?: unknown;
email?: unknown;
notes?: unknown;
website?: unknown;
};
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
// A short random suffix keeps two quotes in the same millisecond from sharing a reference.
function newReference(): string {
return `Q-${Date.now().toString(36).toUpperCase()}-${crypto.randomUUID().slice(0, 4).toUpperCase()}`;
}
export async function POST(request: Request) {
let body: QuotePayload;
try {
body = (await request.json()) as QuotePayload;
} catch {
return NextResponse.json({ error: 'Body must be JSON.' }, { status: 400 });
}
if (isNonEmptyString(body.website)) {
// Same response shape as a real submission, so a bot learns nothing.
return NextResponse.json({ reference: newReference() });
}
const quantity = Number(body.quantity);
const errors: string[] = [];
if (!isNonEmptyString(body.product) || !/^[a-z0-9-]{1,100}$/.test(body.product)) errors.push('product');
if (!Number.isInteger(quantity) || quantity < 1) errors.push('quantity');
if (!isNonEmptyString(body.name) || body.name.length > 200) errors.push('name');
if (!isNonEmptyString(body.email) || !body.email.includes('@') || body.email.length > 254) errors.push('email');
if (isNonEmptyString(body.notes) && body.notes.length > 2000) errors.push('notes');
if (errors.length > 0) {
return NextResponse.json({ error: 'Invalid fields.', fields: errors }, { status: 400 });
}
const webhookUrl = process.env.QUOTE_WEBHOOK_URL;
if (!webhookUrl) {
return NextResponse.json({ error: 'Quote destination not configured.' }, { status: 500 });
}
const reference = newReference();
let forward: Response;
try {
forward = await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
reference,
product: body.product,
quantity,
name: body.name,
email: body.email,
notes: isNonEmptyString(body.notes) ? body.notes : '',
receivedAt: new Date().toISOString(),
}),
});
} catch {
return NextResponse.json({ error: 'Quote could not be delivered.' }, { status: 502 });
}
if (!forward.ok) {
return NextResponse.json({ error: 'Quote could not be delivered.' }, { status: 502 });
}
return NextResponse.json({ reference });
}
The handler now rejects bad input, drops honeypot hits, and forwards the clean payload to the configured endpoint.
Validation and security checks
Review the handler's security boundaries:
- Before production: The form is public, and the honeypot filters simple bots but is not a rate limit, so add one. The outbound request is unsigned; the FAQ below shows how to sign it so your sales system can reject anything that did not come from this handler.
- Secret exposure:
NEXT_PUBLIC_BASE_PATHis intentionally browser-visible and contains only /quote;QUOTE_WEBHOOK_URLis notNEXT_PUBLIC_-prefixed and remains server-side.
A valid submission returns a reference and forwards JSON with a receivedAt timestamp to your webhook endpoint. A missing field returns 400 with the field names listed, and a failed webhook request returns 502 so the browser shows the retry message rather than a false success.
4. Create the Webflow Cloud environment and set environment variables
I create a Webflow Cloud environment for the site from the repository and set /quote as the environment's mount path; the bring-your-own-app guide documents the current setup flow.
I use the same value for NEXT_PUBLIC_BASE_PATH below. If the two differ, the client fetch will likely miss the handler and return 404.The environment configuration should show /quote as the mount path.
When I configure the environment variables, I add QUOTE_WEBHOOK_URL as a secret and NEXT_PUBLIC_BASE_PATH as a plain value of /quote.
Webflow Cloud exposes both kinds to the build and the deployed app; as the docs say, "Both secret and non-secret environment variables are available to your application's build process and to the deployed application at runtime."
That build-time availability is what makes the public variable work, since Next.js inlines NEXT_PUBLIC_ values into the client bundle when it compiles.
Secrets are redacted from build logs, which is why the webhook URL belongs in that category; an agency handing the site over should not have a client's CRM endpoint sitting in plain text in a log. Locally, you run next dev without a mount path, so leave the value empty in .env.local.
Local environment variables
Create .env.local with these two lines:
QUOTE_WEBHOOK_URL=https://example.com/your-inbound-webhook
NEXT_PUBLIC_BASE_PATH=
Replace the example webhook URL with your test endpoint, restart next dev, and a submission reaches that endpoint; on the live site, the browser's fetch resolves to /quote/api/quote once you deploy in the next step.
5. Deploy and link from the Designer
Deploy the app from the Webflow Cloud environment where you set the /quote mount path, then point a "Request a quote" button on each product page at /quote. A custom-domain mount needs Premium or higher, as listed in the prerequisites.
Once the deploy is green, open the site in the Designer and select the button on a product page. Set the button's link to the URL /quote?product= followed by the product's slug.
For product pages managed in a CMS Collection, keep each item's quote URL with its CMS content and use that value for the button link. Should each URL include your domain and end in /quote?product= followed by the item's slug. Publish the site.
The trade-off is upkeep: each new item needs its quote URL added, and the stored domain has to change if the site's domain does.
On a Collection page, the button uses the stored quote URL.
Clicking "Request a quote" on a published product page now lands on the form with the product already filled, and a completed submission produces a reference on screen and a JSON record at your endpoint within the same request.
What causes a quote request form on Webflow Cloud to fail?
I get most quote forms working again quickly by checking four platform-specific causes: an edge runtime directive, a missing /quote prefix, using pnpm or yarn, or a node:fs import.
Each entry starts with what you see, followed by the platform-specific cause and the change that clears it.
The Webflow Cloud build fails after adding an edge runtime directive
Cause: The build succeeds when the Route Handler omits the Next.js edge runtime target because Webflow Cloud deploys Next.js through the OpenNext Cloudflare adapter, which does not support that target.
A Route Handler carrying export const runtime = 'edge' ships a broken build. The confusion is understandable because "edge" means two different things on the same documentation page.
The bring-your-own-app page describes the platform: "Webflow Cloud deploys your app using the Edge runtime, enabling fast, globally distributed hosting". That statement describes Cloudflare Workers, an edge platform your code runs on regardless. OpenNext does not support the separate Next.js runtime target, and leaving it out resolves this failure.
Fix: Remove the directive from app/api/quote/route.ts and from any other route or page where it appears, then redeploy. Your handler already executes at the edge because the platform puts it there; nothing in the code needs to opt in.
If you later add middleware, keep it in middleware.ts. Next.js 16's proxy.ts runs on the Node runtime and cannot opt into Edge, while Webflow Cloud supports only Edge runtime middleware on the Workers runtime.
Submitting the form returns a 404 from /api/quote on the live site but works under next dev
Cause: The request reaches the handler when the browser posts to /quote/api/quote. In this case, the browser is posting to /api/quote at the site root even though the handler lives at /quote/api/quote.
Webflow Cloud injects the mount path at build time, but the bring-your-own-app page is explicit about the browser: "Client-side fetch calls must manually include the base path to correctly reach your endpoints."
Locally, the mount path is empty, so the same code works under next dev and fails only after deploy, which is what makes this one feel like a platform bug rather than a path mismatch.
Reading basePath out of next.config was the older workaround, and that pattern no longer applies now that the docs say you do not need to add a base path there.
Fix: Set NEXT_PUBLIC_BASE_PATH to /quote on the Webflow Cloud environment and keep the fetch call prefixed with it, as in the client component. Confirm the value matches the mount path character for character, including the leading slash and no trailing slash. Redeploy, open the browser's network panel, and check that the POST goes to /quote/api/quote and returns the JSON reference.
The Webflow Cloud build fails before compiling after the project was installed with pnpm or yarn
Cause: Webflow Cloud installs with npm only, and the docs state it directly: "Currently, Webflow Cloud supports only the npm package manager." A repository scaffolded or later installed with pnpm or yarn carries pnpm-lock.yaml or yarn.lock, which belongs to a package manager the platform does not run.
This can surface when a second developer with a different default package manager installs dependencies and commits a lockfile. The build may stop before it reaches your code, so the handler and page look fine and the failure appears to come from nowhere.
Fix: Delete pnpm-lock.yaml or yarn.lock, delete node_modules, run npm install, and commit the resulting package-lock.json. Replace any pnpm add or yarn add instructions in your project README with npm install so the next person does not reintroduce the problem. Push and redeploy; the build should then move on to compiling the app.
The deployed handler errors on a node:fs import
Cause: Webflow Cloud runs your handler on Cloudflare Workers, not on a Node server, and node:fs is unavailable at the compatibility date Webflow Cloud pins. The module requires a newer compatibility date than the platform currently sets, so an import of node:fs in a Route Handler may fail once deployed.
Developers coming from a Node host reach for appendFile as a quick way to log quotes to disk, and because local Node has a file system, the code may appear to work under next dev.
Nothing syntactic flags the problem; the code is valid TypeScript that happens to target a runtime the deployed app does not have. The missing module is also why the handler in this build forwards each quote rather than persisting it anywhere on the platform.
Fix: Remove the node:fs import and send each submission through fetch to the endpoint in QUOTE_WEBHOOK_URL, where your CRM or automation tool stores it. If you only needed path helpers, node:path is available on the runtime and can stay.
For a durable record on your side, pick an external store with an HTTP API and forward to it from the same handler, since any persistence has to live outside the Worker.
What you can build next with quote request forms and Webflow
Marketing keeps editing product pages in the Designer, and the one technical person on the team owns a handler small enough to read in a sitting. For a durable record, route each quote to your CRM, as in HubSpot without Zapier, or to an external store with an HTTP API from the same handler.
For deeper customization beyond a single Route Handler, Webflow's developer docs cover environment variables and the framework constraints on the Workers runtime.
Frequently asked questions
Can a native Webflow form handle quote requests instead of a Webflow Cloud app?
Often, yes. A native form can send each submission to a webhook while keeping Webflow's storage, notifications and spam protection; on the free Starter plan it is capped at 50 submissions in total. Without code, it cannot prefill the product from the page link or return a reference number, and its validation stays limited to the built-in checks. Build the app when you need those.
What happens to a quote if the webhook endpoint is down?
If the webhook endpoint is down, you see the retry message and can resubmit, but Webflow Cloud keeps no copy of the failed quote. If losing a request is unacceptable, forward submissions to an endpoint that queues each request when it arrives, giving you a durable record outside this build when delivery fails.
How does the webhook know a request came from the quote handler?
Sign it, the way Webflow signs its own webhooks. Keep a shared secret in a second secret variable, send a timestamp header, and add an HMAC-SHA256 of the timestamp, a colon and the raw body, made with crypto.subtle. The receiver recomputes it, compares in constant time, and rejects anything older than five minutes.
Can two quotes get the same reference number?
Not in practice. A timestamp alone could repeat, because Workers advance Date.now() only between I/O and requests run in parallel across locations, so each reference also carries four random characters. The reference is for a person to quote back; use your sales system's own record ID as the true key.





