Lead capture form7 min read
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.
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 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.
- The first document request carries the campaign tags in the URL and the outside referrer in the header.
- Root middleware lets the page render, then appends a signed Set-Cookie with the source.
- With JavaScript, Form posts to the action through a data request and React Router follows the redirect with a full page load.
- Without JavaScript, the browser posts natively and follows the 303 itself.
- Either way, a validation failure returns data with status 400 and the page renders the errors next to the typed values.
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 covers creating the key.
app/
root.tsx
lead-source.server.ts
routes.ts
routes/
contact-sales.tsx
contact-sales.thanks.tsx
react-router.config.ts
.envIKIFORM_SECRET_KEY=ik_sk_your_secret_key
COOKIE_SECRET=a-long-random-stringThen 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 |
IKIFORM_SECRET_KEY in server-only environment variables.A signed first-touch cookie
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;
}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.tssuffix keeps the module, andCOOKIE_SECRET, out of the client bundle. leadSource.parsereturnsnullfor 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
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
Originheader is forwarded, because a server-side fetch sends none and Ikiform detects freelocalhosttests 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, andURLSearchParamsencodes it.valuesspreads aftersource, 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.
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.
- Run
react-router dev, open/?utm_source=test&utm_campaign=launch, and find the signedlead_sourcecookie in dev tools. - Edit the cookie's value by hand and submit a lead. The UTM fields arrive empty, because the signature no longer matches.
- Turn JavaScript off, leave Timeline empty and submit. The page reloads with the error and your values.
- 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.
- Open Submissions in the dashboard and check
utm_campaign,referrerandfit. - 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 |
|---|---|
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
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.
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[].
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.
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.