Lead capture form7 min read
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.
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 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.
- Every request passes the CSRF middleware first; it checks only server function calls.
- On a GET with no cookie yet, firstTouch reads the UTM tags and an outside referrer and stores them.
- The page renders as usual.
- On submit, useServerFn posts the FormData to the server function.
- The validator turns it into a plain object and the handler runs Zod on it.
- A valid lead goes to Ikiform with the attribution, the fit and the visitor's Origin.
- The handler throws a redirect to Cal.com or the thank-you route; useServerFn follows it. Errors come back as data.
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 covers the key and the endpoint.
src/
start.ts
lib/lead.ts
routes/
contact-sales.tsx
contact-sales.thanks.tsx
vite.config.ts
.envDefine 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 |
IKIFORM_SECRET_KEY in server-only environment variables.Global middleware for the first touch
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,OriginorRefererwith the request's origin. firstTouchskips anything but GET, so server function posts never touch the cookie.setCookieandgetCookiecome from@tanstack/react-start/serverand only run on the server.- A cookie set during a request isn't readable by
getCookieuntil the next one. That's fine here: the lead submits on a later request.
The server function
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 reshapesFormData; Zod runs in the handler withsafeParse, so field errors return as data instead of a thrown error the component can't read per field.fitis decided on the server. Editing the form can't promote a lead.getRequestHeader("origin")is forwarded, because a server-side fetch sends noOriginand Ikiform detects freelocalhosttests from it.redirect({ href })is the external form;redirect({ to })stays inside the app. Both are thrown.
The route
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.
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.
- Run the dev server, open
/?utm_source=test&utm_campaign=launchand check that thelead_sourcecookie is httpOnly in dev tools. - On
/contact-sales, pick Under $10k and leave Company empty. The company error shows and every other value stays. - 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.
- Submit as a 1 to 10 person team. You land on
/contact-sales/thanks. - Open Submissions in the dashboard and check
utm_source,landing_pageandfit. - 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 |
|---|---|
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
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.
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.
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.
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.