# Qualifizierendes Lead-Formular mit React Router bauen

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

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

Halte UTM-Tags und Referrer mit Root-Middleware beim ersten Dokument-Request fest, in einem signierten Cookie aus `createCookie`. Validiere den Lead in der `action` der Route, leite ihn mit der Attribution an dein Formular-Backend weiter und gib für qualifizierte Leads `redirect()` zu deinem Terminplaner zurück, für den Rest `data()` mit Feldfehlern. `<Form>` sendet mit oder ohne JavaScript, also bedient dieselbe Action beides.

Der [React-Router-Tab der Hauptanleitung](https://ikiform.com/de/guides/lead-capture.md#form) leitet einen Lead aus einer Routen-Action weiter und rendert eine Dankeszeile. Diese Seite baut die Vertriebsversion im Framework-Modus: drei qualifizierende Fragen, auf dem Server geprüft, die erste Kampagne, die den Besucher gebracht hat, und eine Weiterleitung in deinen Kalender für passende Leads.

## Was React Router ändert

Die Daten-APIs von React Router wurden um einfache HTML-Formulare herum entworfen. `<Form>` sendet mit oder ohne JavaScript auf dieselbe Weise an die `action` der Route, und was die Action zurückgibt, `data()` mit Fehlern oder `redirect()`, funktioniert für beides. Mit JavaScript folgt React Router einem Redirect auf einen anderen Origin mit vollständigem Dokument-Laden. Den Server-Pfad schreibst du einmal.

Zwei weitere Teile passen zu dieser Aufgabe. Middleware, seit React Router 8 standardmäßig aktiv, läuft bei jedem Dokument-Request, sodass die Root-Route den ersten Kontakt festhalten kann. Und `createCookie` signiert seinen Wert mit deinem Secret, sodass ein Besucher seine eigene Attribution nicht umschreiben kann, bevor sie dein CRM erreicht.

Diagramm: Dieselbe React-Router-Action bedient einen Lead mit und ohne JavaScript

1. Der erste Dokument-Request trägt die Kampagnen-Tags in der URL und den externen Referrer im Header.
2. Root-Middleware lässt die Seite rendern und hängt dann ein signiertes Set-Cookie mit der Quelle an.
3. Mit JavaScript sendet Form über einen Daten-Request an die Action, und React Router folgt dem Redirect mit vollständigem Seitenladen.
4. Ohne JavaScript sendet der Browser nativ und folgt dem 303 selbst.
5. In beiden Fällen gibt ein Validierungsfehler Daten mit Status 400 zurück, und die Seite rendert die Fehler neben den eingegebenen Werten.

Eine Action, zwei Transportwege. Die Middleware läuft nur bei Dokument-Requests, und genau dort passiert der erste Kontakt.

## Projekt-Setup

Starte mit einer App im Framework-Modus auf React Router 8. Füge zwei reine Server-Variablen hinzu: den Secret Key von Ikiform und ein Secret zum Signieren des Cookies. [Senden von einem Server](https://ikiform.com/de/docs/guides/server.md) behandelt das Anlegen des Keys.

`Dateien`:

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

Füge dann die Felder im Tab **Fields** des Formulars hinzu. Ikiform führt dieselben Prüfungen erneut aus und lehnt Keys ab, die es nicht kennt.

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

## Ein signiertes Cookie für den ersten Kontakt

`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];
```

- Das Suffix `.server.ts` hält das Modul und `COOKIE_SECRET` aus dem Client-Bundle heraus.
- `leadSource.parse` gibt `null` zurück, wenn das Cookie fehlt oder seine Signatur nicht passt, sodass ein manipulierter Wert ignoriert und nicht vertraut wird.
- Die Middleware ruft zuerst `next()` auf und fügt dann nur einen Header hinzu. Laut Doku soll man Status lesen und Header an der Server-Response setzen, ihren Body aber nicht anfassen.
- Server-Middleware läuft bei clientseitigen Navigationen nur, wenn sie `.data` laden. Das ist in Ordnung: Tags kommen beim ersten Dokument-Request an.

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

- Die Validierung ist eine kleine Funktion ohne Abhängigkeit. Nutzt du Zod schon, setz es ein; die Form der Action bleibt gleich.
- `data({ errors, values }, { status: 400 })` sendet einen echten Fehlerstatus, was React Router auch davon abhält, Loader nach einem fehlgeschlagenen Post neu zu validieren.
- Der `Origin`-Header des Browsers wird weitergereicht, weil ein serverseitiges fetch keinen sendet und Ikiform daran kostenlose `localhost`-Tests erkennt.
- `redirect()` akzeptiert absolute URLs. Die Doku warnt davor, Nutzereingaben in Redirect-Zielen ungeprüft zu lassen; hier kommt nur der Query-String vom Besucher, und `URLSearchParams` kodiert ihn.
- `values` wird nach `source` gespreadet, aber jeder Key ist verschieden, also kann das Cookie kein Formularfeld überschreiben.

### UI für Pending und Fehler

`useNavigation().state` ist `submitting`, während die Action läuft, was den Button deaktiviert. Inputs lesen `defaultValue` aus `actionData.values`, sodass ein nativer Post, der mit Fehlern zurückkommt, trotzdem alles zeigt, was der Besucher eingetippt hat. Mit JavaScript wird das Formular gar nicht zurückgesetzt.

### Honeypot und Menschlichkeitsprüfung

Der Honeypot reist mit den anderen Werten, Ikiform speichert einen ausgefüllten als Spam, und die Action schickt ihn auf die Dankes-Route. Der Secret Key überspringt die Menschlichkeitsprüfung von Ikiform, also verifiziere bei Bedarf ein Turnstile-Token in der Action, wie in [Turnstile in einem Formular](https://ikiform.com/de/guides/spam-protection/cloudflare-turnstile.md).

## Rendering und Deployment

Actions brauchen einen Server zur Laufzeit. Mit `ssr: false` in `react-router.config.ts` lehnt der Build jede Route mit `action` ab. In `prerender` gelistete Pfade werden zu statischen Dateien, und ein Host, der diese Dateien ausliefert, führt nie die Middleware aus, also lass deine Kampagnen-Landingpages serverseitig rendern.

| Host | Gut zu wissen |
| --- | --- |
| Vercel | Füge das Preset `@vercel/react-router` in `react-router.config.ts` hinzu. Setze beide Secrets in den Umgebungsvariablen des Projekts. |
| Netlify | Füge `@netlify/vite-plugin-react-router` hinzu. Setze beide Secrets in den Einstellungen der Site. |
| Cloudflare | Nutze `@cloudflare/vite-plugin`; React Router 8 hat seinen eigenen Cloudflare-Dev-Proxy entfernt. Speichere beide Secrets mit `wrangler secret put` und lies sie so, wie deine Worker-Vorlage env bereitstellt. |

## 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 `react-router dev`, öffne `/?utm_source=test&utm_campaign=launch` und such das signierte Cookie `lead_source` in den Entwicklertools.
2. Bearbeite den Wert des Cookies von Hand und sende einen Lead ab. Die UTM-Felder kommen leer an, weil die Signatur nicht mehr passt.
3. Schalte JavaScript aus, lass **Timeline** leer und sende ab. Die Seite lädt mit dem Fehler und deinen Werten neu.
4. Sende als qualifizierter Lead: 51 bis 200 Personen, mehr als $50k, nächstes Quartal. Du landest auf Cal.com mit ausgefülltem Namen und E-Mail.
5. Öffne **Submissions** im Dashboard und prüf `utm_campaign`, `referrer` 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 |
| --- | --- |
| `middleware` hat keine Wirkung | In React Router 7 war es hinter dem Flag `future.v8_middleware`. Aktiviere es oder aktualisiere auf 8, wo es Standard ist. |
| Der Build scheitert mit verbotenen `action`-Funktionen | `ssr: false` ist gesetzt. Schalte SSR für die App wieder ein; das Vorrendern einzelner Pfade funktioniert daneben weiterhin. |
| Das Cookie bleibt nie bestehen | `COOKIE_SECRET` ist leer oder ändert sich bei jedem Deploy, also schlägt jede Signatur fehl. Setze einen stabilen Wert. |
| `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. |
| `402 test_limit_reached` | Der Workspace hat keinen Tarif und hat seine 50 kostenlosen `localhost`-Tests diesen Monat aufgebraucht. Wähle einen Tarif oder nutze **Send test** im Dashboard. |

## Fragen

### Kann eine React-Router-Action auf eine andere Domain weiterleiten?

Ja. redirect() akzeptiert eine absolute URL. Ein nativer Formular-Post folgt dem Location-Header selbst, und mit JavaScript macht React Router eine vollständige Dokument-Navigation zum anderen Origin.

### Wie setze ich ein Cookie aus React-Router-Middleware?

Ruf next() auf, um die Response zu bekommen, und häng dann einen Set-Cookie-Header an, der mit cookie.serialize() aus createCookie erzeugt wird. Exportiere die Middleware aus der Route, etwa root.tsx, als Route.MiddlewareFunction[].

### Funktioniert ein React-Router-Lead-Formular ohne JavaScript?

Ja. Form rendert ein normales form-Element, die Action läuft bei einem nativen Post, und die Rückgabe von data() mit Fehlern oder redirect() funktioniert genauso wie mit JavaScript.

### 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 TanStack Start](https://ikiform.com/de/guides/lead-capture/tanstack-start.md): 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.
- [Lead-Formular mit SvelteKit](https://ikiform.com/de/guides/lead-capture/sveltekit.md): Ein Lead-Formular mit SvelteKit: Der handle-Hook hält UTMs des ersten Kontakts fest, eine Form Action validiert mit Valibot, und redirect() schickt qualifizierte Leads in einen Kalender.
- [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.
