// BLOG: POST
Back
DEEPAK NEGI

Implementing Cloudflare Turnstile in Next.js

A practical guide to adding Cloudflare Turnstile CAPTCHA to your Next.js App Router project — from widget setup to server-side token verification.

20 May 2026
CONTENT

Cloudflare Turnstile is a privacy-friendly CAPTCHA alternative that challenges bots without showing image puzzles to real users. This guide covers integrating Turnstile into a Next.js App Router project, verifying tokens server-side in both Server Actions and Route Handlers, and handling edge cases cleanly.


Why Turnstile over reCAPTCHA

TurnstilereCAPTCHA v3
User frictionNone for most usersNone (score-based)
PrivacyNo tracking, GDPR-friendlyTracks users across Google properties
PriceFreeFree (with limits)
AccuracyHigh — uses browser signalsHigh — uses Google browsing history
ScoreNot exposed0.0–1.0 score in response

Turnstile is the better default for new projects: free, GDPR-compliant, and no image challenges.


How It Works

  1. The Turnstile widget loads in the browser and runs a challenge invisibly (device signals, browser APIs, interaction patterns).
  2. On success, the widget calls your callback with a short-lived token.
  3. Your form submits the token alongside other data.
  4. Your server verifies the token with Cloudflare's siteverify API before processing the request.

The token is single-use and expires after 300 seconds.


Setup

Create a Turnstile site

  1. Go to the Cloudflare Dashboard → Turnstile → Add site.
  2. Choose widget type — Managed is recommended (Cloudflare decides visible vs. invisible challenge automatically).
  3. Add your domain(s). For local development, add localhost.
  4. Copy the Site Key (public) and Secret Key (private).

Add environment variables

# .env.local
NEXT_PUBLIC_TURNSTILE_SITE_KEY=0x4AAAAAAA...   # public, goes to browser
TURNSTILE_SECRET_KEY=0x4AAAAAAA...              # private, server-only

NEXT_PUBLIC_ prefix is required for Next.js to expose a variable to the client bundle.

Install the React wrapper (optional)

You can load the script manually or use a community wrapper:

bun add @marsidev/react-turnstile

Client Integration

Using the React wrapper

"use client";

import { Turnstile } from "@marsidev/react-turnstile";
import { useState } from "react";

export function ContactForm() {
  const [token, setToken] = useState<string | null>(null);

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    if (!token) return;

    const formData = new FormData(e.currentTarget);
    formData.append("cf-turnstile-response", token);

    const res = await fetch("/api/contact", {
      method: "POST",
      body: formData,
    });

    const data = await res.json();
    if (!data.ok) {
      console.error("Submission failed:", data.error);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="email" placeholder="Email" type="email" />
      <textarea name="message" placeholder="Message" />

      <Turnstile
        onSuccess={(t) => setToken(t)}
        onExpire={() => setToken(null)}
        onError={() => setToken(null)}
        siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
      />

      <button disabled={!token} type="submit">
        Send
      </button>
    </form>
  );
}

Loading the script manually (no dependency)

If you prefer not to add a package, load the Cloudflare script directly and render the widget with data- attributes:

// app/layout.tsx
import Script from "next/script";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        {children}
        <Script
          src="https://challenges.cloudflare.com/turnstile/v0/api.js"
          strategy="lazyOnload"
        />
      </body>
    </html>
  );
}
// In your form component
<div
  className="cf-turnstile"
  data-sitekey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY}
  data-callback="onTurnstileSuccess"
/>

The manual approach is fine for simple forms but makes it harder to read the token in React state. The wrapper is recommended for App Router projects.


Server-Side Verification

Never trust the token client-side. Always verify it with Cloudflare before processing the request.

Shared verification utility

// lib/turnstile.ts

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

export async function verifyTurnstileToken(token: string): Promise<boolean> {
  const res = await fetch(
    "https://challenges.cloudflare.com/turnstile/v0/siteverify",
    {
      body: new URLSearchParams({
        secret: process.env.TURNSTILE_SECRET_KEY!,
        response: token,
      }),
      method: "POST",
    },
  );

  const data: TurnstileVerifyResponse = await res.json();
  return data.success === true;
}

In a Route Handler

// app/api/contact/route.ts
import { NextResponse } from "next/server";
import { verifyTurnstileToken } from "@/lib/turnstile";

export async function POST(request: Request) {
  const formData = await request.formData();

  const token = formData.get("cf-turnstile-response");
  if (!token || typeof token !== "string") {
    return NextResponse.json(
      { error: "Missing Turnstile token", ok: false },
      { status: 400 },
    );
  }

  const valid = await verifyTurnstileToken(token);
  if (!valid) {
    return NextResponse.json(
      { error: "Turnstile verification failed", ok: false },
      { status: 403 },
    );
  }

  // Process the form — bot check passed
  const email = formData.get("email");
  const message = formData.get("message");

  // ... save to DB, send email, etc.

  return NextResponse.json({ ok: true });
}

In a Server Action

// app/actions/contact.ts
"use server";

import { verifyTurnstileToken } from "@/lib/turnstile";

export async function submitContact(formData: FormData) {
  const token = formData.get("cf-turnstile-response");

  if (!token || typeof token !== "string") {
    return { error: "Missing Turnstile token", ok: false };
  }

  const valid = await verifyTurnstileToken(token);
  if (!valid) {
    return { error: "Bot check failed. Please try again.", ok: false };
  }

  const email = formData.get("email") as string;
  const message = formData.get("message") as string;

  // ... handle submission

  return { ok: true };
}

Using it in the component:

"use client";

import { useTransition } from "react";
import { submitContact } from "@/app/actions/contact";
import { Turnstile } from "@marsidev/react-turnstile";

export function ContactForm() {
  const [isPending, startTransition] = useTransition();

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    startTransition(async () => {
      const result = await submitContact(formData);
      if (!result.ok) console.error(result.error);
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="email" type="email" />
      <textarea name="message" />
      <Turnstile
        name="cf-turnstile-response"
        siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
      />
      <button disabled={isPending} type="submit">Send</button>
    </form>
  );
}

When you pass name to the Turnstile component, the token is automatically included in FormData — no useState needed.


Protecting Auth Routes

Turnstile is particularly useful on sign-in, sign-up, and password-reset forms to stop credential stuffing and registration spam.

// app/(auth)/register/page.tsx
"use client";

import { Turnstile } from "@marsidev/react-turnstile";
import { signUp } from "@repo/auth/client";

export function RegisterForm() {
  const [token, setToken] = useState<string | null>(null);

  const handleSubmit = async (data: RegisterInput) => {
    if (!token) return;

    // Pass the token to your server action / API that
    // verifies Turnstile before calling signUp.email()
    const result = await registerWithTurnstile({ ...data, turnstileToken: token });
    // ...
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* ... email, password fields ... */}

      <Turnstile
        onError={() => setToken(null)}
        onExpire={() => setToken(null)}
        onSuccess={setToken}
        siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
      />

      <button disabled={!token} type="submit">Create Account</button>
    </form>
  );
}

Server-side handler:

// app/actions/auth.ts
"use server";

import { verifyTurnstileToken } from "@/lib/turnstile";
import { auth } from "@repo/auth/server";
import { headers } from "next/headers";

export async function registerWithTurnstile(data: {
  email: string;
  password: string;
  name: string;
  turnstileToken: string;
}) {
  const valid = await verifyTurnstileToken(data.turnstileToken);
  if (!valid) {
    return { error: "Bot check failed", ok: false };
  }

  return auth.api.signUpEmail({
    body: { email: data.email, name: data.name, password: data.password },
    headers: await headers(),
  });
}

Local Development

Cloudflare provides test keys that always pass or always fail — useful for CI and local development.

Site KeyBehavior
1x00000000000000000000AAAlways passes
2x00000000000000000000ABAlways blocks
3x00000000000000000000FFForces an interactive challenge
# .env.local (development only)
NEXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA

The corresponding secret key for the always-pass site key is 1x0000000000000000000000000000000AA.


Token Expiry and Reset

Tokens expire after 300 seconds. If the user takes a long time to fill the form, the token will be stale and verification will fail. Handle this by resetting the widget on expiry:

<Turnstile
  onExpire={() => {
    setToken(null);
    // The widget resets itself automatically;
    // your state just needs to clear the old token.
  }}
  onSuccess={setToken}
  siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
/>

You can also programmatically reset using the ref API:

import { type TurnstileInstance } from "@marsidev/react-turnstile";
import { useRef } from "react";

const turnstileRef = useRef<TurnstileInstance>(null);

// After a failed submission:
turnstileRef.current?.reset();

<Turnstile ref={turnstileRef} siteKey={...} onSuccess={setToken} />

Quick Checklist

  • Create a Turnstile site in the Cloudflare dashboard and add localhost for development
  • Add NEXT_PUBLIC_TURNSTILE_SITE_KEY (public) and TURNSTILE_SECRET_KEY (private) to .env.local
  • Render the widget with @marsidev/react-turnstile or the manual script approach
  • Pass the token in form data (via name prop or explicit useState)
  • Verify the token server-side with https://challenges.cloudflare.com/turnstile/v0/siteverify before processing any request
  • Use Cloudflare's test keys (1x00000000000000000000AA) for development and CI
  • Handle token expiry by clearing state on onExpire
  • Never skip server-side verification — the client-side widget alone provides no security

Symptoms of misconfiguration

  • Widget loads but token is always rejected → check that you are using the secret key (not the site key) in siteverify
  • Widget does not appear → check that NEXT_PUBLIC_TURNSTILE_SITE_KEY is set and the domain is registered in the Cloudflare dashboard
  • success: false with error-codes: ["invalid-input-response"] → token was already used or has expired; reset the widget
  • success: false with error-codes: ["invalid-input-secret"] → wrong secret key in your environment variable
All Posts© 2026 Deepak Negi