Formular zur Lead-Erfassung7 Min. Lesezeit
Qualifizierendes Lead-Formular mit TanStack Start bauen
Ein Lead-Formular mit TanStack Start: globale Request-Middleware für UTMs des ersten Kontakts, eine Server-Funktion mit Zod und ein geworfener Redirect zu deinem Terminplaner.
Halte UTM-Tags und Referrer beim ersten Seiten-Request mit einer globalen Request-Middleware in src/start.ts fest und speichere sie in einem httpOnly-Cookie. Validiere den Lead in einem createServerFn-Handler mit Zod, leite ihn mit der Attribution an dein Formular-Backend weiter und wirf redirect({ href }), um qualifizierte Leads zu deinem Terminplaner zu schicken. Ruf die Server-Funktion über useServerFn auf, damit der Redirect für dich verfolgt wird.
Der TanStack-Start-Tab der Hauptanleitung sendet einen Lead über eine Server-Funktion und zeigt eine Dankeszeile. Diese Seite baut die Vertriebsversion: drei qualifizierende Fragen, auf dem Server validiert, die Kampagne, die den Besucher gebracht hat, und eine Weiterleitung direkt in deinen Kalender, wenn der Lead passt.
Was TanStack Start ändert
Start hat keine dateibasierte Middleware-Konvention. Alles, was bei jedem Request laufen soll, gehört als globale Request-Middleware in src/start.ts, die vor SSR, Server-Routen und Server-Funktionen gleichermaßen läuft. Dort wird der erste Kontakt festgehalten.
Der Haken: Start installiert CSRF-Schutz für Server-Funktionen nur dann automatisch, wenn src/start.ts nicht existiert. Sobald du die Datei für die Attribution anlegst, gehört dir dieser Schutz, und du musst createCsrfMiddleware() selbst hinzufügen. Vergisst du es, akzeptiert dein Lead-Endpunkt Cross-Site-Posts.
- Jeder Request passiert zuerst die CSRF-Middleware; sie prüft nur Aufrufe von Server-Funktionen.
- Bei einem GET ohne Cookie liest firstTouch die UTM-Tags und einen externen Referrer und speichert sie.
- Die Seite rendert wie gewohnt.
- Beim Absenden sendet useServerFn die FormData an die Server-Funktion.
- Der Validator macht daraus ein einfaches Objekt, und der Handler lässt Zod darüber laufen.
- Ein gültiger Lead geht mit Attribution, Eignung und dem Origin des Besuchers an Ikiform.
- Der Handler wirft einen Redirect zu Cal.com oder zur Dankes-Route; useServerFn folgt ihm. Fehler kommen als Daten zurück.
Projekt-Setup
Starte mit einem TanStack-Start-Projekt auf React und installiere zod. Leg den Secret Key als IKIFORM_SECRET_KEY in .env ab; Server-Funktionen lesen ihn aus process.env, und er erreicht nie das Client-Bundle. Eine eigene Start-Seite gibt es in der Doku nicht; Senden von einem Server behandelt Key und Endpunkt.
src/
start.ts
lib/lead.ts
routes/
contact-sales.tsx
contact-sales.thanks.tsx
vite.config.ts
.envDefiniere die Felder im Tab Fields des Formulars, damit Ikiform alles ablehnt, was nicht passt, auch Attributions-Keys, die du nicht eingeplant hast.
| Feld-ID | Typ in Ikiform | Gesetzt von |
|---|---|---|
name, email, company | Text, E-Mail, Text | Dem Besucher |
company_size | Auswahl: 1_10, 11_50, 51_200, 201_1000, 1000_plus | Dem Besucher |
budget | Auswahl: under_10k, 10k_50k, 50k_plus, unsure | Dem Besucher |
timeline | Auswahl: this_quarter, next_quarter, later, researching | Dem Besucher |
message | Textarea, optional | Dem Besucher |
utm_source, utm_medium, utm_campaign, referrer, landing_page | Text, optional | Deinem Server, aus dem ersten Besuch |
fit | Select: qualified, nurture | Deinem Server, nach der Validierung |
IKIFORM_SECRET_KEY in reinen Server-Umgebungsvariablen.Globale Middleware für den ersten Kontakt
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],
}));- Der Filter behält die CSRF-Prüfung für Server-Funktionen bei, wie es die Doku von Start für den globalen Einsatz empfiehlt. Sie vergleicht
Sec-Fetch-Site,OriginoderReferermit dem Origin des Requests. firstTouchüberspringt alles außer GET, also berühren Posts an Server-Funktionen das Cookie nie.setCookieundgetCookiekommen aus@tanstack/react-start/serverund laufen nur auf dem Server.- Ein während eines Requests gesetztes Cookie kann
getCookieerst beim nächsten lesen. Das ist hier in Ordnung: Der Lead sendet in einem späteren Request.
Die Serverfunktion
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()formt nur dieFormDataum; Zod läuft im Handler mitsafeParse, sodass Feldfehler als Daten zurückkommen statt als geworfene Exception, die die Komponente nicht pro Feld lesen kann.fitwird auf dem Server entschieden. Das Bearbeiten des Formulars kann einen Lead nicht hochstufen.getRequestHeader("origin")wird weitergereicht, weil ein serverseitiges fetch keinenOriginsendet und Ikiform daran kostenloselocalhost-Tests erkennt.redirect({ href })ist die externe Form;redirect({ to })bleibt in der App. Beide werden geworfen.
Die 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>
);
}Der Submit-Handler verhindert den Standard-Post und ruft die Server-Funktion über useServerFn auf, das einem geworfenen Redirect folgt, auch einem auf eine andere Site. Weil das Formular nie zurückgesetzt wird, bleibt jeder Wert stehen, während der Besucher ein Feld korrigiert.
Das Attribut action={submitLead.url} ist der Weg ohne JavaScript. Start dokumentiert .url für HTML-Formulare, aber der Browser zeigt dann die rohe Antwort der Server-Funktion statt dieser Seite. Teste es mit ausgeschaltetem JavaScript und entscheide, ob das akzeptabel ist, oder setze einen einfachen E-Mail-Link zum Vertrieb unter das Formular.
Honeypot und Menschlichkeitsprüfung
Ein ausgefüllter Honeypot wird trotzdem gesendet, sodass Ikiform ihn als Spam speichert, und der Handler schickt ihn auf die Dankes-Route statt in deinen Kalender. Der Secret Key überspringt die Menschlichkeitsprüfung von Ikiform; füge dem Handler eine Turnstile-Prüfung hinzu, wenn Bots das Formular finden, nach Turnstile in einem Formular.
Rendering und Deployment
Routen rendern standardmäßig auf dem Server, und die Middleware läuft bei jedem dieser Requests. Renderst du Marketingseiten zu statischen Dateien vor, liefert das CDN sie aus, ohne die Middleware auszuführen, also muss die Attribution auf einer Seite beginnen, die pro Request rendert.
| Host | Gut zu wissen |
|---|---|
| Vercel | Füge das Plugin nitro/vite hinzu und deploye. Setze IKIFORM_SECRET_KEY in den Umgebungsvariablen des Projekts. |
| Netlify | Füge @netlify/vite-plugin-tanstack-start zu vite.config.ts hinzu. Setze den Key in den Einstellungen der Site. |
| Cloudflare | Füge @cloudflare/vite-plugin hinzu und deploye mit Wrangler. Speichere den Key mit wrangler secret put und aktiviere nodejs_compat, damit process.env ihn enthält. |
Prüfen, ob es funktioniert
Posts vom Dev-Server tragen einen localhost-Origin. Ikiform hat keinen kostenlosen Tarif. Testen ist kostenlos: Ein Workspace ohne Tarif nimmt 50 Testeinsendungen pro Monat von localhost an. Für den Livebetrieb wähle Pro ab $6/Monat für 500 Einsendungen oder zahle einmalig: 10.000 Einsendungen für $49 oder 50.000 Einsendungen für $149, mit Guthaben, das nie verfällt. Siehe Preise.
- Starte den Dev-Server, öffne
/?utm_source=test&utm_campaign=launchund prüf in den Entwicklertools, dass das Cookielead_sourcehttpOnly ist. - Wähle auf
/contact-salesUnder $10k und lass Company leer. Der Fehler zum Unternehmen erscheint, und jeder andere Wert bleibt. - Sende als qualifizierter Lead: 201 bis 1.000 Personen, $10k bis $50k, dieses Quartal. Der Browser wechselt zu Cal.com mit ausgefülltem Namen und E-Mail.
- Sende als Team mit 1 bis 10 Personen. Du landest auf
/contact-sales/thanks. - Öffne Submissions im Dashboard und prüf
utm_source,landing_pageundfit. - Prüfe das Postfach jedes Benachrichtigungsempfängers. Jeder bekommt eine E-Mail mit den Feldern, und Reply geht an den Absender, wenn das Formular ein
email-Feld hat.
Stolperfallen
| Problem | Was zu tun ist |
|---|---|
403 von deiner eigenen Server-Funktion nach dem Hinzufügen von src/start.ts | Die CSRF-Prüfung hat den Aufruf abgelehnt. Requests von einem anderen öffentlichen Origin, etwa hinter einem Proxy, brauchen createCsrfMiddleware({ origin }). |
| Cross-Site-Posts erreichen die Server-Funktion | src/start.ts existiert ohne createCsrfMiddleware(). Füge sie hinzu, wie im Beispiel. |
| Die Seite bleibt nach einem qualifizierten Absenden stehen | Die Server-Funktion wurde direkt aufgerufen, nicht über useServerFn, also folgt niemand dem geworfenen Redirect. |
| Ein Fehler zum Import von Server-Code im Client | getCookie wurde außerhalb einer Server-Funktion oder Middleware aufgerufen. Lass es in .handler() oder .server(). |
422 validation_failed | Der Post passt nicht zur Felddefinition des Formulars. error.fields nennt jedes Feld und den Grund. Gleiche den name des Eingabefelds an die Feld-ID an. |
Fragen
Wirf im Handler redirect({ href: 'https://...' }) aus @tanstack/react-router und ruf die Server-Funktion in der Komponente über useServerFn auf. useServerFn folgt dem Redirect, auch auf eine andere Site.
Globale Request-Middleware gehört in src/start.ts, von createStart als requestMiddleware zurückgegeben. Sie läuft vor SSR, Server-Routen und Server-Funktionen. Wer diese Datei anlegt, muss createCsrfMiddleware() selbst hinzufügen.
Prüf mit .validator(), dass der Input FormData ist, und mach daraus ein einfaches Objekt; führe dann im Handler Zods safeParse aus, damit du eine Meldung pro Feld zurückgeben kannst.
Nein, es gibt keinen kostenlosen Tarif. Testen ist kostenlos: Du kannst 50 Testeinsendungen pro Monat von localhost senden, bevor du zahlst. Für den Livebetrieb startet Pro bei $6/Monat für 500 Einsendungen, oder du zahlst einmalig 10.000 Einsendungen für $49 oder 50.000 Einsendungen für $149, mit Guthaben, das nie verfällt.
Hängst du bei einem Schritt fest oder willst eine Anleitung für deinen Fall? Schreib an hello@ikiform.com.