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

Veröffentlicht am 2026-10-25 · https://ikiform.com/de/guides/lead-capture/tanstack-start

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

Diagramm: Wie ein Lead durch die Middleware und die Server-Funktion von TanStack Start läuft

1. Jeder Request passiert zuerst die CSRF-Middleware; sie prüft nur Aufrufe von Server-Funktionen.
2. Bei einem GET ohne Cookie liest firstTouch die UTM-Tags und einen externen Referrer und speichert sie.
3. Die Seite rendert wie gewohnt.
4. Beim Absenden sendet useServerFn die FormData an die Server-Funktion.
5. Der Validator macht daraus ein einfaches Objekt, und der Handler lässt Zod darüber laufen.
6. Ein gültiger Lead geht mit Attribution, Eignung und dem Origin des Besuchers an Ikiform.
7. Der Handler wirft einen Redirect zu Cal.com oder zur Dankes-Route; useServerFn folgt ihm. Fehler kommen als Daten zurück.

src/start.ts zu schreiben ersetzt die Standard-Request-Middleware von Start, also kommt CSRF von Hand 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](https://ikiform.com/de/docs/guides/server.md) behandelt Key und Endpunkt.

`Dateien`:

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

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

> Die serverseitigen Versionen senden mit einem Secret Key, daher überspringt Ikiform **Allowed domains** und seine Menschlichkeitsprüfung. Jeder Post kommt dann von der IP deines Servers, und das Standard-Limit von 5 pro Minute und Besucher zählt alle deine Besucher zusammen. Wähle für ein Formular, das du über einen Server sendest, das [Rate-Limit](https://ikiform.com/de/docs/rate-limits.md) Relaxed oder Custom, und halte `IKIFORM_SECRET_KEY` in reinen Server-Umgebungsvariablen.

## Globale Middleware für den ersten Kontakt

`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],
}));
```

- 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`, `Origin` oder `Referer` mit dem Origin des Requests.
- `firstTouch` überspringt alles außer GET, also berühren Posts an Server-Funktionen das Cookie nie.
- `setCookie` und `getCookie` kommen aus `@tanstack/react-start/server` und laufen nur auf dem Server.
- Ein während eines Requests gesetztes Cookie kann `getCookie` erst beim nächsten lesen. Das ist hier in Ordnung: Der Lead sendet in einem späteren Request.

## Die Serverfunktion

`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()` formt nur die `FormData` um; Zod läuft im Handler mit `safeParse`, sodass Feldfehler als Daten zurückkommen statt als geworfene Exception, die die Komponente nicht pro Feld lesen kann.
- `fit` wird auf dem Server entschieden. Das Bearbeiten des Formulars kann einen Lead nicht hochstufen.
- `getRequestHeader("origin")` wird weitergereicht, weil ein serverseitiges fetch keinen `Origin` sendet und Ikiform daran kostenlose `localhost`-Tests erkennt.
- `redirect({ href })` ist die externe Form; `redirect({ to })` bleibt in der App. Beide werden geworfen.

## Die 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>
  );
}
```

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](https://ikiform.com/de/guides/spam-protection/cloudflare-turnstile.md).

## 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](https://ikiform.com/de/pricing).

1. Starte den Dev-Server, öffne `/?utm_source=test&utm_campaign=launch` und prüf in den Entwicklertools, dass das Cookie `lead_source` httpOnly ist.
2. Wähle auf `/contact-sales` **Under $10k** und lass **Company** leer. Der Fehler zum Unternehmen erscheint, und jeder andere Wert bleibt.
3. 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.
4. Sende als Team mit 1 bis 10 Personen. Du landest auf `/contact-sales/thanks`.
5. Öffne **Submissions** im Dashboard und prüf `utm_source`, `landing_page` und `fit`.
6. 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

### Wie leite ich aus einer TanStack-Start-Server-Funktion auf eine externe URL weiter?

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.

### Wohin gehört Middleware in TanStack Start?

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.

### Wie validiere ich FormData in einer TanStack-Start-Server-Funktion?

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.

### Ist Ikiform kostenlos?

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.

## Weitere Anleitungen

- [Formular zur Lead-Erfassung](https://ikiform.com/de/guides/lead-capture.md): Ein Lead-Formular auf deiner eigenen Site, das dich per E-Mail mit Reply-To benachrichtigt, den Team-Chat anpingt und dein CRM füttert, damit du antwortest, solange der Lead noch da ist.
- [Lead-Formular mit React Router](https://ikiform.com/de/guides/lead-capture/react-router.md): Ein Lead-Formular mit React Router im Framework-Modus: Root-Middleware mit signiertem Cookie für den ersten Kontakt, eine validierende Routen-Action und eine Weiterleitung zu deinem Terminplaner.
- [Lead-Formular mit Next.js](https://ikiform.com/de/guides/lead-capture/nextjs.md): Ein Lead-Formular für den Next.js App Router, das in einer Server Action mit Zod validiert, UTMs des ersten Kontakts in einem Cookie hält und qualifizierte Leads in deinen Kalender schickt.
- [Formular für Demo-Anfragen](https://ikiform.com/de/guides/lead-capture/demo-request.md): Ein Formular zum Demo-Buchen, das den Lead speichert und den Besucher dann mit vorausgefülltem Namen und E-Mail zu Cal.com oder Calendly schickt.
- [Formular für Angebotsanfragen](https://ikiform.com/de/guides/lead-capture/quote-request.md): Ein Formular für Angebotsanfragen, das Briefings als geteilte Links statt als Uploads sammelt, jeder Anfrage ein Fälligkeitsdatum gibt und linklastige Leads aus dem Spam hält.
- [Formular für Beratungsanfragen](https://ikiform.com/de/guides/lead-capture/consultation-request.md): Ein Formular für Beratungsanfragen für Steuerberater, Anwälte, Coaches und Praxen, das fragt, wie und wann angerufen werden soll, und heikle Details für das Gespräch lässt.
