# Protección antispam para formularios de React Router con Turnstile

> Protege un formulario de React Router del spam: una action de ruta con Turnstile, un límite en Workers basado en cf-connecting-ip, un módulo .server y una cabecera CSP.

Publicado el 2026-10-25 · https://ikiform.com/es/guides/spam-protection/react-router

Haz las comprobaciones en la `action` de la ruta: limita por la IP del cliente, verifica el token de Turnstile con siteverify y luego reenvía los campos con el honeypot. Mantén los helpers en un módulo `.server.ts` para que nunca lleguen al navegador, y fija la CSP desde el export `headers` de la ruta. En Cloudflare Workers, el binding de rate limiting y los secretos vienen de `env` en `cloudflare:workers`.

## Qué cambia en React Router

En el modo framework, cada ruta con una `action` acepta POSTs, de tu formulario o de cualquier otro. React Router te da tres herramientas que determinan cómo encajan las comprobaciones antispam:

- **Módulos `.server`.** Un archivo llamado `spam.server.ts` no puede empaquetarse en el código de cliente, así que los secretos y las llamadas a siteverify se quedan en el servidor aunque un componente importe desde la misma ruta.
- **El export `headers`.** Las cabeceras que devuelve una action no se envían en una respuesta de documento salvo que la función `headers` de la ruta las pase. La CSP también vive ahí.
- **`useFetcher`.** Envía a la action sin navegación, así que la página y su widget siguen montados entre intentos. Por eso el widget se vuelve a montar con una `key` después de cada respuesta.

Esta página despliega en Cloudflare Workers, un hogar habitual de React Router, donde hay un binding de rate limiting integrado. La tabla de despliegue cubre Vercel y Netlify.

Diagrama: Cómo llega un envío a la action de React Router, con y sin JavaScript

1. Una vez hidratado, el formulario envía mediante useFetcher, así que la página no navega.
2. La action limita la frecuencia por IP, verifica el token de Turnstile y reenvía los campos.
3. Su resultado llega como fetcher.data.
4. La key del widget cambia, así que hay un widget nuevo, y un token nuevo, listo para el siguiente intento.
5. Antes de la hidratación, el mismo formulario envía como una petición de documento normal, sin token de Turnstile.
6. La action lo rechaza en siteverify.
7. El export headers pasa Retry-After y la CSP a la respuesta del documento.
8. El componente lee actionData en lugar de fetcher.data y muestra el error.

Una verificación humana necesita JavaScript, así que el camino sin JavaScript termina en un error claro en lugar de pasar en silencio.

## Configuración del proyecto

Parte de la plantilla de React Router de Cloudflare con `@cloudflare/vite-plugin`. En React Router 8 el argumento `context` siempre es un `RouterContextProvider`, y un objeto `{ cloudflare: { env } }` sin más desde la entrada del Worker lanza un error, así que la entrada pasa solo la petición y la ruta lee los bindings con `import { env } from "cloudflare:workers"`. Los archivos que toca esta página:

`Archivos`:

```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 es pública, así que va en `vars`. El loader se la pasa al componente, así que el build del cliente no necesita ninguna variable de entorno.
- Los secretos van en `.dev.vars` en local y con `wrangler secret put` en producción, nunca en `wrangler.jsonc`.
- Ejecuta `wrangler types` después de editar la configuración, para que `CONTACT_LIMIT` y los secretos tengan tipo en `env`.

## Los helpers solo de servidor

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

- El sufijo `.server` hace que el build falle si el código de cliente fuera a incluir este módulo.
- Siteverify acepta cuerpos form-encoded. La comprobación del hostname compara con el host al que envió el visitante y se omite en desarrollo, porque los tokens de prueba llevan un hostname ficticio.
- `forward` envía el `FormData` original, así que todos los campos, incluido el honeypot, llegan a Ikiform tal como se enviaron.
- También reenvía el `Origin` del visitante, porque un fetch del lado del servidor no envía ninguno e Ikiform lo lee para reconocer las pruebas gratuitas desde `localhost`.

> Las versiones de servidor envían con una clave secreta, así que Ikiform se salta **Allowed domains** y su verificación humana. Todos los envíos llegan entonces desde la IP de tu servidor, y el límite Standard de 5 por minuto por visitante cuenta a todos tus visitantes juntos. Elige el [límite de frecuencia](https://ikiform.com/es/docs/rate-limits.md) Relaxed o Custom para un formulario que envías a través de un servidor, y guarda `IKIFORM_SECRET_KEY` en variables de entorno solo de servidor.

## El 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 sube un `<script>` `async` al head y lo carga una sola vez, por muchas veces que se renderice el componente. El effect renderiza el widget de forma explícita, como recomienda Cloudflare para single-page apps, y lo elimina al desmontar.

## La ruta

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

- El límite se ejecuta antes de `request.formData()`, así que un flood nunca cuesta leer un cuerpo ni una llamada a siteverify.
- Cloudflare fija `cf-connecting-ip` en cada petición a un Worker y el visitante no puede fijarla.
- `data()` fija el estado y `Retry-After`. Un fetcher lee el cuerpo directamente; el export `headers` es lo que lleva esas cabeceras a una respuesta de documento.
- `result` recurre a `actionData`, así que el error se muestra igualmente cuando el formulario se envió antes de la hidratación.
- La clase `.hp` oculta el honeypot fuera de pantalla, como en la [guía del honeypot](https://ikiform.com/es/guides/spam-protection/honeypot.md).

React Router renderiza scripts en línea para la hidratación, así que esta política mantiene `'unsafe-inline'` en `script-src`. Para quitarlo, genera un nonce en `entry.server.tsx` y pásalo a `<Scripts>` y al script de Turnstile; Cloudflare indica que Turnstile pasa el nonce a lo que carga.

## Pruébalo en local

La documentación de Cloudflare indica que su plugin de Vite ejecuta tu código de servidor en el runtime de Workers durante el desarrollo, así que `env` de `cloudflare:workers`, `.dev.vars` y el binding de rate limiting se comportan como lo harán en producción. En local ningún edge de Cloudflare fija `cf-connecting-ip`, así que todas las peticiones cuentan como `unknown` y comparten un mismo límite. Es lo esperado. Para ver la cabecera real, despliega un preview con `wrangler versions upload` y prueba desde dos redes.

## Desplegar en otro lugar

| Host | IP del cliente | Limitador | Secretos |
| --- | --- | --- | --- |
| Cloudflare Workers | `cf-connecting-ip` | Binding de rate limiting | `wrangler secret put` |
| Vercel | `x-forwarded-for`, sobrescrita por Vercel | Redis | `process.env` |
| Netlify | `x-nf-client-connection-ip` | Redis | `process.env` |

Cloudflare documenta el binding como aproximado y contado por ubicación, lo que vale para un formulario de contacto. Fuera de Workers no existe el módulo `cloudflare:workers`, así que lee los secretos de `process.env` y cambia el binding por un contador en Redis como el de la [página de límites de frecuencia](https://ikiform.com/es/guides/spam-protection/rate-limiting.md).

## Deja que Ikiform haga las comprobaciones

Si gestionar claves, un binding y una CSP para un solo formulario es más de lo que quieres, apunta un formulario básico a tu endpoint de Ikiform y elimina la 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>
```

Usa un `<form>` en minúscula, no el `<Form>` de React Router, para que el navegador envíe directamente a Ikiform. Pon **Spam check** en **Ikiform**: un envío sin token recibe una breve página de verificación en ikiform.com que ejecuta Turnstile y reenvía los campos. Elige el **Rate limit** Strict, 2 cada 10 minutos por visitante, e indica tu dominio en **Allowed domains**. El spam atrapado recibe la misma respuesta que un mensaje real y se conserva 30 días. La [guía de configuración de servidor](https://ikiform.com/es/docs/guides/server.md) explica cómo mantener tu propia action.

## Comprueba que funciona

1. Ejecuta `npm run dev` y envía un mensaje con las claves de prueba. Aparece en **Submissions**. Un espacio de trabajo sin plan acepta 50 pruebas desde `localhost` al mes.
2. Envía un segundo mensaje sin recargar. Aparece un widget nuevo tras la primera respuesta y el segundo envío funciona.
3. Desactiva JavaScript y envía. La página se recarga con el error de verificación humana.
4. Tras desplegar, envía seis veces en un minuto. El sexto recibe `429` con `Retry-After: 60`.
5. Envía con el honeypot relleno, `_gotcha=bot`. Sigue recibiendo una respuesta de éxito, pero el envío se guarda como spam y no envía email ni webhook.

## Trampas habituales

| Problema | Qué hacer |
| --- | --- |
| `Server-only module referenced by client` | Un componente importa de `spam.server.ts` algo que renderiza. Solo `loader` y `action` pueden usarlo. |
| El segundo intento siempre falla en siteverify | El widget conservó su token usado. Asegúrate de que su `key` cambia después de cada respuesta del fetcher. |
| `Invalid context value provided to handleRequest` | La entrada del Worker sigue pasando `{ cloudflare: { env, ctx } }`, como hacían las plantillas de React Router 7. Llama al handler solo con la petición y lee los bindings de `cloudflare:workers`. |
| Falta `Retry-After` en la respuesta sin JavaScript | Las cabeceras de la action se descartan salvo que el export `headers` las devuelva. Cópialas de `actionHeaders` como se muestra. |

## Preguntas

### ¿Cómo añado Turnstile a una action de React Router?

Renderiza el widget en el componente de la ruta, lee cf-turnstile-response de los datos del formulario en la action y envíalo con tu clave secreta a siteverify. Continúa solo si la respuesta dice success con la action que pusiste en el widget.

### ¿Cómo limito la frecuencia de una action de React Router en Cloudflare Workers?

Declara un binding de rate limiting en wrangler.jsonc y llama a limit con una clave construida a partir de la cabecera cf-connecting-ip al principio de la action. Devuelve un 429 con data() cuando falle.

### ¿Por qué las cabeceras de mi action no llegan al navegador?

En una petición de documento, React Router solo envía las cabeceras que devuelve el export headers de tu ruta. Lee ahí actionHeaders y copia lo que necesites, como Retry-After.

### ¿Ikiform es gratis?

No, no hay plan gratuito. Probar es gratis: puedes enviar 50 envíos de prueba al mes desde localhost antes de pagar. Para pasar a producción, Pro empieza en 6 $/mes por 500 envíos, o puedes pagar una sola vez 49 $ por 10.000 envíos o 149 $ por 50.000 envíos, con créditos que nunca caducan.

## Más guías

- [Frena el spam en formularios de contacto](https://ikiform.com/es/guides/spam-protection.md): Frena el spam en tu formulario de contacto con capas que no cuestan nada a las personas reales: honeypot, tiempo, puntuación del contenido, límites de frecuencia y verificación humana solo donde haga falta.
- [Cloudflare Turnstile](https://ikiform.com/es/guides/spam-protection/cloudflare-turnstile.md): Añade Cloudflare Turnstile a un formulario HTML básico: el widget, siteverify en el servidor, claves de prueba y una forma de usarlo sin claves ni servidor propios.
- [Límites de frecuencia](https://ikiform.com/es/guides/spam-protection/rate-limiting.md): Limita los envíos de un formulario por visitante con un almacén compartido, responde 429 con Retry-After y no bloquees oficinas que comparten una IP.
- [Campo honeypot](https://ikiform.com/es/guides/spam-protection/honeypot.md): Añade a cualquier formulario un honeypot que atrape bots sin molestar a lectores de pantalla, teclado ni autocompletado, con la comprobación en el servidor para cada stack.
- [Sin captcha](https://ikiform.com/es/guides/spam-protection/without-captcha.md): Frena casi todo el spam de tus formularios sin ningún captcha: honeypot, hora de renderizado firmada, puntuación del contenido y límite de frecuencia, sumados en lugar de usarlos por separado.
- [Alternativas a reCAPTCHA](https://ikiform.com/es/guides/spam-protection/recaptcha-alternatives.md): Compara alternativas a reCAPTCHA para formularios, de Turnstile y hCaptcha a proof-of-work o ningún captcha, y cambia con un ajuste pequeño y seguro.
