# Spamschutz für React-Router-Formulare mit Turnstile

> Schütze ein React-Router-Formular vor Spam: eine Route-Action mit Turnstile, ein Workers-Rate-Limit nach cf-connecting-ip, ein .server-Modul und ein CSP-Header.

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

Führe die Prüfungen in der `action` der Route aus: Begrenze nach der Client-IP, verifiziere das Turnstile-Token mit siteverify und leite dann die Felder mit dem Honeypot weiter. Halte die Helfer in einem `.server.ts`-Modul, damit sie nie den Browser erreichen, und setze die CSP aus dem `headers`-Export der Route. Auf Cloudflare Workers kommen das Rate-Limiting-Binding und die Secrets aus `env` in `cloudflare:workers`.

## Was in React Router anders ist

Im Framework-Modus nimmt jede Route mit einer `action` POSTs an, von deinem Formular oder von jedem anderen. React Router gibt dir drei Werkzeuge, die bestimmen, wie Spamprüfungen hineinpassen:

- **`.server`-Module.** Eine Datei namens `spam.server.ts` kann nicht in Client-Code gebündelt werden, Secrets und siteverify-Aufrufe bleiben also auf dem Server, selbst wenn eine Komponente aus derselben Route importiert.
- **Der `headers`-Export.** Header, die eine Action zurückgibt, werden bei einer Dokument-Antwort nicht gesendet, außer die `headers`-Funktion der Route reicht sie weiter. Auch die CSP liegt dort.
- **`useFetcher`.** Es sendet an die Action ohne Navigation, Seite und Widget bleiben also zwischen den Versuchen gemountet. Deshalb wird das Widget nach jeder Antwort mit einem `key` neu gemountet.

Diese Seite deployt auf Cloudflare Workers, ein verbreitetes Zuhause für React Router, wo ein Rate-Limiting-Binding eingebaut ist. Die Deployment-Tabelle behandelt Vercel und Netlify.

Diagramm: Wie ein Post die React-Router-Action erreicht, mit und ohne JavaScript

1. Nach der Hydration sendet das Formular über useFetcher, die Seite navigiert also nicht.
2. Die Action begrenzt nach IP, verifiziert das Turnstile-Token und leitet die Felder weiter.
3. Ihr Ergebnis kommt als fetcher.data an.
4. Der key des Widgets ändert sich, sodass ein frisches Widget und ein frisches Token für den nächsten Versuch bereitstehen.
5. Vor der Hydration sendet dasselbe Formular als normaler Dokument-Request, ohne Turnstile-Token.
6. Die Action lehnt es bei siteverify ab.
7. Der headers-Export reicht Retry-After und die CSP an die Dokument-Antwort weiter.
8. Die Komponente liest actionData statt fetcher.data und zeigt den Fehler.

Eine Menschlichkeitsprüfung braucht JavaScript, der Weg ohne JavaScript endet also in einem klaren Fehler statt in stillem Durchwinken.

## Projekt-Setup

Starte mit dem React-Router-Template von Cloudflare mit `@cloudflare/vite-plugin`. In React Router 8 ist das Argument `context` immer ein `RouterContextProvider`, und ein einfaches Objekt `{ cloudflare: { env } }` aus dem Worker-Einstiegspunkt wirft einen Fehler, daher übergibt der Einstiegspunkt nur den Request, und die Route liest Bindings mit `import { env } from "cloudflare:workers"`. Die Dateien, die diese Seite anfasst:

`Dateien`:

```text
app/
  routes/
    contact.tsx
  components/
    turnstile.tsx
  lib/
    spam.server.ts
workers/
  app.ts
wrangler.jsonc
.dev.vars
```

`workers/app.ts`:

```ts
import { createRequestHandler } from "react-router";

const handler = createRequestHandler(() => import("virtual:react-router/server-build"), import.meta.env.MODE);

export default {
  fetch(request) {
    return handler(request);
  },
} satisfies ExportedHandler<Env>;
```

`wrangler.jsonc`:

```json
{
  "name": "acme-site",
  "main": "./workers/app.ts",
  "vars": { "TURNSTILE_SITE_KEY": "1x00000000000000000000AA" },
  "ratelimits": [
    {
      "name": "CONTACT_LIMIT",
      "namespace_id": "1001",
      "simple": { "limit": 5, "period": 60 }
    }
  ]
}
```

`.dev.vars`:

```bash
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
```

- Der Site Key ist öffentlich, er steht also in `vars`. Der Loader reicht ihn an die Komponente weiter, der Client-Build braucht also überhaupt keine Umgebungsvariable.
- Secrets gehören lokal in `.dev.vars` und in der Produktion in `wrangler secret put`, nie in `wrangler.jsonc`.
- Führe nach dem Bearbeiten der Konfiguration `wrangler types` aus, damit `CONTACT_LIMIT` und die Secrets auf `env` typisiert sind.

## Die reinen Server-Helfer

`app/lib/spam.server.ts`:

```ts
export async function isHuman(secret: string, token: string, ip: string, host: string) {
  if (!token || token.length > 2048) return false;
  const body = new URLSearchParams({ secret, response: token, remoteip: ip });
  try {
    const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { method: "POST", body });
    const verdict = (await response.json()) as { success: boolean; hostname?: string; action?: string };
    return verdict.success && verdict.action === "contact" && (import.meta.env.DEV || verdict.hostname === host);
  } catch {
    return false;
  }
}

export async function forward(secretKey: string, form: FormData, origin: string | null) {
  const headers = new Headers({ Accept: "application/json", Authorization: `Bearer ${secretKey}` });
  if (origin) headers.set("Origin", origin);
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", { method: "POST", headers, body: form }).catch(() => null);
  return response?.ok ?? false;
}
```

- Das Suffix `.server` lässt den Build scheitern, wenn Client-Code dieses Modul einbinden würde.
- Siteverify nimmt form-encoded Bodys an. Die Hostnamen-Prüfung vergleicht mit dem Host, an den der Besucher gesendet hat, und wird in der Entwicklung übersprungen, weil Test-Tokens einen Dummy-Hostnamen tragen.
- `forward` sendet die ursprünglichen `FormData`, jedes Feld, einschließlich des Honeypots, erreicht Ikiform also so, wie es gesendet wurde.
- Es reicht außerdem den `Origin` des Besuchers weiter, weil ein serverseitiges fetch keinen sendet und Ikiform ihn liest, um kostenlose `localhost`-Tests zu erkennen.

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

## Das Widget

`app/components/turnstile.tsx`:

```tsx
import { useEffect, useRef } from "react";

const SCRIPT = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";

interface TurnstileApi {
  render(container: HTMLElement, options: { sitekey: string; action: string }): string;
  remove(widgetId: string): void;
}

declare global {
  interface Window {
    turnstile?: TurnstileApi;
  }
}

export function Turnstile({ siteKey }: { siteKey: string }) {
  const box = useRef<HTMLDivElement>(null);

  useEffect(() => {
    let widgetId: string | undefined;
    const mount = () => {
      if (box.current && window.turnstile) widgetId = window.turnstile.render(box.current, { sitekey: siteKey, action: "contact" });
    };
    if (window.turnstile) mount();
    else document.querySelector(`script[src="${SCRIPT}"]`)?.addEventListener("load", mount, { once: true });
    return () => {
      if (widgetId) window.turnstile?.remove(widgetId);
    };
  }, [siteKey]);

  return (
    <>
      <script src={SCRIPT} async />
      <div ref={box} />
    </>
  );
}
```

React 19 hebt ein `async`-`<script>` in den Head und lädt es einmal, egal wie oft die Komponente rendert. Der Effect rendert das Widget explizit, was Cloudflare für Single-Page-Apps empfiehlt, und entfernt es beim Unmount.

## Die Route

`app/routes/contact.tsx`:

```tsx
import { useEffect, useState } from "react";
import { data, useFetcher } from "react-router";
import { env } from "cloudflare:workers";
import { Turnstile } from "~/components/turnstile";
import { forward, isHuman } from "~/lib/spam.server";
import type { Route } from "./+types/contact";

const POLICY = [
  "default-src 'self'",
  "script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com",
  "frame-src https://challenges.cloudflare.com",
  "object-src 'none'",
  "base-uri 'self'",
].join("; ");

export function loader() {
  return { siteKey: env.TURNSTILE_SITE_KEY };
}

export async function action({ request }: Route.ActionArgs) {
  const ip = request.headers.get("cf-connecting-ip") ?? "unknown";

  const { success } = await env.CONTACT_LIMIT.limit({ key: `contact:${ip}` });
  if (!success) {
    return data({ ok: false, error: "Too many messages. Try again in a minute." }, { status: 429, headers: { "Retry-After": "60" } });
  }

  const form = await request.formData();
  const token = String(form.get("cf-turnstile-response") ?? "");
  if (!(await isHuman(env.TURNSTILE_SECRET_KEY, token, ip, new URL(request.url).hostname))) {
    return data({ ok: false, error: "We couldn't confirm you're human. Try again." }, { status: 403 });
  }

  form.delete("cf-turnstile-response");
  const sent = await forward(env.IKIFORM_SECRET_KEY, form, request.headers.get("origin"));
  return sent ? { ok: true, error: "" } : data({ ok: false, error: "Your message didn't send. Try again." }, { status: 502 });
}

export function headers({ actionHeaders, parentHeaders }: Route.HeadersArgs) {
  parentHeaders.set("Content-Security-Policy", POLICY);
  const retry = actionHeaders.get("Retry-After");
  if (retry) parentHeaders.set("Retry-After", retry);
  return parentHeaders;
}

export default function Contact({ loaderData, actionData }: Route.ComponentProps) {
  const fetcher = useFetcher<typeof action>();
  const result = fetcher.data ?? actionData;
  const [round, setRound] = useState(0);

  useEffect(() => {
    if (fetcher.state === "idle" && fetcher.data) setRound((count) => count + 1);
  }, [fetcher.state, fetcher.data]);

  if (result?.ok) return <p role="status">Thanks. We'll reply by email.</p>;

  return (
    <fetcher.Form method="post">
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required maxLength={120} />
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={5} required minLength={10} />
      <div className="hp" aria-hidden="true">
        <label htmlFor="hp-field">Leave this empty</label>
        <input id="hp-field" name="_gotcha" type="text" tabIndex={-1} autoComplete="off" />
      </div>
      <Turnstile key={round} siteKey={loaderData.siteKey} />
      {result?.error ? <p role="alert">{result.error}</p> : null}
      <button disabled={fetcher.state !== "idle"}>{fetcher.state === "idle" ? "Send message" : "Sending"}</button>
    </fetcher.Form>
  );
}
```

- Das Limit läuft vor `request.formData()`, eine Flut kostet also nie ein Body-Parsing oder einen siteverify-Aufruf.
- `cf-connecting-ip` setzt Cloudflare bei jedem Request an einen Worker, und der Besucher kann es nicht setzen.
- `data()` setzt Status und `Retry-After`. Ein Fetcher liest den Body direkt; der `headers`-Export bringt diese Header in eine Dokument-Antwort.
- `result` fällt auf `actionData` zurück, damit der Fehler auch angezeigt wird, wenn das Formular vor der Hydration gesendet hat.
- Die Klasse `.hp` versteckt den Honeypot außerhalb des sichtbaren Bereichs, wie in der [Honeypot-Anleitung](https://ikiform.com/de/guides/spam-protection/honeypot.md).

React Router rendert für die Hydration Inline-Skripte, daher behält diese Policy `'unsafe-inline'` in `script-src`. Um darauf zu verzichten, erzeuge in `entry.server.tsx` eine Nonce und übergib sie an `<Scripts>` und das Turnstile-Skript; laut Cloudflare reicht Turnstile eine Nonce an das weiter, was es lädt.

## Lokal testen

Laut Cloudflares Dokumentation führt sein Vite-Plugin deinen Servercode während der Entwicklung in der Workers-Runtime aus, `env` aus `cloudflare:workers`, `.dev.vars` und das Rate-Limiting-Binding verhalten sich also wie in der Produktion. Lokal setzt keine Cloudflare-Edge `cf-connecting-ip`, also zählt jeder Request als `unknown` und teilt sich ein Limit. Das ist zu erwarten. Um den echten Header zu sehen, deploye eine Preview mit `wrangler versions upload` und teste aus zwei Netzen.

## Anderswo deployen

| Host | Client-IP | Limiter | Secrets |
| --- | --- | --- | --- |
| Cloudflare Workers | `cf-connecting-ip` | Rate-Limiting-Binding | `wrangler secret put` |
| Vercel | `x-forwarded-for`, von Vercel überschrieben | Redis | `process.env` |
| Netlify | `x-nf-client-connection-ip` | Redis | `process.env` |

Cloudflare dokumentiert das Binding als ungefähr und pro Standort gezählt, was für ein Kontaktformular in Ordnung ist. Außerhalb von Workers gibt es kein Modul `cloudflare:workers`, lies die Secrets also aus `process.env` und ersetze das Binding durch einen Redis-Zähler wie den auf der [Seite zu Rate-Limits](https://ikiform.com/de/guides/spam-protection/rate-limiting.md).

## Ikiform die Prüfungen ausführen lassen

Wenn dir Schlüssel, ein Binding und eine CSP für ein einziges Formular zu viel sind, richte ein einfaches Formular auf deinen Ikiform-Endpunkt aus und lösche die Action:

`app/routes/contact.tsx`:

```tsx
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <label htmlFor="email">Email</label>
  <input id="email" name="email" type="email" autoComplete="email" required />
  <label htmlFor="message">Message</label>
  <textarea id="message" name="message" rows={5} required />
  <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
  <button>Send message</button>
</form>
```

Nutze ein kleingeschriebenes `<form>`, nicht das `<Form>` von React Router, damit der Browser direkt an Ikiform sendet. Setze **Spam check** auf **Ikiform**: Ein Post ohne Token bekommt eine kurze Prüfseite auf ikiform.com, die Turnstile ausführt und die Felder weiterschickt. Wähle das **Rate limit** Strict, 2 alle 10 Minuten pro Besucher, und trag deine Domain unter **Allowed domains** ein. Abgefangener Spam bekommt dieselbe Antwort wie eine echte Nachricht und wird 30 Tage aufbewahrt. Die [Anleitung zur Server-Einrichtung](https://ikiform.com/de/docs/guides/server.md) behandelt, wie du stattdessen deine eigene Action behältst.

## Prüfen, ob es funktioniert

1. Führe `npm run dev` aus und sende mit den Testschlüsseln eine Nachricht. Sie erscheint unter **Submissions**. Ein Workspace ohne Tarif nimmt 50 `localhost`-Tests pro Monat an.
2. Sende eine zweite Nachricht, ohne neu zu laden. Nach der ersten Antwort erscheint ein frisches Widget, und der zweite Post gelingt.
3. Deaktiviere JavaScript und sende ab. Die Seite lädt mit dem Fehler der Menschlichkeitsprüfung neu.
4. Sende nach dem Deployment sechsmal in einer Minute. Der sechste Post bekommt `429` mit `Retry-After: 60`.
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.

## Stolperfallen

| Problem | Was zu tun ist |
| --- | --- |
| `Server-only module referenced by client` | Eine Komponente importiert aus `spam.server.ts` etwas, das sie rendert. Nur `loader` und `action` dürfen es verwenden. |
| Der zweite Versuch scheitert immer an siteverify | Das Widget hat sein benutztes Token behalten. Stell sicher, dass sich sein `key` nach jeder Fetcher-Antwort ändert. |
| `Invalid context value provided to handleRequest` | Der Worker-Einstiegspunkt übergibt noch `{ cloudflare: { env, ctx } }`, wie es die Templates für React Router 7 taten. Ruf den Handler nur mit dem Request auf und lies Bindings aus `cloudflare:workers`. |
| `Retry-After` fehlt in der Antwort ohne JavaScript | Action-Header werden verworfen, außer der `headers`-Export gibt sie zurück. Kopiere sie wie gezeigt aus `actionHeaders`. |

## Fragen

### Wie füge ich Turnstile zu einer React-Router-Action hinzu?

Rendere das Widget in der Route-Komponente, lies cf-turnstile-response in der Action aus den Formulardaten und sende es mit deinem Secret Key an siteverify. Mach nur weiter, wenn die Antwort success mit der Action lautet, die du am Widget gesetzt hast.

### Wie begrenze ich eine React-Router-Action auf Cloudflare Workers?

Deklariere ein Rate-Limiting-Binding in wrangler.jsonc und ruf ganz oben in der Action limit mit einem Schlüssel aus dem Header cf-connecting-ip auf. Gib bei einem Fehlschlag mit data() einen 429 zurück.

### Warum erreichen die Header meiner Action nicht den Browser?

Bei einem Dokument-Request sendet React Router nur die Header, die der headers-Export deiner Route zurückgibt. Lies dort actionHeaders und kopiere, was du brauchst, etwa Retry-After.

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

- [Spam im Kontaktformular stoppen](https://ikiform.com/de/guides/spam-protection.md): Stopp Kontaktformular-Spam mit Schichten, die echte Menschen nichts kosten: Honeypot, Timing, Inhaltsbewertung, Rate-Limits und eine Menschlichkeitsprüfung nur bei Bedarf.
- [Cloudflare Turnstile](https://ikiform.com/de/guides/spam-protection/cloudflare-turnstile.md): Füge Cloudflare Turnstile zu einem einfachen HTML-Formular hinzu: das Widget, serverseitiges siteverify, Testschlüssel und ein Weg ganz ohne eigene Schlüssel oder Server.
- [Rate-Limits](https://ikiform.com/de/guides/spam-protection/rate-limiting.md): Begrenze Formulareinsendungen pro Besucher mit einem gemeinsamen Speicher, antworte mit 429 und Retry-After und sperre keine Büros aus, die sich eine IP teilen.
- [Honeypot-Feld](https://ikiform.com/de/guides/spam-protection/honeypot.md): Füge jedem Formular einen Honeypot hinzu, der Bots abfängt, ohne Screenreader, Tastatur oder Autofill zu stören, mit der Serverprüfung für jeden Stack.
- [Ohne Captcha](https://ikiform.com/de/guides/spam-protection/without-captcha.md): Stopp den meisten Formular-Spam ganz ohne Captcha: Honeypot, signierte Render-Zeit, Inhaltsbewertung und Rate-Limit, zusammen statt einzeln eingesetzt.
- [Alternativen zu reCAPTCHA](https://ikiform.com/de/guides/spam-protection/recaptcha-alternatives.md): Vergleiche reCAPTCHA-Alternativen für Formulare, von Turnstile und hCaptcha bis Proof-of-Work und ganz ohne Captcha, und wechsle mit einer kleinen, sicheren Änderung.
