# 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.

Published 2026-10-25 · https://ikiform.com/guides/lead-capture/nextjs

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](https://ikiform.com/guides/lead-capture.md#form) 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`.

Diagram: How a Next.js lead travels from the first ad click to a booked call

1. The visitor lands from a campaign link with UTM tags or from another site's link.
2. proxy.ts stores the tags, the referrer and the landing path in an httpOnly cookie, once.
3. The pricing and contact pages stay static and come from the cache.
4. On submit, the server action validates the fields with Zod and reads the cookie.
5. It posts the lead, the attribution and a computed fit to Ikiform with the secret key.
6. A qualified lead is redirected to the scheduler with name and email filled in; others go to the thank-you page.
7. A validation error comes back as action state, with every value the visitor typed.

The cookie carries the first touch across pages, so the contact page never has to read the URL.

## 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](https://ikiform.com/docs/guides/nextjs.md) covers the key and the form setup in detail.

`files`:

```text
app/
  contact-sales/
    page.tsx
    actions.ts
    lead-form.tsx
    thanks/page.tsx
lib/
  lead.ts
proxy.ts
.env.local
```

`.env.local`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
```

Then 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 |

> The server-side versions post with a secret key, so Ikiform skips **Allowed domains** and its human check. Every post then comes from your server's IP, and the Standard limit of 5 a minute per visitor counts all your visitors together. Choose the Relaxed or Custom [rate limit](https://ikiform.com/docs/rate-limits.md) for a form you post through a server, and keep `IKIFORM_SECRET_KEY` in server-only environment variables.

## First-touch attribution in proxy.ts

`proxy.ts`:

```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 `has` check 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.
- `URLSearchParams` gives 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.

`lib/lead.ts`:

```ts
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;
}
```

`app/contact-sales/actions.ts`:

```ts
"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.flattenError` turns its issues into one message per field.
- The browser sends an `Origin` header with every server action post, and the action forwards it: a server-side fetch sends none, and Ikiform detects free `localhost` tests from it.
- `fit` is computed on the server. A visitor can't mark themselves qualified by editing the form.
- Both `redirect()` calls sit outside any `try` block. `redirect()` works by throwing, and a `catch` would 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

`app/contact-sales/lead-form.tsx`:

```tsx
"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>
  );
}
```

`app/contact-sales/page.tsx`:

```tsx
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](https://ikiform.com/guides/spam-protection/cloudflare-turnstile.md).

## 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](https://ikiform.com/pricing).

1. Run `next dev` and open `/pricing?utm_source=test&utm_campaign=launch`. Check the `lead_source` cookie in your browser's dev tools.
2. Click through to `/contact-sales`, leave **Company size** empty and submit. The error shows under the field and the other values stay.
3. 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.
4. Submit as a 1 to 10 person team. You land on `/contact-sales/thanks`.
5. Open **Submissions** in the dashboard. Both leads are there with `utm_source`, `landing_page` and `fit`.
6. 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 `email` field.

## 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

### Can a Next.js server action redirect to an external URL?

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.

### Why capture UTM parameters in proxy.ts instead of on the contact page?

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.

### Do I need a database to qualify leads in Next.js?

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.

### Is Ikiform free?

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.

## More guides

- [Lead capture form](https://ikiform.com/guides/lead-capture.md): A lead capture form on your own site that emails you with reply-to, pings team chat and feeds your CRM, so you answer while the lead is still there.
- [Demo request form](https://ikiform.com/guides/lead-capture/demo-request.md): A book-a-demo form that stores the lead, then sends the visitor to Cal.com or Calendly with their name and email already filled in.
- [Astro lead form](https://ikiform.com/guides/lead-capture/astro.md): An Astro lead form built on Actions: Zod validation, first-touch UTMs on a static site, an on-demand contact page and a redirect to your scheduler.
- [React Router lead form](https://ikiform.com/guides/lead-capture/react-router.md): A React Router framework-mode lead form: root middleware with a signed first-touch cookie, a route action that validates, and a redirect to your scheduler.
- [Quote request form](https://ikiform.com/guides/lead-capture/quote-request.md): A request-a-quote form that collects briefs as shared links instead of uploads, puts a due date on every request, and keeps link-heavy leads out of spam.
- [Consultation request form](https://ikiform.com/guides/lead-capture/consultation-request.md): A consultation request form for accountants, lawyers, coaches and clinics that asks how and when to call, and leaves sensitive details for the call.
