Stop contact form spam

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.

Preet SutharFounder, Ikiform

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.

How a post reaches the React Router action, with and without JavaScriptHydrated: useFetcherBefore hydration: a document POSTfetcher.Formnonavigationactionlimit,siteverify,forwardfetcher.dataerror orthanksnew widgetkey changesplain formpostno widgetrenderedactionsiteverifyrefusesheadersexportCSP,Retry-AfteractionDataerror shownPOSTdata()remountPOSTrenderHow a post reaches the React Router action, with and without JavaScriptHydrated: useFetcherBefore hydration: a document POSTfetcher.Formno navigationactionlimit, siteverify, forwardfetcher.dataerror or thanksnew widgetkey changesplain form postno widget renderedactionsiteverify refusesheaders exportCSP, Retry-AfteractionDataerror shownPOSTdata()remountPOSTrender
  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
app/
  routes/
    contact.tsx
  components/
    turnstile.tsx
  lib/
    spam.server.ts
workers/
  app.ts
wrangler.jsonc
.dev.vars
workers/app.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
{
  "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
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
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 for a form you post through a server, and keep IKIFORM_SECRET_KEY in server-only environment variables.

The widget

app/components/turnstile.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
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.

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

HostClient IPLimiterSecrets
Cloudflare Workerscf-connecting-ipRate limiting bindingwrangler secret put
Vercelx-forwarded-for, overwritten by VercelRedisprocess.env
Netlifyx-nf-client-connection-ipRedisprocess.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:

app/routes/contact.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 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

ProblemWhat to do
Server-only module referenced by clientA component imports something from spam.server.ts that it renders. Only loader and action may use it.
The second attempt always fails siteverifyThe widget kept its used token. Make sure its key changes after every fetcher answer.
Invalid context value provided to handleRequestThe 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 responseAction headers are dropped unless the headers export returns them. Copy them from actionHeaders as shown.

Questions

Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.