# Protection anti-spam d'un formulaire React Router avec Turnstile

> Protégez un formulaire React Router du spam : une action de route avec Turnstile, une limite Workers basée sur cf-connecting-ip, un module .server et un en-tête CSP.

Publié le 2026-10-25 · https://ikiform.com/fr/guides/spam-protection/react-router

Faites les vérifications dans l'`action` de la route : limitez par l'IP client, vérifiez le token Turnstile avec siteverify, puis transmettez les champs avec le honeypot. Gardez les helpers dans un module `.server.ts` pour qu'ils n'atteignent jamais le navigateur, et définissez la CSP depuis l'export `headers` de la route. Sur Cloudflare Workers, le binding de limitation de débit et les secrets viennent de `env` dans `cloudflare:workers`.

## Ce qui change avec React Router

En mode framework, chaque route dotée d'une `action` accepte des POST, de votre formulaire ou de n'importe qui d'autre. React Router vous donne trois outils qui déterminent la place des vérifications anti-spam :

- **Les modules `.server`.** Un fichier nommé `spam.server.ts` ne peut pas être inclus dans le code client : les secrets et les appels à siteverify restent donc sur le serveur même quand un composant importe depuis la même route.
- **L'export `headers`.** Les en-têtes renvoyés par une action ne sont pas envoyés sur une réponse de document, sauf si la fonction `headers` de la route les transmet. La CSP vit là aussi.
- **`useFetcher`.** Il envoie vers l'action sans navigation : la page et son widget restent donc montés entre les tentatives. C'est pourquoi le widget est remonté avec une `key` après chaque réponse.

Cette page déploie sur Cloudflare Workers, un hébergement courant pour React Router, où un binding de limitation de débit est intégré. Le tableau de déploiement couvre Vercel et Netlify.

Schéma : Comment un envoi atteint l'action React Router, avec et sans JavaScript

1. Une fois hydraté, le formulaire envoie via useFetcher : la page ne navigue donc pas.
2. L'action limite le débit par IP, vérifie le token Turnstile et transmet les champs.
3. Son résultat arrive dans fetcher.data.
4. La key du widget change : un nouveau widget, et un nouveau token, est donc prêt pour la tentative suivante.
5. Avant l'hydratation, le même formulaire envoie une requête de document normale, sans token Turnstile.
6. L'action la refuse à l'étape siteverify.
7. L'export headers transmet Retry-After et la CSP à la réponse de document.
8. Le composant lit actionData au lieu de fetcher.data et affiche l'erreur.

Une vérification humaine a besoin de JavaScript : le chemin sans JavaScript aboutit donc à une erreur claire plutôt qu'à un passage silencieux.

## Mise en place du projet

Partez du modèle React Router de Cloudflare avec `@cloudflare/vite-plugin`. Dans React Router 8, l'argument `context` est toujours un `RouterContextProvider`, et un simple objet `{ cloudflare: { env } }` venant de l'entrée du Worker lève une erreur : l'entrée passe donc la requête seule et la route lit les bindings avec `import { env } from "cloudflare:workers"`. Les fichiers que touche cette page :

`Fichiers`:

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

- La site key est publique : elle se trouve donc dans `vars`. Le loader la transmet au composant : le build client n'a donc besoin d'aucune variable d'environnement.
- Les secrets vont dans `.dev.vars` en local et via `wrangler secret put` en production, jamais dans `wrangler.jsonc`.
- Lancez `wrangler types` après avoir modifié la configuration, pour que `CONTACT_LIMIT` et les secrets soient typés sur `env`.

## Les helpers côté serveur uniquement

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

- Le suffixe `.server` fait échouer le build si du code client devait inclure ce module.
- Siteverify accepte des corps form-encoded. La vérification du hostname compare avec l'hôte vers lequel le visiteur a envoyé et est sautée en développement, car les tokens de test portent un hostname factice.
- `forward` envoie le `FormData` d'origine : chaque champ, honeypot compris, atteint donc Ikiform tel qu'il a été envoyé.
- Il transmet aussi l'`Origin` du visiteur, car un fetch côté serveur n'en envoie aucun et Ikiform le lit pour reconnaître les tests gratuits depuis `localhost`.

> Les versions côté serveur envoient avec une clé secrète, donc Ikiform ignore **Allowed domains** et sa vérification humaine. Chaque envoi vient alors de l'IP de votre serveur, et la limite Standard de 5 par minute et par visiteur compte tous vos visiteurs ensemble. Choisissez la [limite de débit](https://ikiform.com/fr/docs/rate-limits.md) Relaxed ou Custom pour un formulaire que vous envoyez via un serveur, et gardez `IKIFORM_SECRET_KEY` dans des variables d'environnement réservées au serveur.

## Le 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 remonte un `<script>` `async` dans le head et le charge une seule fois, quel que soit le nombre de rendus du composant. L'effet rend le widget explicitement, ce que Cloudflare recommande pour les applications monopages, et le retire au démontage.

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

- La limite s'exécute avant `request.formData()` : un flot ne coûte donc jamais d'analyse de corps ni d'appel à siteverify.
- `cf-connecting-ip` est défini par Cloudflare sur chaque requête vers un Worker et ne peut pas être défini par le visiteur.
- `data()` définit le statut et `Retry-After`. Un fetcher lit directement le corps ; c'est l'export `headers` qui porte ces en-têtes sur une réponse de document.
- `result` se rabat sur `actionData` : l'erreur s'affiche donc quand même si le formulaire a été envoyé avant l'hydratation.
- La classe `.hp` masque le honeypot hors écran, comme dans le [guide du honeypot](https://ikiform.com/fr/guides/spam-protection/honeypot.md).

React Router rend des scripts inline pour l'hydratation : cette politique garde donc `'unsafe-inline'` dans `script-src`. Pour vous en passer, générez un nonce dans `entry.server.tsx` et passez-le à `<Scripts>` et au script Turnstile ; d'après Cloudflare, Turnstile transmet un nonce à ce qu'il charge.

## Tester en local

D'après la documentation de Cloudflare, son plugin Vite exécute votre code serveur dans le runtime Workers pendant le développement : `env` de `cloudflare:workers`, `.dev.vars` et le binding de limitation de débit se comportent donc comme en production. En local, aucun edge Cloudflare ne définit `cf-connecting-ip` : chaque requête compte donc comme `unknown` et partage une même limite. C'est normal. Pour voir le vrai en-tête, déployez une préproduction avec `wrangler versions upload` et testez depuis deux réseaux.

## Déployer ailleurs

| Hébergeur | IP client | Limiteur | Secrets |
| --- | --- | --- | --- |
| Cloudflare Workers | `cf-connecting-ip` | Binding de limitation de débit | `wrangler secret put` |
| Vercel | `x-forwarded-for`, écrasé par Vercel | Redis | `process.env` |
| Netlify | `x-nf-client-connection-ip` | Redis | `process.env` |

Cloudflare documente le binding comme approximatif et compté par emplacement, ce qui convient pour un formulaire de contact. Hors de Workers, il n'y a pas de module `cloudflare:workers` : lisez donc les secrets dans `process.env` et remplacez le binding par un compteur Redis comme celui de la [page sur la limitation de débit](https://ikiform.com/fr/guides/spam-protection/rate-limiting.md).

## Laisser Ikiform faire les vérifications

Si gérer des clés, un binding et une CSP pour un seul formulaire, c'est plus que vous n'en voulez, faites pointer un formulaire simple vers votre endpoint Ikiform et supprimez l'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>
```

Utilisez un `<form>` en minuscules, pas le `<Form>` de React Router, pour que le navigateur envoie directement vers Ikiform. Réglez **Spam check** sur **Ikiform** : un envoi sans token reçoit une courte page de vérification sur ikiform.com qui exécute Turnstile et transmet les champs. Choisissez le **Rate limit** Strict, 2 toutes les 10 minutes par visiteur, et listez votre domaine sous **Allowed domains**. Le spam attrapé reçoit la même réponse qu'un vrai message et est conservé 30 jours. Le [guide d'installation serveur](https://ikiform.com/fr/docs/guides/server.md) explique comment garder plutôt votre propre action.

## Vérifiez que tout fonctionne

1. Lancez `npm run dev` et envoyez un message avec les clés de test. Il apparaît sous **Submissions**. Un espace de travail sans offre accepte 50 tests depuis `localhost` par mois.
2. Envoyez un second message sans recharger. Un nouveau widget apparaît après la première réponse et le second envoi réussit.
3. Désactivez JavaScript et envoyez. La page se recharge avec l'erreur de vérification humaine.
4. Envoyez six fois en une minute après le déploiement. Le sixième reçoit `429` avec `Retry-After: 60`.
5. Envoyez avec le honeypot rempli, `_gotcha=bot`. L'envoi reçoit quand même une réponse de succès, mais la soumission est stockée comme spam et n'envoie ni e-mail ni webhook.

## Pièges

| Problème | Que faire |
| --- | --- |
| `Server-only module referenced by client` | Un composant importe depuis `spam.server.ts` quelque chose qu'il affiche. Seuls `loader` et `action` peuvent l'utiliser. |
| La deuxième tentative échoue toujours à siteverify | Le widget a gardé son token déjà utilisé. Assurez-vous que sa `key` change après chaque réponse du fetcher. |
| `Invalid context value provided to handleRequest` | L'entrée du Worker passe encore `{ cloudflare: { env, ctx } }`, comme le faisaient les modèles React Router 7. Appelez le handler avec la requête seule et lisez les bindings depuis `cloudflare:workers`. |
| `Retry-After` manque dans la réponse sans JavaScript | Les en-têtes de l'action sont perdus sauf si l'export `headers` les renvoie. Copiez-les depuis `actionHeaders` comme montré. |

## Questions

### Comment ajouter Turnstile à une action React Router ?

Affichez le widget dans le composant de la route, lisez cf-turnstile-response dans les données du formulaire dans l'action, et envoyez-le avec votre clé secrète à siteverify. Ne continuez que si la réponse indique success avec l'action définie sur le widget.

### Comment limiter le débit d'une action React Router sur Cloudflare Workers ?

Déclarez un binding de limitation de débit dans wrangler.jsonc et appelez limit avec une clé construite à partir de l'en-tête cf-connecting-ip en haut de l'action. Renvoyez un 429 avec data() en cas d'échec.

### Pourquoi les en-têtes de mon action n'atteignent-ils pas le navigateur ?

Sur une requête de document, React Router n'envoie que les en-têtes que renvoie l'export headers de votre route. Lisez-y actionHeaders et copiez ce dont vous avez besoin, comme Retry-After.

### Ikiform est-il gratuit ?

Non, il n'y a pas d'offre gratuite. Les tests sont gratuits : vous pouvez envoyer 50 soumissions de test par mois depuis localhost avant de payer. Pour passer en production, Pro commence à 6 $/mois pour 500 soumissions, ou vous pouvez payer une seule fois 10 000 soumissions pour 49 $ ou 50 000 soumissions pour 149 $, avec des crédits qui n'expirent jamais.

## Autres guides

- [Arrêter le spam des formulaires de contact](https://ikiform.com/fr/guides/spam-protection.md): Arrêtez le spam des formulaires de contact avec des couches invisibles pour les vrais visiteurs : honeypot, délai, score du contenu, limites de débit, vérification humaine si besoin.
- [Cloudflare Turnstile](https://ikiform.com/fr/guides/spam-protection/cloudflare-turnstile.md): Ajoutez Cloudflare Turnstile à un formulaire HTML simple : le widget, siteverify côté serveur, les clés de test, et une façon de l'utiliser sans clés ni serveur à vous.
- [Limitation de débit](https://ikiform.com/fr/guides/spam-protection/rate-limiting.md): Limitez les envois de formulaire par visiteur avec un stockage partagé, répondez 429 avec Retry-After, et évitez de bloquer les bureaux qui partagent une IP.
- [Champ honeypot](https://ikiform.com/fr/guides/spam-protection/honeypot.md): Ajoutez à n'importe quel formulaire un honeypot qui attrape les bots sans gêner lecteurs d'écran, clavier ou remplissage automatique, avec la vérification serveur pour chaque stack.
- [Sans captcha](https://ikiform.com/fr/guides/spam-protection/without-captcha.md): Arrêtez l'essentiel du spam sans captcha : un honeypot, une heure de rendu signée, un score du contenu et une limite de débit, cumulés plutôt qu'utilisés seuls.
- [Alternatives à reCAPTCHA](https://ikiform.com/fr/guides/spam-protection/recaptcha-alternatives.md): Comparez les alternatives à reCAPTCHA pour formulaires, de Turnstile et hCaptcha à la preuve de travail ou à l'absence de captcha, puis migrez avec un petit changement sûr.
