# Proteção contra spam em formulários React Router com Turnstile

> Proteja um formulário React Router contra spam: action de rota com Turnstile, rate limit no Workers por cf-connecting-ip, um módulo .server e um cabeçalho de CSP.

Publicado em 2026-10-25 · https://ikiform.com/pt-br/guides/spam-protection/react-router

Faça as verificações na `action` da rota: limite pelo IP do cliente, valide o token do Turnstile com o siteverify e depois repasse os campos com o honeypot. Mantenha os helpers num módulo `.server.ts` para que nunca cheguem ao navegador, e defina a CSP no export `headers` da rota. No Cloudflare Workers, o binding de rate limiting e os segredos vêm de `env` em `cloudflare:workers`.

## O que muda no React Router

No framework mode, toda rota com `action` aceita POSTs, do seu formulário ou de qualquer outra pessoa. O React Router dá três ferramentas que moldam como as verificações de spam se encaixam:

- **Módulos `.server`.** Um arquivo chamado `spam.server.ts` não pode entrar no bundle do cliente, então segredos e chamadas ao siteverify ficam no servidor mesmo quando um componente importa da mesma rota.
- **O export `headers`.** Cabeçalhos que uma action retorna não são enviados numa resposta de documento, a menos que a função `headers` da rota os repasse. A CSP também fica ali.
- **`useFetcher`.** Ele envia à action sem navegação, então a página e o widget continuam montados entre tentativas. Por isso o widget é remontado com uma `key` após cada resposta.

Esta página faz deploy no Cloudflare Workers, uma casa comum para o React Router, onde há um binding de rate limiting embutido. A tabela de deploy cobre Vercel e Netlify.

Diagrama: Como um envio chega à action do React Router, com e sem JavaScript

1. Depois da hidratação, o formulário envia por useFetcher, então a página não navega.
2. A action limita a taxa por IP, valida o token do Turnstile e repassa os campos.
3. O resultado chega como fetcher.data.
4. A key do widget muda, então um widget novo, e um token novo, fica pronto para a próxima tentativa.
5. Antes da hidratação, o mesmo formulário envia como uma requisição de documento normal, sem token do Turnstile.
6. A action o recusa no siteverify.
7. O export headers repassa o Retry-After e a CSP para a resposta de documento.
8. O componente lê actionData em vez de fetcher.data e mostra o erro.

Uma verificação humana precisa de JavaScript, então o caminho sem JavaScript termina num erro claro em vez de passar em silêncio.

## Configuração do projeto

Comece pelo template React Router da Cloudflare com `@cloudflare/vite-plugin`. No React Router 8 o argumento `context` é sempre um `RouterContextProvider`, e um objeto simples `{ cloudflare: { env } }` vindo da entrada do Worker lança erro, então a entrada passa só a requisição e a rota lê os bindings com `import { env } from "cloudflare:workers"`. Os arquivos que esta página mexe:

`Arquivos`:

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

- A site key é pública, então fica em `vars`. O loader a entrega ao componente, então o build do cliente não precisa de variável de ambiente nenhuma.
- Os segredos vão em `.dev.vars` localmente e em `wrangler secret put` em produção, nunca em `wrangler.jsonc`.
- Rode `wrangler types` depois de editar a configuração, para que `CONTACT_LIMIT` e os segredos fiquem tipados em `env`.

## Os helpers só do 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;
}
```

- O sufixo `.server` faz o build falhar se o código do cliente fosse incluir este módulo.
- O siteverify aceita corpos form-encoded. A verificação de hostname compara com o host para onde o visitante enviou e é pulada em desenvolvimento, porque tokens de teste trazem um hostname fictício.
- `forward` envia o `FormData` original, então todo campo, inclusive o honeypot, chega ao Ikiform como foi enviado.
- Ele também repassa o `Origin` do visitante, porque um fetch do lado do servidor não envia nenhum e o Ikiform o lê para reconhecer testes gratuitos em `localhost`.

> As versões do lado do servidor enviam com uma chave secreta, então o Ikiform pula **Allowed domains** e a verificação humana dele. Todo envio passa a vir do IP do seu servidor, e o limite Standard de 5 por minuto por visitante conta todos os seus visitantes juntos. Escolha o [limite de taxa](https://ikiform.com/pt-br/docs/rate-limits.md) Relaxed ou Custom para um formulário enviado por um servidor e mantenha `IKIFORM_SECRET_KEY` em variáveis de ambiente só do servidor.

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

O React 19 sobe um `<script>` `async` para o head e o carrega uma vez, não importa quantas vezes o componente renderize. O effect renderiza o widget explicitamente, como a Cloudflare recomenda para apps de página única, e o remove ao desmontar.

## A rota

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

- O limite roda antes de `request.formData()`, então uma enxurrada nunca custa a leitura do corpo nem uma chamada ao siteverify.
- `cf-connecting-ip` é definido pela Cloudflare em toda requisição a um Worker e não pode ser definido pelo visitante.
- `data()` define o status e o `Retry-After`. Um fetcher lê o corpo diretamente; é o export `headers` que leva esses cabeçalhos para uma resposta de documento.
- `result` recorre a `actionData`, então o erro ainda aparece quando o formulário enviou antes da hidratação.
- A classe `.hp` esconde o honeypot fora da tela, como no [guia do honeypot](https://ikiform.com/pt-br/guides/spam-protection/honeypot.md).

O React Router renderiza scripts inline para a hidratação, então esta política mantém `'unsafe-inline'` em `script-src`. Para tirá-lo, gere um nonce em `entry.server.tsx` e passe-o a `<Scripts>` e ao script do Turnstile; a Cloudflare diz que o Turnstile repassa o nonce ao que ele carrega.

## Teste localmente

A documentação da Cloudflare diz que o plugin do Vite roda seu código de servidor no runtime do Workers durante o desenvolvimento, então `env` de `cloudflare:workers`, `.dev.vars` e o binding de rate limiting se comportam como em produção. Localmente nenhuma borda da Cloudflare define `cf-connecting-ip`, então toda requisição conta como `unknown` e compartilha um único limite. Isso é esperado. Para ver o cabeçalho real, publique um preview com `wrangler versions upload` e teste a partir de duas redes.

## Deploy em outros lugares

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

A Cloudflare documenta o binding como aproximado e contado por localidade, o que serve bem para um formulário de contato. Fora do Workers não existe o módulo `cloudflare:workers`, então leia os segredos de `process.env` e troque o binding por um contador com Redis como o da [página de limite de taxa](https://ikiform.com/pt-br/guides/spam-protection/rate-limiting.md).

## Deixe o Ikiform rodar as verificações

Se manter chaves, um binding e uma CSP para um único formulário for mais do que você quer, aponte um formulário simples para o seu endpoint do Ikiform e apague a 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>
```

Use um `<form>` minúsculo, não o `<Form>` do React Router, para que o navegador envie direto ao Ikiform. Defina **Spam check** como **Ikiform**: um envio sem token recebe uma breve página de verificação em ikiform.com que roda o Turnstile e repassa os campos. Escolha o **Rate limit** Strict, 2 a cada 10 minutos por visitante, e liste seu domínio em **Allowed domains**. O spam barrado recebe a mesma resposta que uma mensagem real e fica guardado por 30 dias. O [guia de configuração de servidor](https://ikiform.com/pt-br/docs/guides/server.md) cobre como manter sua própria action.

## Confira se funciona

1. Rode `npm run dev` e envie uma mensagem com as chaves de teste. Ela aparece em **Submissions**. Um workspace sem plano aceita 50 testes por mês a partir de `localhost`.
2. Envie uma segunda mensagem sem recarregar. Um widget novo aparece após a primeira resposta e o segundo envio funciona.
3. Desative o JavaScript e envie. A página recarrega com o erro da verificação humana.
4. Depois do deploy, envie seis vezes em um minuto. O sexto recebe `429` com `Retry-After: 60`.
5. Envie com o honeypot preenchido, `_gotcha=bot`. Ele ainda recebe uma resposta de sucesso, mas o envio é guardado como spam e não dispara e-mail nem webhook.

## Pegadinhas

| Problema | O que fazer |
| --- | --- |
| `Server-only module referenced by client` | Um componente importa de `spam.server.ts` algo que ele renderiza. Só `loader` e `action` podem usá-lo. |
| A segunda tentativa sempre falha no siteverify | O widget manteve o token já usado. Garanta que a `key` dele mude após cada resposta do fetcher. |
| `Invalid context value provided to handleRequest` | A entrada do Worker ainda passa `{ cloudflare: { env, ctx } }`, como faziam os templates do React Router 7. Chame o handler só com a requisição e leia os bindings de `cloudflare:workers`. |
| `Retry-After` não aparece na resposta sem JavaScript | Os cabeçalhos da action são descartados a menos que o export `headers` os retorne. Copie-os de `actionHeaders` como mostrado. |

## Perguntas

### Como adiciono o Turnstile a uma action do React Router?

Renderize o widget no componente da rota, leia cf-turnstile-response dos dados do formulário na action e envie-o com sua chave secreta ao siteverify. Só continue se a resposta disser success com a action que você definiu no widget.

### Como limito a taxa de uma action do React Router no Cloudflare Workers?

Declare um binding de rate limiting em wrangler.jsonc e chame limit com uma chave montada a partir do cabeçalho cf-connecting-ip no topo da action. Retorne um 429 com data() quando falhar.

### Por que os cabeçalhos da minha action não chegam ao navegador?

Numa requisição de documento, o React Router só envia os cabeçalhos que o export headers da sua rota retorna. Leia actionHeaders ali e copie o que precisar, como o Retry-After.

### O Ikiform é gratuito?

Não, não há plano gratuito. Testar é grátis: você pode enviar 50 envios de teste por mês a partir de localhost antes de pagar. Para entrar no ar, o Pro começa em US$ 6/mês para 500 envios, ou você pode pagar uma vez: 10.000 envios por US$ 49 ou 50.000 envios por US$ 149, com créditos que nunca expiram.

## Mais guias

- [Acabe com o spam no formulário de contato](https://ikiform.com/pt-br/guides/spam-protection.md): Acabe com o spam no formulário de contato com camadas que não custam nada a pessoas reais: honeypot, tempo, pontuação de conteúdo, limites de taxa e verificação humana só quando precisar.
- [Cloudflare Turnstile](https://ikiform.com/pt-br/guides/spam-protection/cloudflare-turnstile.md): Adicione o Cloudflare Turnstile a um formulário HTML simples: o widget, o siteverify no servidor, chaves de teste e um jeito de rodá-lo sem chaves nem servidor próprio.
- [Limite de taxa](https://ikiform.com/pt-br/guides/spam-protection/rate-limiting.md): Limite a taxa de envios por visitante com um armazenamento compartilhado, responda 429 com Retry-After e evite bloquear escritórios que compartilham um IP.
- [Campo honeypot](https://ikiform.com/pt-br/guides/spam-protection/honeypot.md): Adicione a qualquer formulário um honeypot que pega bots sem atrapalhar leitores de tela, teclados ou preenchimento automático, com a verificação no servidor para cada stack.
- [Sem captcha](https://ikiform.com/pt-br/guides/spam-protection/without-captcha.md): Barre a maior parte do spam em formulários sem captcha nenhum: honeypot, horário de renderização assinado, pontuação de conteúdo e limite de taxa, somados em vez de usados sozinhos.
- [Alternativas ao reCAPTCHA](https://ikiform.com/pt-br/guides/spam-protection/recaptcha-alternatives.md): Compare alternativas ao reCAPTCHA para formulários, do Turnstile e hCaptcha a prova de trabalho e nenhum captcha, e migre com uma mudança pequena e segura.
