Stop contact form spam

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.

Preet SutharFounder, Ikiform

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 order checks run in inside the Next.js server actionserveractiona POSTanyonecan sendratelimitVercelFirewallrule,per IPsiteverifytoken,action,hostnameIkiformscoresthehoneypot,storeserrorstatenothingforwardedunder the limithumanover the limitThe order checks run in inside the Next.js server actionserver actiona POST anyone cansendrate limitVercel Firewall rule,per IPsiteverifytoken, action,hostnameIkiformscores the honeypot,storeserrorstatenothingforwardedunder the limithumanover the limit
  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
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
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:

Which header carries the real client IP on each hostincoming requestclientIp()readsCLIENT_IP_HEADERVercelx-real-ip orx-forwarded-forNetlifyx-nf-client-connection-ipCloudflarecf-connecting-ipWhich header carries the real client IP on each hostincomingrequestclientIp()readsCLIENT_IP_HEADERVercelx-real-ip orx-forwarded-forNetlifyx-nf-client-connection-ipCloudflarecf-connecting-ip
  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
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
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
"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 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 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
"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 hides the honeypot off-screen.
app/contact/page.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
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

HostCLIENT_IP_HEADERRate limitNotes
Vercelx-real-ipFirewall rule and checkRateLimitAdd a rule per environment you test in
Netlifyx-nf-client-connection-ipRedis, such as UpstashServer actions run in one Netlify function
Cloudflare, with OpenNextcf-connecting-ipWorkers rate limiting bindingThe 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
"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

  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

ProblemWhat to do
The widget never appears after navigating to the pageThe 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 productionTURNSTILE_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 bucketCLIENT_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

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