Stop contact form spam7 min read
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.
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:
.servermodules. A file namedspam.server.tscan'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
headersexport. Headers an action returns are not sent on a document response unless the route'sheadersfunction 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 akeyafter 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.
- Once hydrated, the form posts through useFetcher, so the page doesn't navigate.
- The action rate limits by IP, verifies the Turnstile token and forwards the fields.
- Its result arrives as fetcher.data.
- The widget's key changes, so a fresh widget, and a fresh token, is ready for the next attempt.
- Before hydration, the same form posts as a normal document request, without a Turnstile token.
- The action refuses it at siteverify.
- The headers export passes Retry-After and the CSP onto the document response.
- The component reads actionData instead of fetcher.data and shows the error.
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:
app/
routes/
contact.tsx
components/
turnstile.tsx
lib/
spam.server.ts
workers/
app.ts
wrangler.jsonc
.dev.varsimport { 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>;{
"name": "acme-site",
"main": "./workers/app.ts",
"vars": { "TURNSTILE_SITE_KEY": "1x00000000000000000000AA" },
"ratelimits": [
{
"name": "CONTACT_LIMIT",
"namespace_id": "1001",
"simple": { "limit": 5, "period": 60 }
}
]
}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.varslocally andwrangler secret putin production, never inwrangler.jsonc. - Run
wrangler typesafter editing the config, soCONTACT_LIMITand the secrets are typed onenv.
The server-only helpers
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
.serversuffix 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.
forwardsends the originalFormData, so every field, including the honeypot, reaches Ikiform as posted.- It also passes the visitor's
Originon, because a server-side fetch sends none and Ikiform reads it to recognise freelocalhosttests.
IKIFORM_SECRET_KEY in server-only environment variables.The widget
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
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-ipis set by Cloudflare on every request to a Worker and can't be set by the visitor.data()sets the status andRetry-After. A fetcher reads the body directly; theheadersexport is what carries those headers onto a document response.resultfalls back toactionData, so the error still shows when the form posted before hydration.- The
.hpclass hides the honeypot off-screen, as in the honeypot guide.
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.
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:
<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 covers keeping your own action instead.
Check it works
- Run
npm run devand send a message with the test keys. It appears under Submissions. A workspace without a plan accepts 50localhosttests a month. - Send a second message without reloading. A fresh widget appears after the first answer and the second post succeeds.
- Disable JavaScript and submit. The page reloads with the human-check error.
- Post six times in a minute after deploying. The sixth gets
429withRetry-After: 60. - 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
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.
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.
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.
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.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.