Lead capture form8 min read
Build a qualifying lead form in Next.js
A Next.js App Router lead form that validates with Zod in a server action, keeps first-touch UTMs in a cookie and sends qualified leads to your calendar.
Capture campaign tags and the referrer in proxy.ts on the first visit, and keep them in a cookie. Validate the lead with Zod in a server action, score it on company size, budget and timeline, and forward it with the attribution to your form backend. Then call redirect() to send qualified leads to your scheduler and everyone else to a thank-you page.
The Next.js tab on the main guide forwards a lead and shows a thank-you line. This page builds the sales version: three qualifying questions checked on your server, campaign data that survives the visitor clicking around before they convert, and a direct hand-off to your calendar for leads worth a call.
What Next.js changes
Two Next.js features do most of the work. proxy.ts, the file that was called middleware.ts before Next.js 16, runs before every page request, even for static pages. That makes it the right place to read ?utm_source= and the Referer header on the first visit, because by the time the visitor reaches Talk to sales both are gone from the URL. The server action then reads the cookie, so the contact page itself can stay static.
The second is redirect(). In a server action it accepts an absolute URL, so a qualified lead lands on Cal.com without any client code. With JavaScript loaded, Next.js navigates on the client; before hydration, it answers the form post with a 303.
- The visitor lands from a campaign link with UTM tags or from another site's link.
- proxy.ts stores the tags, the referrer and the landing path in an httpOnly cookie, once.
- The pricing and contact pages stay static and come from the cache.
- On submit, the server action validates the fields with Zod and reads the cookie.
- It posts the lead, the attribution and a computed fit to Ikiform with the secret key.
- A qualified lead is redirected to the scheduler with name and email filled in; others go to the thank-you page.
- A validation error comes back as action state, with every value the visitor typed.
Project setup
Start from any App Router project on Next.js 16. Install zod, create a form in the dashboard and a secret key with the submissions:write scope under API keys. The Next.js docs page covers the key and the form setup in detail.
app/
contact-sales/
page.tsx
actions.ts
lead-form.tsx
thanks/page.tsx
lib/
lead.ts
proxy.ts
.env.localIKIFORM_SECRET_KEY=ik_sk_your_secret_keyThen add the fields on the form's Fields tab, so Ikiform enforces them too. Without a definition Ikiform stores whatever you send; with one, an unknown field is a 422, which is what you want once attribution is in the payload.
| Field id | Type in Ikiform | Set by |
|---|---|---|
name, email, company | Text, email, text | The visitor |
company_size | Select: 1_10, 11_50, 51_200, 201_1000, 1000_plus | The visitor |
budget | Select: under_10k, 10k_50k, 50k_plus, unsure | The visitor |
timeline | Select: this_quarter, next_quarter, later, researching | The visitor |
message | Textarea, optional | The visitor |
utm_source, utm_medium, utm_campaign, referrer, landing_page | Text, optional | Your server, from the first visit |
fit | Select: qualified, nurture | Your server, after validation |
IKIFORM_SECRET_KEY in server-only environment variables.First-touch attribution in proxy.ts
import { type NextRequest, NextResponse } from "next/server";
const TAGS = ["utm_source", "utm_medium", "utm_campaign"];
export function proxy(request: NextRequest) {
const response = NextResponse.next();
if (request.cookies.has("lead_source")) return response;
const url = request.nextUrl;
const referer = request.headers.get("referer") ?? "";
const from = URL.canParse(referer) ? new URL(referer) : null;
const external = from && from.host !== url.host ? from.href : "";
const source = new URLSearchParams();
for (const tag of TAGS) source.set(tag, url.searchParams.get(tag)?.slice(0, 100) ?? "");
if (!external && !TAGS.some((tag) => source.get(tag))) return response;
source.set("referrer", external.slice(0, 300));
source.set("landing_page", url.pathname.slice(0, 200));
response.cookies.set("lead_source", source.toString(), {
httpOnly: true,
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 30,
});
return response;
}
export const config = {
matcher: [
{
source: "/((?!api|_next/static|_next/image|favicon.ico).*)",
missing: [
{ type: "header", key: "next-router-prefetch" },
{ type: "header", key: "purpose", value: "prefetch" },
],
},
],
};- The cookie is set once and never overwritten, so it records the first campaign, not the last internal click. Delete the
hascheck if you prefer last touch. - A referrer from your own host is ignored. Without that, every page view after the first would count as a referral from yourself.
- The matcher skips static files and router prefetches. Prefetches carry your own page as the referrer and would otherwise run the proxy for every link on screen.
URLSearchParamsgives a compact cookie value and a safe parser. The action reads only the five keys it knows, so a visitor who edits the cookie can't add fields.
Validation, scoring and the hand-off
The schema lives in its own module so the action and any future API route share it. The fit rule is deliberately plain: at least eleven people, a budget of $10k or more, and a start date within two quarters.
import { z } from "zod";
export const LeadSchema = z.object({
name: z.string().trim().min(1, "Tell us who to reply to.").max(120),
email: z.email("Enter a work email we can reply to."),
company: z.string().trim().min(1, "Which company is this for?").max(200),
company_size: z.enum(["1_10", "11_50", "51_200", "201_1000", "1000_plus"], { error: "Pick a company size." }),
budget: z.enum(["under_10k", "10k_50k", "50k_plus", "unsure"], { error: "Pick a budget range." }),
timeline: z.enum(["this_quarter", "next_quarter", "later", "researching"], { error: "Pick a timeline." }),
message: z.string().trim().max(5000).optional(),
});
export type Lead = z.infer<typeof LeadSchema>;
export function isQualified(lead: Lead) {
const bigEnough = lead.company_size !== "1_10";
const funded = lead.budget === "10k_50k" || lead.budget === "50k_plus";
const soon = lead.timeline === "this_quarter" || lead.timeline === "next_quarter";
return bigEnough && funded && soon;
}"use server";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { z } from "zod";
import { isQualified, LeadSchema } from "@/lib/lead";
export interface LeadState {
errors: Record<string, string>;
values: Record<string, string>;
}
const SOURCE = ["utm_source", "utm_medium", "utm_campaign", "referrer", "landing_page"];
export async function submitLead(_previous: LeadState, formData: FormData): Promise<LeadState> {
const values = Object.fromEntries([...formData].map(([key, value]) => [key, String(value)]));
const parsed = LeadSchema.safeParse(values);
if (!parsed.success) {
const { fieldErrors } = z.flattenError(parsed.error);
const errors = Object.fromEntries(Object.entries(fieldErrors).map(([key, list]) => [key, list?.[0] ?? ""]));
return { errors, values };
}
const stored = new URLSearchParams((await cookies()).get("lead_source")?.value ?? "");
const source = Object.fromEntries(SOURCE.map((key) => [key, stored.get(key) ?? ""]));
const origin = (await headers()).get("origin");
const qualified = isQualified(parsed.data);
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({
...parsed.data,
...source,
fit: qualified ? "qualified" : "nurture",
_gotcha: values._gotcha ?? "",
}),
signal: AbortSignal.timeout(8000),
}).catch(() => null);
const result = await response?.json().catch(() => null);
if (!result?.ok) {
const fields: Record<string, string[]> = result?.error?.fields ?? {};
const errors = Object.fromEntries(Object.entries(fields).map(([key, list]) => [key, list[0] ?? ""]));
if (!Object.keys(errors).length) errors.form = "We couldn't send that. Email sales@example.com instead.";
return { errors, values };
}
if (!qualified || values._gotcha) redirect("/contact-sales/thanks");
const { name, email } = parsed.data;
redirect(`https://cal.com/acme/intro?${new URLSearchParams({ name, email })}`);
}- Zod runs before anything leaves your server, and
z.flattenErrorturns its issues into one message per field. - The browser sends an
Originheader with every server action post, and the action forwards it: a server-side fetch sends none, and Ikiform detects freelocalhosttests from it. fitis computed on the server. A visitor can't mark themselves qualified by editing the form.- Both
redirect()calls sit outside anytryblock.redirect()works by throwing, and acatchwould swallow it. - A filled honeypot still gets posted, so Ikiform records it as spam, but it never reaches your calendar.
The form, with and without JavaScript
"use client";
import { useActionState } from "react";
import { type LeadState, submitLead } from "./actions";
const initial: LeadState = { errors: {}, values: {} };
function FieldError({ message }: { message?: string }) {
return message ? <p role="alert">{message}</p> : null;
}
export function LeadForm() {
const [state, action, pending] = useActionState(submitLead, initial);
const { errors, values } = state;
return (
<form action={action}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required defaultValue={values.name} />
<FieldError message={errors.name} />
<label htmlFor="email">Work email</label>
<input id="email" name="email" type="email" autoComplete="email" required defaultValue={values.email} />
<FieldError message={errors.email} />
<label htmlFor="company">Company</label>
<input id="company" name="company" autoComplete="organization" required defaultValue={values.company} />
<FieldError message={errors.company} />
<label htmlFor="company_size">Company size</label>
<select id="company_size" name="company_size" required defaultValue={values.company_size ?? ""} key={values.company_size}>
<option value="" disabled>Choose one</option>
<option value="1_10">1 to 10</option>
<option value="11_50">11 to 50</option>
<option value="51_200">51 to 200</option>
<option value="201_1000">201 to 1,000</option>
<option value="1000_plus">More than 1,000</option>
</select>
<FieldError message={errors.company_size} />
<label htmlFor="budget">Budget</label>
<select id="budget" name="budget" required defaultValue={values.budget ?? ""} key={values.budget}>
<option value="" disabled>Choose one</option>
<option value="under_10k">Under $10k</option>
<option value="10k_50k">$10k to $50k</option>
<option value="50k_plus">More than $50k</option>
<option value="unsure">Not sure yet</option>
</select>
<FieldError message={errors.budget} />
<label htmlFor="timeline">When do you want to start?</label>
<select id="timeline" name="timeline" required defaultValue={values.timeline ?? ""} key={values.timeline}>
<option value="" disabled>Choose one</option>
<option value="this_quarter">This quarter</option>
<option value="next_quarter">Next quarter</option>
<option value="later">Later this year</option>
<option value="researching">Researching for now</option>
</select>
<FieldError message={errors.timeline} />
<label htmlFor="message">Anything we should know?</label>
<textarea id="message" name="message" rows={4} defaultValue={values.message} />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
<FieldError message={errors.form} />
<button disabled={pending}>{pending ? "Sending" : "Talk to sales"}</button>
</form>
);
}import { LeadForm } from "./lead-form";
export default function ContactSales() {
return (
<main>
<h1>Talk to sales</h1>
<p>Tell us a little about your team. If we look like a fit, you pick a call time on the next screen.</p>
<LeadForm />
</main>
);
}useActionState gives the form its pending flag and the returned errors. Because the action is a server function, the form also posts before hydration: Next.js renders the result on the server and the visitor sees the same errors, only with a full page load.
React resets a form after its action runs, so every error path returns values and each input reads it back as defaultValue. The key on each select forces it to remount with the returned choice; a select doesn't pick up a changed defaultValue on its own.
Honeypot and human check
The hidden honeypot is forwarded as is. Ikiform's own human check doesn't run on a post with a secret key, so if this form draws bot traffic, verify a Turnstile token in the action before posting, as in Turnstile on a form.
Rendering and deploy
Nothing on the contact page reads request data, so next build prerenders it. The proxy and the action run per request. Check how your host runs them:
| Host | What to know |
|---|---|
| Vercel | Proxy and server actions work with no config. Add IKIFORM_SECRET_KEY under the project's environment variables for Production and Preview. |
| Netlify | Next.js runs through Netlify's adapter with no config. Set the key in the site's environment variables, scoped to functions. |
| Cloudflare | Deploy with the OpenNext Cloudflare adapter and store the key with wrangler secret put. Check its docs for current proxy support before you rely on the cookie. |
Check it works
Posts from next dev carry a localhost origin. Ikiform has no free plan. Testing is free: a workspace without a plan accepts 50 test submissions a month from localhost. To go live, pick Pro from $6/mo for 500 submissions, or pay once: 10,000 submissions for $49 or 50,000 submissions for $149, with credits that never expire. See pricing.
- Run
next devand open/pricing?utm_source=test&utm_campaign=launch. Check thelead_sourcecookie in your browser's dev tools. - Click through to
/contact-sales, leave Company size empty and submit. The error shows under the field and the other values stay. - Submit as a qualified lead: 51 to 200 people, $10k to $50k, this quarter. You land on Cal.com with your name and email filled in.
- Submit as a 1 to 10 person team. You land on
/contact-sales/thanks. - Open Submissions in the dashboard. Both leads are there with
utm_source,landing_pageandfit. - Check the inbox of each notification recipient. Each gets an email with the fields, and Reply goes to the submitter when the form has an
emailfield.
Gotchas
| Problem | What to do |
|---|---|
| The UTM fields arrive empty | The cookie was set before you added the tags, or the proxy file isn't at the project root (or in src/ if you use one). Clear the cookie and reload with the tags. |
| The redirect never happens and the form shows nothing | redirect() was called inside try. Move it after the block, as in the sample. |
422 with Unknown field for fit or referrer | The form's field definition doesn't list it. Add every id from the table as an optional field. |
402 test_limit_reached | The workspace has no plan and used its 50 free localhost tests this month. Pick a plan, or use Send test in the dashboard. |
| The selects reset to Choose one after an error | The key prop is missing, so React keeps the reset select. Key each one on its returned value. |
Questions
Yes. redirect() from next/navigation accepts an absolute URL. In a server action it navigates on the client when JavaScript is loaded and answers with a 303 for a form posted before hydration. Call it outside any try block, because it works by throwing.
Visitors rarely convert on the page they land on. Proxy runs on the first request, stores the tags and referrer in a cookie, and the server action reads it later. The contact page stays static because it never reads the URL.
No. The server action scores the lead, forwards it to Ikiform with the result in a fit field, and redirects. Ikiform stores the lead, emails your team and can send a webhook to your CRM.
No, there is no free plan. Testing is free: you can send 50 test submissions a month from localhost before you pay. To go live, Pro starts at $6/mo for 500 submissions, or you can pay once for 10,000 submissions for $49 or 50,000 submissions for $149, with credits that never expire.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.