# Build a qualifying lead form in React Router

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

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

Record UTM tags and the referrer with root middleware on the first document request, in a signed cookie from `createCookie`. Validate the lead in the route's `action`, forward it with the attribution to your form backend, and return `redirect()` to your scheduler for qualified leads or `data()` with field errors for the rest. `<Form>` posts with or without JavaScript, so the same action serves both.

The [React Router tab on the main guide](https://ikiform.com/guides/lead-capture.md#form) forwards a lead from a route action and renders a thank-you line. This page builds the sales version in framework mode: three qualifying questions checked on the server, the first campaign that brought the visitor, and a redirect to your calendar for leads that fit.

## What React Router changes

React Router's data APIs were designed around plain HTML forms. `<Form>` posts to the route's `action` the same way with or without JavaScript, and whatever the action returns, `data()` with errors or `redirect()`, works for both. With JavaScript, React Router follows a redirect to another origin with a full document load. You write the server path once.

Two more pieces fit this job. Middleware, on by default since React Router 8, runs on every document request, so the root route can record the first touch. And `createCookie` signs its value with your secret, so a visitor can't rewrite their own attribution before it reaches your CRM.

Diagram: The same React Router action serving a lead with and without JavaScript

1. The first document request carries the campaign tags in the URL and the outside referrer in the header.
2. Root middleware lets the page render, then appends a signed Set-Cookie with the source.
3. With JavaScript, Form posts to the action through a data request and React Router follows the redirect with a full page load.
4. Without JavaScript, the browser posts natively and follows the 303 itself.
5. Either way, a validation failure returns data with status 400 and the page renders the errors next to the typed values.

One action, two transports. The middleware only runs on document requests, which is where the first touch happens.

## Project setup

Start from a framework-mode app on React Router 8. Add two server-only variables: the Ikiform secret key and a secret for signing the cookie. [Posting from a server](https://ikiform.com/docs/guides/server.md) covers creating the key.

`files`:

```text
app/
  root.tsx
  lead-source.server.ts
  routes.ts
  routes/
    contact-sales.tsx
    contact-sales.thanks.tsx
react-router.config.ts
.env
```

`.env`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
COOKIE_SECRET=a-long-random-string
```

Then add the fields on the form's **Fields** tab. Ikiform runs the same checks again and rejects keys it doesn't know.

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

## A signed first-touch cookie

`app/lead-source.server.ts`:

```ts
import { createCookie } from "react-router";

export interface LeadSource {
  utm_source: string;
  utm_medium: string;
  utm_campaign: string;
  referrer: string;
  landing_page: string;
}

export const leadSource = createCookie("lead_source", {
  httpOnly: true,
  sameSite: "lax",
  path: "/",
  maxAge: 60 * 60 * 24 * 30,
  secrets: [process.env.COOKIE_SECRET ?? ""],
});

export async function firstTouch(request: Request): Promise<LeadSource | null> {
  const url = new URL(request.url);
  const referer = request.headers.get("referer") ?? "";
  const from = URL.canParse(referer) ? new URL(referer) : null;
  const tag = (key: string) => url.searchParams.get(key)?.slice(0, 100) ?? "";
  const source: LeadSource = {
    utm_source: tag("utm_source"),
    utm_medium: tag("utm_medium"),
    utm_campaign: tag("utm_campaign"),
    referrer: from && from.host !== url.host ? from.href.slice(0, 300) : "",
    landing_page: url.pathname.slice(0, 200),
  };
  const hasSignal = source.referrer || source.utm_source || source.utm_medium || source.utm_campaign;
  return hasSignal ? source : null;
}
```

`app/root.tsx`:

```tsx
import { leadSource, firstTouch } from "./lead-source.server";
import type { Route } from "./+types/root";

const recordFirstTouch: Route.MiddlewareFunction = async ({ request }, next) => {
  const response = await next();
  if (request.method !== "GET") return response;
  if (await leadSource.parse(request.headers.get("Cookie"))) return response;
  const source = await firstTouch(request);
  if (source) response.headers.append("Set-Cookie", await leadSource.serialize(source));
  return response;
};

export const middleware: Route.MiddlewareFunction[] = [recordFirstTouch];
```

- The `.server.ts` suffix keeps the module, and `COOKIE_SECRET`, out of the client bundle.
- `leadSource.parse` returns `null` for a missing cookie and for one whose signature doesn't match, so a tampered value is ignored, not trusted.
- The middleware calls `next()` first, then only adds a header. The docs say to read status and set headers on the server response, not touch its body.
- Server middleware runs on client-side navigations only when they fetch `.data`. That's fine: tags arrive on the first document request.

## The route action

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

```tsx
import { data, Form, redirect, useNavigation } from "react-router";
import { type LeadSource, leadSource } from "../lead-source.server";
import type { Route } from "./+types/contact-sales";

const CHOICES = {
  company_size: ["1_10", "11_50", "51_200", "201_1000", "1000_plus"],
  budget: ["under_10k", "10k_50k", "50k_plus", "unsure"],
  timeline: ["this_quarter", "next_quarter", "later", "researching"],
};

function validate(values: Record<string, string>) {
  const errors: Record<string, string> = {};
  if (!values.name) errors.name = "Tell us who to reply to.";
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email)) errors.email = "Enter a work email we can reply to.";
  if (!values.company) errors.company = "Which company is this for?";
  for (const [key, allowed] of Object.entries(CHOICES)) {
    if (!allowed.includes(values[key])) errors[key] = "Pick one of the options.";
  }
  if (values.message.length > 5000) errors.message = "Keep it under 5,000 characters.";
  return errors;
}

export async function action({ request }: Route.ActionArgs) {
  const form = await request.formData();
  const keys = ["name", "email", "company", "company_size", "budget", "timeline", "message", "_gotcha"];
  const values = Object.fromEntries(keys.map((key) => [key, String(form.get(key) ?? "").trim()]));
  const errors = validate(values);
  if (Object.keys(errors).length) return data({ errors, values }, { status: 400 });

  const source: Partial<LeadSource> = (await leadSource.parse(request.headers.get("Cookie"))) ?? {};
  const qualified =
    values.company_size !== "1_10" &&
    ["10k_50k", "50k_plus"].includes(values.budget) &&
    ["this_quarter", "next_quarter"].includes(values.timeline);
  const origin = request.headers.get("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({ ...source, ...values, fit: qualified ? "qualified" : "nurture" }),
  });
  const result = await response.json().catch(() => null);
  if (!result?.ok) {
    const fields: Record<string, string[]> = result?.error?.fields ?? {};
    const failed = Object.fromEntries(Object.entries(fields).map(([key, list]) => [key, list[0] ?? ""]));
    if (!Object.keys(failed).length) failed.form = "We couldn't send that. Email sales@example.com instead.";
    return data({ errors: failed, values }, { status: response.status });
  }

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

export default function ContactSales({ actionData }: Route.ComponentProps) {
  const pending = useNavigation().state === "submitting";
  const errors: Record<string, string | undefined> = actionData?.errors ?? {};
  const values: Record<string, string | undefined> = actionData?.values ?? {};
  const error = (key: string) => (errors[key] ? <p role="alert">{errors[key]}</p> : null);

  return (
    <Form method="post">
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required defaultValue={values.name} />
      {error("name")}
      <label htmlFor="email">Work email</label>
      <input id="email" name="email" type="email" autoComplete="email" required defaultValue={values.email} />
      {error("email")}
      <label htmlFor="company">Company</label>
      <input id="company" name="company" autoComplete="organization" required defaultValue={values.company} />
      {error("company")}
      <label htmlFor="company_size">Company size</label>
      <select id="company_size" name="company_size" required defaultValue={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>
      {error("company_size")}
      <label htmlFor="budget">Budget</label>
      <select id="budget" name="budget" required defaultValue={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>
      {error("budget")}
      <label htmlFor="timeline">When do you want to start?</label>
      <select id="timeline" name="timeline" required defaultValue={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>
      {error("timeline")}
      <label htmlFor="message">Anything we should know?</label>
      <textarea id="message" name="message" rows={4} defaultValue={values.message} />
      {error("message")}
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {error("form")}
      <button disabled={pending}>{pending ? "Sending" : "Talk to sales"}</button>
    </Form>
  );
}
```

- Validation is a small function with no dependency. Swap in Zod if you already use it; the action shape stays the same.
- `data({ errors, values }, { status: 400 })` sends a real error status, which also stops React Router from revalidating loaders on a failed post.
- The browser's `Origin` header is forwarded, because a server-side fetch sends none and Ikiform detects free `localhost` tests from it.
- `redirect()` accepts absolute URLs. The docs warn to validate user input in redirect targets; here only the query string comes from the visitor, and `URLSearchParams` encodes it.
- `values` spreads after `source`, but every key differs, so the cookie can't overwrite a form field.

### Pending and error UI

`useNavigation().state` is `submitting` while the action runs, which disables the button. Inputs read `defaultValue` from `actionData.values`, so a native post that comes back with errors still shows everything the visitor typed. With JavaScript, the form isn't reset at all.

### Honeypot and human check

The honeypot travels with the other values, Ikiform stores a filled one as spam, and the action sends it to the thank-you route. The secret key skips Ikiform's human check, so verify a Turnstile token in the action if you need one, as in [Turnstile on a form](https://ikiform.com/guides/spam-protection/cloudflare-turnstile.md).

## Rendering and deploy

Actions need a runtime server. With `ssr: false` in `react-router.config.ts` the build refuses any route with an `action`. Paths listed in `prerender` become static files, and a host serving those files never runs the middleware, so leave your campaign landing pages server-rendered.

| Host | What to know |
| --- | --- |
| Vercel | Add the `@vercel/react-router` preset in `react-router.config.ts`. Set both secrets in the project's environment variables. |
| Netlify | Add `@netlify/vite-plugin-react-router`. Set both secrets in site settings. |
| Cloudflare | Use `@cloudflare/vite-plugin`; React Router 8 removed its own Cloudflare dev proxy. Store both secrets with `wrangler secret put` and read them the way your Worker template exposes env. |

## 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 `react-router dev`, open `/?utm_source=test&utm_campaign=launch`, and find the signed `lead_source` cookie in dev tools.
2. Edit the cookie's value by hand and submit a lead. The UTM fields arrive empty, because the signature no longer matches.
3. Turn JavaScript off, leave **Timeline** empty and submit. The page reloads with the error and your values.
4. Submit as a qualified lead: 51 to 200 people, more than $50k, next quarter. You land on Cal.com with name and email filled in.
5. Open **Submissions** in the dashboard and check `utm_campaign`, `referrer` 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 |
| --- | --- |
| `middleware` has no effect | On React Router 7 it was behind the `future.v8_middleware` flag. Turn it on, or upgrade to 8 where it is the default. |
| The build fails with `action` functions prohibited | `ssr: false` is set. Turn SSR back on for the app; prerendering specific paths still works alongside it. |
| The cookie never sticks | `COOKIE_SECRET` is empty or changes on every deploy, so every signature fails. Set a stable value. |
| `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. |
| `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. |

## Questions

### Can a React Router action redirect to another domain?

Yes. redirect() accepts an absolute URL. A native form post follows the Location header itself, and with JavaScript React Router does a full document navigation to the other origin.

### How do I set a cookie from React Router middleware?

Call next() to get the response, then append a Set-Cookie header made with cookie.serialize() from createCookie. Export the middleware from the route, such as root.tsx, as Route.MiddlewareFunction[].

### Does a React Router lead form work without JavaScript?

Yes. Form renders a normal form element, the action runs on a native post, and returning data() with errors or redirect() works the same as with JavaScript.

### 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.
- [TanStack Start lead form](https://ikiform.com/guides/lead-capture/tanstack-start.md): A TanStack Start lead form: global request middleware for first-touch UTMs, a server function with Zod, and a thrown redirect to your scheduler.
- [SvelteKit lead form](https://ikiform.com/guides/lead-capture/sveltekit.md): A SvelteKit lead form: the handle hook records first-touch UTMs, a form action validates with Valibot, and redirect() sends qualified leads to a 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.
