# Next.js form spam protection with Turnstile

> Protect a Next.js form from spam: a honeypot, Turnstile checked in a server action, a per-IP limit on Vercel, Netlify or Cloudflare, and a CSP for it.

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

In Next.js, check spam inside the server action, because the action is a public POST endpoint that anyone can call without your form. Rate limit by the client IP your host sets, verify the Turnstile token with siteverify, then forward the fields with the honeypot intact. Load the Turnstile script through `next/script` with your CSP nonce, and allow `challenges.cloudflare.com` in `frame-src`.

## What's different in Next.js

A server action looks like a function call, but Next.js turns it into a POST endpoint with an ID in the page's JavaScript. A bot doesn't need your form. It can replay that POST with any fields it likes, as fast as it likes. So every check belongs inside the action, never only in the component.

Three Next.js details shape the rest of this page:

- **There is no `request.ip`.** The client IP comes from a header, and which one you can trust depends on the host.
- **A nonce means dynamic rendering.** A per-request nonce can't apply to a page built at build time.
- **Client navigation keeps the page alive.** A Turnstile script loaded once won't render a widget again after a soft navigation.

Diagram: The order checks run in inside the Next.js server action

1. The form, or a script replaying it, posts to the server action.
2. The action checks the visitor's count against a Vercel Firewall rule. Over the limit, it returns an error state and stops.
3. It sends the Turnstile token and the client IP to siteverify, and checks the action name and hostname in the answer.
4. Only then does it forward the fields, honeypot included, to Ikiform, which scores and stores the message.

The cheapest check runs first. The honeypot travels on untouched, so a filled one is stored as spam rather than silently dropped.

## Project setup

The files this page adds to an App Router project:

`Files`:

```text
app/
  contact/
    page.tsx
    contact-form.tsx
    actions.ts
lib/
  client-ip.ts
  turnstile.ts
proxy.ts
.env.local
```

Create a Turnstile widget in the Cloudflare dashboard and list every hostname the form runs on. While you build, use Cloudflare's test keys, which pass on any host, including `localhost`:

`.env.local`:

```bash
NEXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
TURNSTILE_HOSTNAME=
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
CLIENT_IP_HEADER=x-real-ip
```

- Only `NEXT_PUBLIC_TURNSTILE_SITE_KEY` reaches the browser. Next.js inlines `NEXT_PUBLIC_` values at build time, so changing the site key needs a new build.
- `TURNSTILE_HOSTNAME` stays empty locally, because test tokens don't carry your hostname. Set it to your real domain in production.
- Swap the secret for `2x0000000000000000000000000000000AA` to watch every check fail.

## Read the client IP your host sets

A rate limit keyed on a header the visitor can write is no limit. Each host sets a header it overwrites on the way in, and that is the only one to trust:

Diagram: Which header carries the real client IP on each host

1. clientIp() reads the one header named in CLIENT_IP_HEADER and ignores the rest.
2. Vercel overwrites x-forwarded-for and x-real-ip with the visitor's address.
3. Netlify puts the address in x-nf-client-connection-ip.
4. Cloudflare, including Next.js deployed with OpenNext, sets cf-connecting-ip.

Vercel documents that it overwrites X-Forwarded-For to prevent spoofing. Netlify's docs name only context.ip in Functions; x-nf-client-connection-ip is the header Netlify staff point to.

`lib/client-ip.ts`:

```ts
import "server-only";
import { headers } from "next/headers";

const HEADER = process.env.CLIENT_IP_HEADER ?? "x-real-ip";

export async function clientIp() {
  const value = (await headers()).get(HEADER);
  return value?.split(",")[0]?.trim() || null;
}
```

## Verify Turnstile in a server-only helper

`lib/turnstile.ts`:

```ts
import "server-only";

interface Verdict {
  success: boolean;
  hostname?: string;
  action?: string;
  "error-codes"?: string[];
}

export type Check = { ok: true } | { ok: false; reason: string };

export async function verifyTurnstile(token: string, ip: string | null): Promise<Check> {
  if (!token || token.length > 2048) return { ok: false, reason: "missing-input-response" };

  let verdict: Verdict;
  try {
    const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ secret: process.env.TURNSTILE_SECRET_KEY, response: token, remoteip: ip ?? undefined }),
      signal: AbortSignal.timeout(5000),
    });
    verdict = (await response.json()) as Verdict;
  } catch {
    return { ok: false, reason: "siteverify-unreachable" };
  }

  if (!verdict.success) return { ok: false, reason: verdict["error-codes"]?.join(",") || "failed" };
  if (verdict.action !== "contact") return { ok: false, reason: "wrong-action" };
  const expected = process.env.TURNSTILE_HOSTNAME;
  if (expected && verdict.hostname !== expected) return { ok: false, reason: "wrong-hostname" };
  return { ok: true };
}
```

- A token is valid for 300 seconds and can be verified once, then siteverify returns `timeout-or-duplicate`.
- `success` alone isn't enough. A token minted on another site with your site key also says `success`, so the helper checks `action` and, in production, `hostname`.
- It fails closed. If Cloudflare is unreachable, the visitor sees an error and no bot gets through.

## The server action

`app/contact/actions.ts`:

```ts
"use server";

import { checkRateLimit } from "@vercel/firewall";
import { headers } from "next/headers";
import { clientIp } from "@/lib/client-ip";
import { verifyTurnstile } from "@/lib/turnstile";

export interface ContactState {
  status: "idle" | "sent" | "error";
  message: string;
  fields?: { name: string; email: string; message: string };
}

export async function sendMessage(_previous: ContactState, formData: FormData): Promise<ContactState> {
  const fields = {
    name: String(formData.get("name") ?? "").trim(),
    email: String(formData.get("email") ?? "").trim(),
    message: String(formData.get("message") ?? "").trim(),
  };

  if (process.env.VERCEL) {
    const { rateLimited } = await checkRateLimit("contact-form", { headers: await headers() });
    if (rateLimited) return { status: "error", message: "Too many messages from your connection. Try again in a minute.", fields };
  }

  const check = await verifyTurnstile(String(formData.get("cf-turnstile-response") ?? ""), await clientIp());
  if (!check.ok) {
    console.warn("turnstile", check.reason);
    return { status: "error", message: "We couldn't confirm you're human. Try the check again.", fields };
  }

  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
      Origin: (await headers()).get("origin") ?? "",
    },
    body: JSON.stringify({ ...fields, _gotcha: String(formData.get("_gotcha") ?? "") }),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);

  const result = await response?.json().catch(() => null);
  if (result?.ok) return { status: "sent", message: "Thanks. We'll reply by email." };
  return { status: "error", message: result?.error?.message ?? "Your message didn't send. Try again.", fields };
}
```

On Vercel, the limit comes from the Firewall. Open your project's **Firewall**, add a rule whose condition is `@vercel/firewall`, set the **Rate limit ID** to `contact-form`, and choose a window such as 5 requests a minute. `checkRateLimit` takes the request headers, so it works inside a server action where there is no `Request` object, and it buckets by client IP unless you pass a `rateLimitKey`.

- Vercel counts each rule per region, so traffic spread across regions can exceed the number you set. For a contact form that's fine; for an exact global count, use Redis, as on the [rate limiting page](https://ikiform.com/guides/spam-protection/rate-limiting.md).
- The visitor's `Origin` header is passed on because a server-side fetch sends none, and Ikiform reads it to recognise free `localhost` tests.
- Off Vercel, the Firewall call is skipped. Put a Redis or Workers limiter there, keyed on `clientIp()`.

> 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 form and the widget

`app/contact/contact-form.tsx`:

```tsx
"use client";

import Script from "next/script";
import { useActionState, useEffect, useRef } from "react";
import { type ContactState, sendMessage } from "./actions";

interface Turnstile {
  render(container: HTMLElement, options: { sitekey: string; action: string }): string;
  reset(widgetId: string): void;
  remove(widgetId: string): void;
}

declare global {
  interface Window {
    turnstile?: Turnstile;
  }
}

const initial: ContactState = { status: "idle", message: "" };

export function ContactForm({ nonce }: { nonce?: string }) {
  const [state, action, pending] = useActionState(sendMessage, initial);
  const box = useRef<HTMLDivElement>(null);
  const widget = useRef<string | null>(null);

  function mount() {
    if (!box.current || !window.turnstile || widget.current) return;
    widget.current = window.turnstile.render(box.current, {
      sitekey: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!,
      action: "contact",
    });
  }

  useEffect(() => {
    if (state.status === "error" && widget.current) window.turnstile?.reset(widget.current);
  }, [state]);

  useEffect(
    () => () => {
      if (widget.current) window.turnstile?.remove(widget.current);
      widget.current = null;
    },
    [],
  );

  if (state.status === "sent") return <p role="status">{state.message}</p>;

  return (
    <form action={action}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required maxLength={120} defaultValue={state.fields?.name} />
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required defaultValue={state.fields?.email} />
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={5} required minLength={10} defaultValue={state.fields?.message} />
      <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>
      <div ref={box} />
      {state.status === "error" ? <p role="alert">{state.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
      <Script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" nonce={nonce} onReady={mount} />
    </form>
  );
}
```

- `?render=explicit` stops the script from scanning the page for widgets. `onReady` runs once the script loads and again every time the component mounts, which covers client-side navigation.
- A token works once. When the action returns an error, the effect resets the widget so the next attempt carries a fresh token.
- The form posts before hydration too, because `action` is the server action itself. Without JavaScript there is no widget, so siteverify refuses the post; the human check needs a browser.
- The `.hp` class from the [honeypot guide](https://ikiform.com/guides/spam-protection/honeypot.md) hides the honeypot off-screen.

`app/contact/page.tsx`:

```tsx
import { headers } from "next/headers";
import { ContactForm } from "./contact-form";

export default async function Page() {
  const nonce = (await headers()).get("x-nonce") ?? undefined;

  return (
    <main>
      <h1>Contact us</h1>
      <ContactForm nonce={nonce} />
    </main>
  );
}
```

## A CSP that lets Turnstile run

Next.js reads the nonce from the `Content-Security-Policy` request header and adds it to its own scripts and to any `<Script>` given the `nonce` prop. Cloudflare says Turnstile passes the nonce on to what it loads, and that its iframe needs `frame-src https://challenges.cloudflare.com`. In Next.js 16 the file is `proxy.ts`, which replaced `middleware.ts`:

`proxy.ts`:

```ts
import { type NextRequest, NextResponse } from "next/server";

export function proxy(request: NextRequest) {
  const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
  const dev = process.env.NODE_ENV === "development";
  const policy = [
    "default-src 'self'",
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https://challenges.cloudflare.com${dev ? " 'unsafe-eval'" : ""}`,
    "frame-src https://challenges.cloudflare.com",
    `style-src 'self' 'nonce-${nonce}'`,
    "img-src 'self' blob: data:",
    "object-src 'none'",
    "base-uri 'self'",
    "form-action 'self'",
    "frame-ancestors 'none'",
  ].join("; ");

  const requestHeaders = new Headers(request.headers);
  requestHeaders.set("x-nonce", nonce);
  requestHeaders.set("Content-Security-Policy", policy);
  const response = NextResponse.next({ request: { headers: requestHeaders } });
  response.headers.set("Content-Security-Policy", policy);
  return response;
}

export const config = {
  matcher: [
    {
      source: "/((?!api|_next/static|_next/image|favicon.ico).*)",
      missing: [
        { type: "header", key: "next-router-prefetch" },
        { type: "header", key: "purpose", value: "prefetch" },
      ],
    },
  ],
};
```

- With `'strict-dynamic'`, modern browsers trust the nonce and ignore the host, which stays for older browsers.
- Reading `headers()` in the page makes it render per request, which a nonce requires. A statically built contact page can't carry one; use a policy without nonces in `next.config` `headers()` instead, at the cost of `'unsafe-inline'`.

## Deploying

| Host | CLIENT_IP_HEADER | Rate limit | Notes |
| --- | --- | --- | --- |
| Vercel | `x-real-ip` | Firewall rule and `checkRateLimit` | Add a rule per environment you test in |
| Netlify | `x-nf-client-connection-ip` | Redis, such as Upstash | Server actions run in one Netlify function |
| Cloudflare, with OpenNext | `cf-connecting-ip` | Workers rate limiting binding | The binding is approximate and counted per location |

Set `TURNSTILE_HOSTNAME` to your real domain in production. Preview deployments get their own hostnames, so add them to the widget or leave the check off in preview.

## Let Ikiform run the checks

Everything above exists because the server action forwards with a secret key, which turns off Ikiform's own human check and per-visitor limit. Post from the browser instead and Ikiform runs the honeypot, Turnstile and the rate limit for you. There are no Cloudflare keys, no `proxy.ts` changes beyond allowing `https://ikiform.com` in `frame-src` and `connect-src`, and no action:

`app/contact/contact-form.tsx`:

```tsx
"use client";

import { challenge, submit } from "@ikiform/sdk";
import { useActionState, useRef } from "react";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";

export function ContactForm() {
  const frame = useRef<HTMLDivElement>(null);
  const [message, action, pending] = useActionState(async (_previous: string, data: FormData) => {
    try {
      const turnstileToken = await challenge(FORM_ID, { container: frame.current ?? undefined });
      const result = await submit(FORM_ID, data, { turnstileToken });
      return result.ok ? "sent" : result.error.message;
    } catch {
      return "The human check didn't finish. Try again.";
    }
  }, "");

  if (message === "sent") return <p role="status">Thanks. We'll reply by email.</p>;

  return (
    <form action={action}>
      <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} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      <div ref={frame} />
      {message ? <p role="alert">{message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
    </form>
  );
}
```

Set the form's **Spam check** to **Ikiform** and its **Rate limit** to Strict, 2 every 10 minutes per visitor, and add your domain plus `localhost` under **Allowed domains**. `challenge()` mounts a small ikiform.com frame that stays invisible unless Turnstile needs a click. Spam gets the same success answer as a real message, is stored with the reasons it was caught, and is kept for 30 days before it is deleted. The [Next.js setup guide](https://ikiform.com/docs/guides/nextjs.md) covers creating the form and its keys.

## Check it works

1. Run `next dev` with the test keys and send a message. It arrives under **Submissions**. Without a plan, a workspace accepts 50 test submissions a month from `localhost`.
2. Set `TURNSTILE_SECRET_KEY` to the always-fail secret and submit. The form shows the human-check error and keeps your text.
3. On a Vercel preview with the Firewall rule, submit six times in a minute. The sixth shows the limit message.
4. 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.
5. Open the page with the console showing. There are no CSP violations for `challenges.cloudflare.com`.

## Gotchas

| Problem | What to do |
| --- | --- |
| The widget never appears after navigating to the page | The script loaded on an earlier page and won't scan again. Render from `onReady`, as in the sample, not from `onLoad`. |
| Every message fails the human check in production | `TURNSTILE_HOSTNAME` doesn't match the hostname siteverify returns, often `www.` versus the apex domain. Log `check.reason` to see which. |
| Every visitor shares one rate-limit bucket | `CLIENT_IP_HEADER` names a header your host doesn't set, so `clientIp()` returns null. Pick the header from the table above. |
| `checkRateLimit` returns `error: "not-found"` | No Firewall rule has the Rate limit ID `contact-form` in this environment. Add it, then publish the Firewall changes. |

## Questions

### How do I add Cloudflare Turnstile to a Next.js server action?

Render the widget in a client component, load the script with next/script, and read the cf-turnstile-response field in the server action. Send it with your secret key to siteverify and only continue when the answer says success with the action and hostname you expect.

### How do I get the client IP in a Next.js server action?

Read it from the header your host sets with headers() from next/headers: x-real-ip or x-forwarded-for on Vercel, x-nf-client-connection-ip on Netlify, cf-connecting-ip on Cloudflare. Next.js has no request.ip in the App Router.

### Does a CSP nonce stop the contact page from being static?

Yes. A nonce is created per request, so the page must render on each request. Keep the contact page dynamic, or use a policy without nonces that allows challenges.cloudflare.com by host.

### 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.
