# Build a qualifying lead form in TanStack Start

> A TanStack Start lead form: global request middleware for first-touch UTMs, a server function with Zod, and a thrown redirect to your scheduler.

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

Record UTM tags and the referrer on the first page request with a global request middleware in `src/start.ts`, and keep them in an httpOnly cookie. Validate the lead in a `createServerFn` handler with Zod, forward it with the attribution to your form backend, and throw `redirect({ href })` to send qualified leads to your scheduler. Call the server function through `useServerFn` so the redirect is followed for you.

The [TanStack Start tab on the main guide](https://ikiform.com/guides/lead-capture.md#form) sends a lead through a server function and shows a thank-you line. This page builds the sales version: three qualifying questions validated on the server, the campaign that brought the visitor, and a redirect straight to your calendar when the lead fits.

## What TanStack Start changes

Start has no file-based middleware convention. Anything that should run on every request goes in `src/start.ts` as global request middleware, which runs before SSR, server routes and server functions alike. That's where the first touch is recorded.

The catch: Start installs CSRF protection for server functions automatically only when `src/start.ts` doesn't exist. The moment you create the file for attribution, you own that protection and must add `createCsrfMiddleware()` yourself. Forget it and your lead endpoint accepts cross-site posts.

Diagram: How a lead moves through TanStack Start's middleware and server function

1. Every request passes the CSRF middleware first; it checks only server function calls.
2. On a GET with no cookie yet, firstTouch reads the UTM tags and an outside referrer and stores them.
3. The page renders as usual.
4. On submit, useServerFn posts the FormData to the server function.
5. The validator turns it into a plain object and the handler runs Zod on it.
6. A valid lead goes to Ikiform with the attribution, the fit and the visitor's Origin.
7. The handler throws a redirect to Cal.com or the thank-you route; useServerFn follows it. Errors come back as data.

Writing src/start.ts replaces Start's default request middleware, so CSRF goes back in by hand.

## Project setup

Start from a TanStack Start project on React and install `zod`. Put the secret key in `.env` as `IKIFORM_SECRET_KEY`; server functions read it from `process.env`, and it never reaches the client bundle. There's no dedicated Start page in the docs; [posting from a server](https://ikiform.com/docs/guides/server.md) covers the key and the endpoint.

`files`:

```text
src/
  start.ts
  lib/lead.ts
  routes/
    contact-sales.tsx
    contact-sales.thanks.tsx
vite.config.ts
.env
```

Define the fields on the form's **Fields** tab, so Ikiform rejects anything that doesn't match, including attribution keys you didn't plan for.

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

## Global middleware for the first touch

`src/start.ts`:

```ts
import { createCsrfMiddleware, createMiddleware, createStart } from "@tanstack/react-start";
import { getCookie, setCookie } from "@tanstack/react-start/server";

const TAGS = ["utm_source", "utm_medium", "utm_campaign"];

const csrf = createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === "serverFn" });

const firstTouch = createMiddleware().server(({ request, next }) => {
  if (request.method !== "GET" || getCookie("lead_source")) return next();

  const url = new URL(request.url);
  const referer = request.headers.get("referer") ?? "";
  const from = URL.canParse(referer) ? new URL(referer) : null;
  const external = from && from.host !== url.host ? from.href.slice(0, 300) : "";
  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))) {
    source.set("referrer", external);
    source.set("landing_page", url.pathname.slice(0, 200));
    setCookie("lead_source", source.toString(), {
      httpOnly: true,
      sameSite: "lax",
      path: "/",
      maxAge: 60 * 60 * 24 * 30,
    });
  }
  return next();
});

export const startInstance = createStart(() => ({
  requestMiddleware: [csrf, firstTouch],
}));
```

- The filter keeps the CSRF check on server functions, as Start's docs recommend for global use. It compares `Sec-Fetch-Site`, `Origin` or `Referer` with the request's origin.
- `firstTouch` skips anything but GET, so server function posts never touch the cookie.
- `setCookie` and `getCookie` come from `@tanstack/react-start/server` and only run on the server.
- A cookie set during a request isn't readable by `getCookie` until the next one. That's fine here: the lead submits on a later request.

## The server function

`src/lib/lead.ts`:

```ts
import { redirect } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
import { getCookie, getRequestHeader } from "@tanstack/react-start/server";
import { z } from "zod";

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(),
  _gotcha: z.string().optional(),
});

const SOURCE = ["utm_source", "utm_medium", "utm_campaign", "referrer", "landing_page"];
const FIT = { size: ["1_10"], budget: ["10k_50k", "50k_plus"], timeline: ["this_quarter", "next_quarter"] };

export type LeadResult = { errors: Record<string, string> };

export const submitLead = createServerFn({ method: "POST" })
  .validator((data: unknown) => {
    if (!(data instanceof FormData)) throw new Error("Expected form data");
    return Object.fromEntries([...data].map(([key, value]) => [key, String(value)]));
  })
  .handler(async ({ data }): Promise<LeadResult> => {
    const parsed = LeadSchema.safeParse(data);
    if (!parsed.success) {
      const { fieldErrors } = z.flattenError(parsed.error);
      return { errors: Object.fromEntries(Object.entries(fieldErrors).map(([key, list]) => [key, list?.[0] ?? ""])) };
    }
    const lead = parsed.data;
    const stored = new URLSearchParams(getCookie("lead_source") ?? "");
    const qualified =
      !FIT.size.includes(lead.company_size) && FIT.budget.includes(lead.budget) && FIT.timeline.includes(lead.timeline);
    const origin = getRequestHeader("origin");

    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({
        ...lead,
        ...Object.fromEntries(SOURCE.map((key) => [key, stored.get(key) ?? ""])),
        fit: qualified ? "qualified" : "nurture",
      }),
    });
    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] ?? ""]));
      return { errors: Object.keys(errors).length ? errors : { form: result?.error?.message ?? "Try again." } };
    }

    if (!qualified || lead._gotcha) throw redirect({ to: "/contact-sales/thanks" });
    throw redirect({ href: `https://cal.com/acme/intro?${new URLSearchParams({ name: lead.name, email: lead.email })}` });
  });
```

- `.validator()` only reshapes `FormData`; Zod runs in the handler with `safeParse`, so field errors return as data instead of a thrown error the component can't read per field.
- `fit` is decided on the server. Editing the form can't promote a lead.
- `getRequestHeader("origin")` is forwarded, because a server-side fetch sends no `Origin` and Ikiform detects free `localhost` tests from it.
- `redirect({ href })` is the external form; `redirect({ to })` stays inside the app. Both are thrown.

## The route

`src/routes/contact-sales.tsx`:

```tsx
import { createFileRoute } from "@tanstack/react-router";
import { useServerFn } from "@tanstack/react-start";
import { type FormEvent, useState } from "react";
import { submitLead } from "../lib/lead";

export const Route = createFileRoute("/contact-sales")({ component: ContactSales });

const SELECTS = {
  company_size: ["Company size", [["1_10", "1 to 10"], ["11_50", "11 to 50"], ["51_200", "51 to 200"], ["201_1000", "201 to 1,000"], ["1000_plus", "More than 1,000"]]],
  budget: ["Budget", [["under_10k", "Under $10k"], ["10k_50k", "$10k to $50k"], ["50k_plus", "More than $50k"], ["unsure", "Not sure yet"]]],
  timeline: ["When do you want to start?", [["this_quarter", "This quarter"], ["next_quarter", "Next quarter"], ["later", "Later this year"], ["researching", "Researching for now"]]],
} as const;

function ContactSales() {
  const send = useServerFn(submitLead);
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [pending, setPending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setPending(true);
    try {
      const result = await send({ data: new FormData(event.currentTarget) });
      setErrors(result?.errors ?? {});
    } catch {
      setErrors({ form: "We couldn't send that. Email sales@example.com instead." });
    } finally {
      setPending(false);
    }
  }

  const error = (key: string) => (errors[key] ? <p role="alert">{errors[key]}</p> : null);

  return (
    <form method="post" action={submitLead.url} encType="multipart/form-data" onSubmit={onSubmit}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required />
      {error("name")}
      <label htmlFor="email">Work email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      {error("email")}
      <label htmlFor="company">Company</label>
      <input id="company" name="company" autoComplete="organization" required />
      {error("company")}
      {Object.entries(SELECTS).map(([id, [label, options]]) => (
        <div key={id}>
          <label htmlFor={id}>{label}</label>
          <select id={id} name={id} required defaultValue="">
            <option value="" disabled>Choose one</option>
            {options.map(([value, text]) => (
              <option key={value} value={value}>{text}</option>
            ))}
          </select>
          {error(id)}
        </div>
      ))}
      <label htmlFor="message">Anything we should know?</label>
      <textarea id="message" name="message" rows={4} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {error("form")}
      <button disabled={pending}>{pending ? "Sending" : "Talk to sales"}</button>
    </form>
  );
}
```

The submit handler prevents the default post and calls the server function through `useServerFn`, which is what follows a thrown redirect, including one to another site. Because the form never resets, every value stays in place while the visitor fixes a field.

The `action={submitLead.url}` attribute is the no-JavaScript path. Start documents `.url` for HTML forms, but the browser then shows the server function's raw response instead of this page. Test it with JavaScript off and decide whether that is acceptable, or put a plain email link for sales under the form.

### Honeypot and human check

A filled honeypot is still posted, so Ikiform stores it as spam, and the handler sends it to the thank-you route instead of your calendar. The secret key skips Ikiform's human check; add a Turnstile check to the handler if bots find the form, following [Turnstile on a form](https://ikiform.com/guides/spam-protection/cloudflare-turnstile.md).

## Rendering and deploy

Routes render on the server by default, and the middleware runs on each of those requests. If you prerender marketing pages to static files, the CDN serves them without running the middleware, so attribution must start on a page that renders per request.

| Host | What to know |
| --- | --- |
| Vercel | Add the `nitro/vite` plugin and deploy. Set `IKIFORM_SECRET_KEY` in the project's environment variables. |
| Netlify | Add `@netlify/vite-plugin-tanstack-start` to `vite.config.ts`. Set the key in site settings. |
| Cloudflare | Add `@cloudflare/vite-plugin` and deploy with Wrangler. Store the key with `wrangler secret put` and enable `nodejs_compat` so `process.env` holds it. |

## Check it works

Posts from the dev server 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 the dev server, open `/?utm_source=test&utm_campaign=launch` and check that the `lead_source` cookie is httpOnly in dev tools.
2. On `/contact-sales`, pick **Under $10k** and leave **Company** empty. The company error shows and every other value stays.
3. Submit as a qualified lead: 201 to 1,000 people, $10k to $50k, this quarter. The browser leaves for Cal.com with 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 and check `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 |
| --- | --- |
| `403` from your own server function after adding `src/start.ts` | The CSRF check rejected the call. Requests from a different public origin, such as behind a proxy, need `createCsrfMiddleware({ origin })`. |
| Cross-site posts reach the server function | `src/start.ts` exists without `createCsrfMiddleware()`. Add it, as in the sample. |
| The page stays put after a qualified submit | The server function was called directly, not through `useServerFn`, so nothing follows the thrown redirect. |
| An error about importing server code in the client | `getCookie` was called outside a server function or middleware. Keep it inside `.handler()` or `.server()`. |
| `422 validation_failed` | The post doesn't match the form's field definition. `error.fields` names each field and the reason. Match the input `name` to the field id. |

## Questions

### How do I redirect to an external URL from a TanStack Start server function?

Throw redirect({ href: 'https://...' }) from @tanstack/react-router inside the handler, and call the server function through useServerFn in the component. useServerFn follows the redirect, including to another site.

### Where does middleware go in TanStack Start?

Global request middleware goes in src/start.ts, returned from createStart as requestMiddleware. It runs before SSR, server routes and server functions. Creating that file means adding createCsrfMiddleware() yourself.

### How do I validate FormData in a TanStack Start server function?

Use .validator() to check that the input is FormData and turn it into a plain object, then run Zod's safeParse in the handler so you can return one message per field.

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