Lead capture form

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.

Preet SutharFounder, Ikiform

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.

How a Next.js lead travels from the first ad click to a booked callfirst visitsubmitad or link?utm_source=...proxy.tssets lead_sourcecookiestatic pageserved from cacheserver actionZod, reads the cookieIkiformlead with UTMs andfitredirect()Cal.com, prefilledaction stateerrors and typedvaluesGETnext()JSONok422 or Zod errorHow a Next.js lead travels from the first ad click to a booked callfirst visitsubmitad or link?utm_source=...proxy.tssets lead_sourcecookiestatic pageserved from cacheserver actionZod, reads thecookieIkiformlead with UTMs andfitredirect()Cal.com, prefilledactionstateerrors andtypedvaluesGETnext()JSONok422 or Zod error
  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 covers the key and the form setup in detail.

files
app/
  contact-sales/
    page.tsx
    actions.ts
    lead-form.tsx
    thanks/page.tsx
lib/
  lead.ts
proxy.ts
.env.local
.env.local
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 idType in IkiformSet by
name, email, companyText, email, textThe visitor
company_sizeSelect: 1_10, 11_50, 51_200, 201_1000, 1000_plusThe visitor
budgetSelect: under_10k, 10k_50k, 50k_plus, unsureThe visitor
timelineSelect: this_quarter, next_quarter, later, researchingThe visitor
messageTextarea, optionalThe visitor
utm_source, utm_medium, utm_campaign, referrer, landing_pageText, optionalYour server, from the first visit
fitSelect: qualified, nurtureYour 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 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
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
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
"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
"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
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:

HostWhat to know
VercelProxy and server actions work with no config. Add IKIFORM_SECRET_KEY under the project's environment variables for Production and Preview.
NetlifyNext.js runs through Netlify's adapter with no config. Set the key in the site's environment variables, scoped to functions.
CloudflareDeploy 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.

  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

ProblemWhat to do
The UTM fields arrive emptyThe 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 nothingredirect() was called inside try. Move it after the block, as in the sample.
422 with Unknown field for fit or referrerThe form's field definition doesn't list it. Add every id from the table as an optional field.
402 test_limit_reachedThe 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 errorThe key prop is missing, so React keeps the reset select. Key each one on its returned value.

Questions

Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.