Stop contact form spam8 min read
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.
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.
- The form, or a script replaying it, posts to the server action.
- The action checks the visitor's count against a Vercel Firewall rule. Over the limit, it returns an error state and stops.
- It sends the Turnstile token and the client IP to siteverify, and checks the action name and hostname in the answer.
- Only then does it forward the fields, honeypot included, to Ikiform, which scores and stores the message.
Project setup
The files this page adds to an App Router project:
app/
contact/
page.tsx
contact-form.tsx
actions.ts
lib/
client-ip.ts
turnstile.ts
proxy.ts
.env.localCreate 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:
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_KEYreaches the browser. Next.js inlinesNEXT_PUBLIC_values at build time, so changing the site key needs a new build. TURNSTILE_HOSTNAMEstays empty locally, because test tokens don't carry your hostname. Set it to your real domain in production.- Swap the secret for
2x0000000000000000000000000000000AAto 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:
- clientIp() reads the one header named in CLIENT_IP_HEADER and ignores the rest.
- Vercel overwrites x-forwarded-for and x-real-ip with the visitor's address.
- Netlify puts the address in x-nf-client-connection-ip.
- Cloudflare, including Next.js deployed with OpenNext, sets cf-connecting-ip.
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
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. successalone isn't enough. A token minted on another site with your site key also sayssuccess, so the helper checksactionand, in production,hostname.- It fails closed. If Cloudflare is unreachable, the visitor sees an error and no bot gets through.
The server action
"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.
- The visitor's
Originheader is passed on because a server-side fetch sends none, and Ikiform reads it to recognise freelocalhosttests. - Off Vercel, the Firewall call is skipped. Put a Redis or Workers limiter there, keyed on
clientIp().
IKIFORM_SECRET_KEY in server-only environment variables.The form and the widget
"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=explicitstops the script from scanning the page for widgets.onReadyruns 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
actionis the server action itself. Without JavaScript there is no widget, so siteverify refuses the post; the human check needs a browser. - The
.hpclass from the honeypot guide hides the honeypot off-screen.
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:
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 innext.configheaders()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:
"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 covers creating the form and its keys.
Check it works
- Run
next devwith the test keys and send a message. It arrives under Submissions. Without a plan, a workspace accepts 50 test submissions a month fromlocalhost. - Set
TURNSTILE_SECRET_KEYto the always-fail secret and submit. The form shows the human-check error and keeps your text. - On a Vercel preview with the Firewall rule, submit six times in a minute. The sixth shows the limit message.
- 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. - 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
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.
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.
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.
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.