# Eine Warteliste in React Router bauen

> Eine React-Router-Warteliste im Footer jeder Seite: Ein Fetcher sendet an eine Route-Action, markiert jede Anmeldung mit ihrer Seite und funktioniert ohne JavaScript.

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

Gib der Warteliste eine eigene Route mit einer `action`, die die E-Mail validiert und sie mit einem Secret Key an dein Formular-Backend weiterleitet. Rendere das Formular in deinem Layout mit `useFetcher`, damit eine Anmeldung von jeder Seite auf dieser Seite bleibt und mit ihrem Pfad markiert wird; ohne JavaScript sendet dasselbe Formular an die Wartelisten-Route, und deren Seite zeigt das Ergebnis.

Die [Hauptanleitung](https://ikiform.com/de/guides/waitlist.md#form) hat einen React-Router-Tab mit dem Formular und seiner `action` auf der Startseiten-Route. Das passt zu einer Site mit einer Seite. Die meisten SaaS-Sites haben vor dem Launch ein paar Seiten, etwa Preise, einen Blogpost und die Startseite, und du willst die Anmeldung auf allen und wissen, welche funktioniert hat. Diese Seite setzt das Formular ins Layout und sendet jede Kopie an eine Action.

## Was in React Router anders ist

Im Framework-Modus gehört eine Action zu einer Route, und ein `<Form>` sendet an die Route, in der es steht. Ein Formular im Layout steht in jeder Route, die Wahl der Komponente entscheidet also, was beim Absenden passiert:

|   | `<Form method="post">` | `<fetcher.Form action="/waitlist">` |
| --- | --- | --- |
| Wohin es sendet | Die aktuelle Route, außer du setzt `action` | Die Action der Route `/waitlist` |
| Nach dem Absenden | Eine Navigation. URL und Verlauf ändern sich | Keine Navigation. Der Besucher bleibt auf der Seite |
| Das Ergebnis | `actionData` der Route, die gelaufen ist | `fetcher.data`, geteilt von jedem Fetcher mit demselben `key` |
| Ohne JavaScript | Ein normaler Post | Ein normaler Post an `/waitlist`, das seine eigene Seite rendert |

Ein Fetcher passt zu einer Footer-Anmeldung: Niemand will die Preisseite verlassen, um sich auf eine Liste zu setzen. Er degradiert auch sauber. Vor der Hydration ist `fetcher.Form` ein gewöhnliches `<form>`, das an `/waitlist` sendet, diese Route braucht also eine eigene Seite, um das Ergebnis zu zeigen.

Diagramm: Footer-Anmeldungen von jeder React-Router-Seite laufen durch eine Route-Action

1. Das Layout rendert das Wartelisten-Formular auf jeder Seite, mit einem versteckten ref aus dem Pfad der Seite.
2. Mit JavaScript sendet fetcher.Form an /waitlist, ohne die Seite zu verlassen. Der key des Fetchers ist geteilt, ein Hinweis im Header sieht also dasselbe Ergebnis.
3. Ohne JavaScript sendet dasselbe Formular als normale Navigation an /waitlist.
4. So oder so prüft die Action von /waitlist die E-Mail und leitet die Anmeldung mit dem Secret Key an Ikiform weiter.
5. Ein Fetcher liest das Ergebnis aus fetcher.data. Ein einfacher Post landet auf der Seite /waitlist, die es aus actionData zeigt.

Eine Action, beliebig viele Formulare. Der Pfad sagt dir, welche Seite die Anmeldung gebracht hat.

## Das Projekt einrichten

Die [Server-Anleitung](https://ikiform.com/de/docs/guides/server.md) behandelt das Anlegen des Formulars und eines Secret Keys. Die Dateien auf dieser Seite:

`Projekt`:

```text
app/
  root.tsx
  routes.ts
  routes/home.tsx
  routes/pricing.tsx
  routes/waitlist.tsx
  components/waitlist-form.tsx
react-router.config.ts
.env
```

Leg den Schlüssel in `.env` ab und lies ihn in der Action aus `process.env`. Route-Module werden für den Browser aufgeteilt, aber `action` läuft nur auf dem Server, der Schlüssel landet also nie beim Client. Importier ihn nie in eine Komponente.

`.env`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
```

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

Die Action sendet `email` und `ref`. Wenn das Formular im Tab **Fields** eine Felddefinition hat, muss sie beide auflisten:

`Felder`:

```json
{
  "version": 1,
  "title": "Waitlist",
  "fields": [
    { "id": "email", "type": "email", "label": "Email", "required": true, "placeholder": "you@company.com" },
    { "id": "ref", "type": "text", "label": "Source", "validation": { "max_length": 40 } }
  ]
}
```

## Bauen

### Die Route registrieren

`app/routes.ts`:

```ts
import { index, type RouteConfig, route } from "@react-router/dev/routes";

export default [
  index("routes/home.tsx"),
  route("pricing", "routes/pricing.tsx"),
  route("waitlist", "routes/waitlist.tsx"),
] satisfies RouteConfig;
```

### Die Wartelisten-Route

`app/routes/waitlist.tsx`:

```tsx
import { data } from "react-router";
import { WaitlistForm } from "../components/waitlist-form";
import type { Route } from "./+types/waitlist";

const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const SOURCE = /^[a-z0-9_-]{1,40}$/;

export async function action({ request }: Route.ActionArgs) {
  const form = await request.formData();
  const email = String(form.get("email") ?? "").trim();
  if (!EMAIL.test(email)) {
    return data({ ok: false, message: "Enter the email we should send your invite to.", email }, { status: 400 });
  }
  const ref = String(form.get("ref") ?? "");

  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: request.headers.get("origin") ?? "",
    },
    body: JSON.stringify({
      email,
      ref: SOURCE.test(ref) ? ref : "direct",
      _gotcha: String(form.get("_gotcha") ?? ""),
    }),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);

  const result = await response?.json().catch(() => null);
  if (result?.ok) return { ok: true, message: "You're on the list. We'll email you on launch day.", email };
  return data(
    {
      ok: false,
      message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "Couldn't join the list. Try again.",
      email,
    },
    { status: response?.status === 429 ? 429 : 400 },
  );
}

export default function Waitlist({ actionData }: Route.ComponentProps) {
  return (
    <main>
      <h1>Join the Acme waitlist</h1>
      <WaitlistForm initial={actionData} />
    </main>
  );
}
```

- `data()` setzt den Status eines Fehlers, ohne ihn in eine geworfene Response zu verwandeln, die Komponente rendert die Meldung also trotzdem.
- Jede Antwort bringt die E-Mail zurück, damit das Eingabefeld sie nach einem Fehler wieder anzeigen kann.
- Der `Origin`-Header des Browsers wird weitergereicht, weil Ikiform kostenlose `localhost`-Testeinsendungen daran zählt und das eigene `fetch` der Action keinen sendet.
- `ref` muss aus Kleinbuchstaben, Ziffern, `-` oder `_` bestehen, bis zu 40 Zeichen, dieselbe `max_length` wie in der Definition. Alles andere wird als `direct` gespeichert.
- Der Default-Export ist der Fallback ohne JavaScript. Er rendert dasselbe Formular mit dem Ergebnis der Action.

### Das geteilte Formular

`app/components/waitlist-form.tsx`:

```tsx
import { useFetcher, useLocation } from "react-router";

interface Outcome {
  ok: boolean;
  message: string;
  email: string;
}

export function sourceFor(pathname: string) {
  const slug = pathname.replace(/^\/+|\/+$/g, "").replace(/\//g, "-").toLowerCase();
  return (slug || "home").slice(0, 40);
}

export function WaitlistForm({ initial }: { initial?: Outcome }) {
  const fetcher = useFetcher<Outcome>({ key: "waitlist" });
  const { pathname } = useLocation();
  const outcome = fetcher.data ?? initial;
  const pending = fetcher.state !== "idle";

  if (outcome?.ok) return <p role="status">{outcome.message}</p>;

  return (
    <fetcher.Form method="post" action="/waitlist">
      <label>
        Email
        <input name="email" type="email" autoComplete="email" required defaultValue={outcome?.email} />
      </label>
      <input type="hidden" name="ref" value={sourceFor(pathname)} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      <p role="alert">{outcome?.message}</p>
      <button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
    </fetcher.Form>
  );
}

export function JoinedNote() {
  const fetcher = useFetcher<Outcome>({ key: "waitlist" });
  return fetcher.data?.ok ? <span>You're on the list</span> : <a href="#waitlist">Join the waitlist</a>;
}
```

- `key: "waitlist"` macht alle Fetcher mit diesem key zu einem geteilten Fetcher. `JoinedNote` im Header liest dieselben `fetcher.data`, ändert sich also in dem Moment, in dem das Footer-Formular erfolgreich ist.
- `sourceFor` macht aus `/blog/launch` ein `blog-launch` und aus `/` ein `home`. Der Pfad ist auf dem Server bekannt, das funktioniert also auch auf vorgerenderten Seiten.
- `fetcher.state` ist `submitting`, während der Post läuft, dann `loading`, während React Router revalidiert, der Button bleibt also deaktiviert, bis beides fertig ist.
- Das Label umschließt das Eingabefeld, die Kopien im Hero und im Footer auf einer Seite teilen sich also keine `id`.

Rendere `<WaitlistForm />` im `Layout` von `app/root.tsx`, in einem Footer mit `id="waitlist"`, und `<JoinedNote />` in deinem Header. Der Honeypot reist mit und erreicht Ikiform unverändert. Posts mit dem Secret Key überspringen Ikiforms Menschlichkeitsprüfung, **Spam check** zu aktivieren bringt für dieses Formular also nichts.

### Das Neuladen jedes Loaders überspringen

Nach einer Action lädt React Router die Daten jeder Route auf der Seite neu. Eine Anmeldung ändert nichts davon. Füge das Routen hinzu, deren Loader langsam sind oder Geld kosten:

`app/routes/pricing.tsx`:

```ts
import type { ShouldRevalidateFunctionArgs } from "react-router";

export function shouldRevalidate({ formAction, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
  if (formAction === "/waitlist") return false;
  return defaultShouldRevalidate;
}
```

## Rendering und Deployment

Marketingseiten ändern sich nicht pro Besucher, rendere sie also vor und behalte den Server für die Action:

`react-router.config.ts`:

```ts
import type { Config } from "@react-router/dev/config";

export default {
  ssr: true,
  prerender: ["/", "/pricing"],
} satisfies Config;
```

Behalte `ssr: true`. Mit `ssr: false` gibt es keinen Laufzeitserver, und React Router lässt den Build für jede Route scheitern, die eine `action` exportiert. Mit `ssr: true` werden `/` und `/pricing` beim Build als HTML geschrieben, und `/waitlist` läuft weiterhin auf dem Server.

| Host | Was hinzuzufügen ist | Den Schlüssel lesen |
| --- | --- | --- |
| Vercel | `@vercel/react-router`, mit `presets: [vercelPreset()]` in `react-router.config.ts` | `process.env`, gesetzt unter **Environment Variables** |
| Netlify | `@netlify/vite-plugin-react-router`, als `netlifyReactRouter()` in den Vite-Plugins | `process.env`, gesetzt unter **Environment variables** |
| Cloudflare Workers | `@cloudflare/vite-plugin`, als `cloudflare()` vor `reactRouter()` | `env.IKIFORM_SECRET_KEY` aus `cloudflare:workers`, gesetzt mit `wrangler secret put` |

Auf Cloudflare nutzt du `import { env } from "cloudflare:workers"` und liest den Schlüssel daraus statt aus `process.env`. In React Router 8 ist der `context` der Action ein `RouterContextProvider`, das v7-Muster `context.cloudflare.env` gilt also nicht mehr. Der Rest der Action bleibt gleich.

## Prüfen, ob es funktioniert

1. Führe `react-router dev` aus, öffne `http://localhost:5173/pricing` und melde dich im Footer an. Die Dankeszeile ersetzt das Formular, und der Hinweis im Header ändert sich mit.
2. Öffne im Dashboard **Submissions**. Die Anmeldung hat `ref` auf `pricing`.
3. Schalte JavaScript aus und melde dich auf der Startseite an. Du landest mit der Dankeszeile auf `/waitlist`, und die Anmeldung hat `ref` auf `home`.
4. Sende `a@b` aus dem Footer ab. Der Fehler erscheint unter dem Eingabefeld, und die Adresse steht noch darin.
5. Sende einen Post mit ausgefülltem Honeypot, `_gotcha=bot`. Er bekommt trotzdem eine Erfolgsantwort, aber die Einsendung wird als Spam gespeichert und löst weder E-Mail noch Webhook aus.
6. Posts aus `react-router dev` tragen einen `localhost`-Origin, zählen also als Testeinsendungen. 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).

## Stolperfallen

| Problem | Was zu tun ist |
| --- | --- |
| Eine Footer-Anmeldung navigiert auch mit JavaScript zu `/waitlist` | Das Formular ist ein `<Form>` oder ein einfaches `<form>`, nicht `fetcher.Form`. Nutze den Fetcher und gib aus der Action kein `redirect()` zurück; eine Weiterleitung aus der Action eines Fetchers navigiert. |
| Der Build scheitert mit einem Fehler zu `action`-Exporten | `ssr` ist in `react-router.config.ts` auf `false`. Setze es auf `true`, sonst hat die Warteliste keinen Server, auf dem sie laufen kann. |
| Das `ref` jeder Anmeldung ist `waitlist` | Die Posts kommen von der Seite `/waitlist` selbst. Das ist die Fallback-Seite; Links sollten auf deine echten Seiten zeigen. |
| `422` mit `unknown_field` | Die Felddefinition listet `ref` nicht auf. Füge es wie in der Definition oben hinzu. |
| `402 plan_required` | Der Workspace hat keinen Tarif, und der Post kam von einer Live-Site. Er wird zurückgehalten, nicht verloren. Wähle einen Tarif oder kauf ein Paket unter **Settings › Plan and usage**, und zurückgehaltene Einsendungen kommen an. |

## Fragen

### Wie sende ich ein React-Router-Formular ohne Navigation ab?

Nutze useFetcher und rendere fetcher.Form mit der action der Route, die es verarbeitet. Der Besucher bleibt auf der Seite, und das Ergebnis kommt in fetcher.data an. Vor der Hydration sendet es als normales Formular.

### Können vorgerenderte React-Router-Seiten ein funktionierendes Formular haben?

Ja, wenn ssr true ist. Vorgerenderte Seiten werden als HTML ausgeliefert, und ihre Formulare senden an eine Action, die auf dem Server läuft. Mit ssr auf false sind Actions nicht erlaubt.

### Woher weiß ich, von welcher Seite eine React-Router-Anmeldung kam?

Setz den Pfad der Seite in ein verstecktes ref-Eingabefeld, wie es sourceFor oben tut. Der Pfad ist auf Server und Client bekannt, das funktioniert also auf vorgerenderten Seiten und vor der Hydration.

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

- [Warteliste für ein neues SaaS](https://ikiform.com/de/guides/waitlist.md): Baue eine Pre-Launch-Warteliste auf deiner eigenen Landingpage: das Formular, Spamschutz, Duplikate, Bestätigungs-E-Mails und ein sauberer Export am Launch-Tag.
- [Warteliste mit Next.js](https://ikiform.com/de/guides/waitlist/nextjs.md): Eine Warteliste mit dem Next.js App Router: eine Server Action, die Anmeldungen mit Secret Key weiterleitet, useActionState, ein optimistisches Danke und ein Limit pro Besucher.
- [Warteliste mit TanStack Start](https://ikiform.com/de/guides/waitlist/tanstack-start.md): Eine TanStack-Start-Warteliste: Eine Server-Route beantwortet sowohl einen einfachen Formular-Post als auch fetch, und validateSearch trägt Quelle und Ergebnis in der URL.
- [Warteliste in reinem HTML](https://ikiform.com/de/guides/waitlist/html.md): Eine komplette Wartelisten-Seite in reinem HTML ohne Build, die ohne JavaScript funktioniert, nach dem Laden ein Danke inline zeigt und bei Bedarf zurückfällt.
- [Launch-Liste für Product Hunt](https://ikiform.com/de/guides/waitlist/product-hunt.md): Sammle Wochen vor Product Hunt eine Launch-Liste, markiere jede Anmeldung nach Kanal und sende die Launch-E-Mail in zwei Wellen, abgestimmt auf den Reset nach Pacific Time.
- [Beta-Anmeldung mit Auswahl](https://ikiform.com/de/guides/waitlist/beta-signup.md): Ein Beta-Bewerbungsformular mit Auswahlfragen, die der Server durchsetzt, und ein Skript, das Bewerber bewertet und jede Kohorte aus der Liste auswählt.
