# React Router form spam protection with Turnstile

> Protect a React Router form from spam: a route action with Turnstile, a Workers rate limit keyed on cf-connecting-ip, a .server module and a CSP header.

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

Do the checks in the route's `action`: limit by the client IP, verify the Turnstile token with siteverify, then forward the fields with the honeypot. Keep the helpers in a `.server.ts` module so they never reach the browser, and set the CSP from the route's `headers` export. On Cloudflare Workers, the rate limiting binding and secrets come from `env` in `cloudflare:workers`.

## What's different in React Router

In framework mode, every route with an `action` accepts POSTs, from your form or from anyone else. React Router gives you three tools that shape how spam checks fit:

- **`.server` modules.** A file named `spam.server.ts` can't be bundled into client code, so secrets and siteverify calls stay on the server even when a component imports from the same route.
- **The `headers` export.** Headers an action returns are not sent on a document response unless the route's `headers` function passes them on. The CSP lives there too.
- **`useFetcher`.** It posts to the action without a navigation, so the page and its widget stay mounted between attempts. That's why the widget is remounted with a `key` after each answer.

This page deploys to Cloudflare Workers, a common home for React Router, where a rate limiting binding is built in. The deploy table covers Vercel and Netlify.

Diagram: How a post reaches the React Router action, with and without JavaScript

1. Once hydrated, the form posts through useFetcher, so the page doesn't navigate.
2. The action rate limits by IP, verifies the Turnstile token and forwards the fields.
3. Its result arrives as fetcher.data.
4. The widget's key changes, so a fresh widget, and a fresh token, is ready for the next attempt.
5. Before hydration, the same form posts as a normal document request, without a Turnstile token.
6. The action refuses it at siteverify.
7. The headers export passes Retry-After and the CSP onto the document response.
8. The component reads actionData instead of fetcher.data and shows the error.

A human check needs JavaScript, so the no-JavaScript path ends in a clear error rather than a silent pass.

## Project setup

Start from Cloudflare's React Router template with `@cloudflare/vite-plugin`. In React Router 8 the `context` argument is always a `RouterContextProvider`, and a plain `{ cloudflare: { env } }` object from the Worker entry throws, so the entry passes the request alone and the route reads bindings with `import { env } from "cloudflare:workers"`. The files this page touches:

`Files`:

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

- The site key is public, so it sits in `vars`. The loader hands it to the component, so the client build needs no environment variable at all.
- Secrets go in `.dev.vars` locally and `wrangler secret put` in production, never in `wrangler.jsonc`.
- Run `wrangler types` after editing the config, so `CONTACT_LIMIT` and the secrets are typed on `env`.

## The server-only helpers

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

- The `.server` suffix makes the build fail if client code would include this module.
- Siteverify takes form-encoded bodies. The hostname check compares against the host the visitor posted to and is skipped in development, because test tokens carry a dummy hostname.
- `forward` sends the original `FormData`, so every field, including the honeypot, reaches Ikiform as posted.
- It also passes the visitor's `Origin` on, because a server-side fetch sends none and Ikiform reads it to recognise free `localhost` tests.

> The server-side versions post with a secret key, so Ikiform skips **Allowed domains** and its human check. Every post then comes from your server's IP, and the Standard limit of 5 a minute per visitor counts all your visitors together. Choose the Relaxed or Custom [rate limit](https://ikiform.com/docs/rate-limits.md) for a form you post through a server, and keep `IKIFORM_SECRET_KEY` in server-only environment variables.

## The 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 hoists an `async` `<script>` into the head and loads it once, however many times the component renders. The effect renders the widget explicitly, which Cloudflare recommends for single-page apps, and removes it on unmount.

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

- The limit runs before `request.formData()`, so a flood never costs a body parse or a siteverify call.
- `cf-connecting-ip` is set by Cloudflare on every request to a Worker and can't be set by the visitor.
- `data()` sets the status and `Retry-After`. A fetcher reads the body directly; the `headers` export is what carries those headers onto a document response.
- `result` falls back to `actionData`, so the error still shows when the form posted before hydration.
- The `.hp` class hides the honeypot off-screen, as in the [honeypot guide](https://ikiform.com/guides/spam-protection/honeypot.md).

React Router renders inline scripts for hydration, so this policy keeps `'unsafe-inline'` in `script-src`. To drop it, generate a nonce in `entry.server.tsx` and pass it to `<Scripts>` and the Turnstile script; Cloudflare says Turnstile passes a nonce on to what it loads.

## Test it locally

Cloudflare's docs say its Vite plugin runs your server code in the Workers runtime during development, so `env` from `cloudflare:workers`, `.dev.vars` and the rate limiting binding behave as they will in production. Locally no Cloudflare edge sets `cf-connecting-ip`, so every request counts as `unknown` and shares one limit. That's expected. To see the real header, deploy a preview with `wrangler versions upload` and test from two networks.

## Deploying elsewhere

| Host | Client IP | Limiter | Secrets |
| --- | --- | --- | --- |
| Cloudflare Workers | `cf-connecting-ip` | Rate limiting binding | `wrangler secret put` |
| Vercel | `x-forwarded-for`, overwritten by Vercel | Redis | `process.env` |
| Netlify | `x-nf-client-connection-ip` | Redis | `process.env` |

Cloudflare documents the binding as approximate and counted per location, which is fine for a contact form. Off Workers, there is no `cloudflare:workers` module, so read the secrets from `process.env` and swap the binding for a Redis counter such as the one on the [rate limiting page](https://ikiform.com/guides/spam-protection/rate-limiting.md).

## Let Ikiform run the checks

If running keys, a binding and a CSP for one form is more than you want, point a plain form at your Ikiform endpoint and delete the 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 a lowercase `<form>`, not React Router's `<Form>`, so the browser posts straight to Ikiform. Set **Spam check** to **Ikiform**: a post without a token gets a short check page on ikiform.com that runs Turnstile and sends the fields on. Choose the Strict **Rate limit**, 2 every 10 minutes per visitor, and list your domain under **Allowed domains**. Caught spam gets the same answer as a real message and is kept for 30 days. The [server setup guide](https://ikiform.com/docs/guides/server.md) covers keeping your own action instead.

## Check it works

1. Run `npm run dev` and send a message with the test keys. It appears under **Submissions**. A workspace without a plan accepts 50 `localhost` tests a month.
2. Send a second message without reloading. A fresh widget appears after the first answer and the second post succeeds.
3. Disable JavaScript and submit. The page reloads with the human-check error.
4. Post six times in a minute after deploying. The sixth gets `429` with `Retry-After: 60`.
5. Post with the honeypot filled in, `_gotcha=bot`. It still gets a success answer, but the submission is stored as spam and sends no email or webhook.

## Gotchas

| Problem | What to do |
| --- | --- |
| `Server-only module referenced by client` | A component imports something from `spam.server.ts` that it renders. Only `loader` and `action` may use it. |
| The second attempt always fails siteverify | The widget kept its used token. Make sure its `key` changes after every fetcher answer. |
| `Invalid context value provided to handleRequest` | The Worker entry still passes `{ cloudflare: { env, ctx } }`, as React Router 7 templates did. Call the handler with the request alone and read bindings from `cloudflare:workers`. |
| `Retry-After` is missing on the no-JavaScript response | Action headers are dropped unless the `headers` export returns them. Copy them from `actionHeaders` as shown. |

## Questions

### How do I add Turnstile to a React Router action?

Render the widget in the route component, read cf-turnstile-response from the form data in the action, and send it with your secret key to siteverify. Continue only if the answer says success with the action you set on the widget.

### How do I rate limit a React Router action on Cloudflare Workers?

Declare a rate limiting binding in wrangler.jsonc and call limit with a key built from the cf-connecting-ip header at the top of the action. Return a 429 with data() when it fails.

### Why don't headers from my action reach the browser?

On a document request, React Router only sends the headers your route's headers export returns. Read actionHeaders there and copy what you need, such as Retry-After.

### Is Ikiform free?

No, there is no free plan. Testing is free: you can send 50 test submissions a month from localhost before you pay. To go live, Pro starts at $6/mo for 500 submissions, or you can pay once for 10,000 submissions for $49 or 50,000 submissions for $149, with credits that never expire.

## More guides

- [Stop contact form spam](https://ikiform.com/guides/spam-protection.md): Stop contact form spam with layers that cost real people nothing: a honeypot, timing, content scoring, rate limits and a human check only where needed.
- [Cloudflare Turnstile](https://ikiform.com/guides/spam-protection/cloudflare-turnstile.md): Add Cloudflare Turnstile to a plain HTML form: the widget, server-side siteverify, test keys, and a way to run it with no keys or server of your own.
- [Rate limiting](https://ikiform.com/guides/spam-protection/rate-limiting.md): Rate limit form submissions per visitor with a shared store, answer 429 with Retry-After, and avoid locking out offices that share one IP.
- [Honeypot field](https://ikiform.com/guides/spam-protection/honeypot.md): Add a honeypot to any form that catches bots without tripping screen readers, keyboards or autofill, with the server check for every stack.
- [Without a captcha](https://ikiform.com/guides/spam-protection/without-captcha.md): Stop most form spam with no captcha at all: a honeypot, a signed render time, a content score and a rate limit, added together instead of used alone.
- [reCAPTCHA alternatives](https://ikiform.com/guides/spam-protection/recaptcha-alternatives.md): Compare reCAPTCHA alternatives for forms, from Turnstile and hCaptcha to proof-of-work and no captcha at all, then switch with a small, safe change.
