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.
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
| Turnstile | reCAPTCHA v3 | |
|---|---|---|
| User friction | None for most users | None (score-based) |
| Privacy | No tracking, GDPR-friendly | Tracks users across Google properties |
| Price | Free | Free (with limits) |
| Accuracy | High — uses browser signals | High — uses Google browsing history |
| Score | Not exposed | 0.0–1.0 score in response |
Turnstile is the better default for new projects: free, GDPR-compliant, and no image challenges.
How It Works
- The Turnstile widget loads in the browser and runs a challenge invisibly (device signals, browser APIs, interaction patterns).
- On success, the widget calls your callback with a short-lived token.
- Your form submits the token alongside other data.
- 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
- Go to the Cloudflare Dashboard → Turnstile → Add site.
- Choose widget type — Managed is recommended (Cloudflare decides visible vs. invisible challenge automatically).
- Add your domain(s). For local development, add
localhost. - 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-turnstileClient 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
nameto theTurnstilecomponent, the token is automatically included inFormData— nouseStateneeded.
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 Key | Behavior |
|---|---|
1x00000000000000000000AA | Always passes |
2x00000000000000000000AB | Always blocks |
3x00000000000000000000FF | Forces an interactive challenge |
# .env.local (development only)
NEXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AAThe 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
localhostfor development - Add
NEXT_PUBLIC_TURNSTILE_SITE_KEY(public) andTURNSTILE_SECRET_KEY(private) to.env.local - Render the widget with
@marsidev/react-turnstileor the manual script approach - Pass the token in form data (via
nameprop or explicituseState) - Verify the token server-side with
https://challenges.cloudflare.com/turnstile/v0/siteverifybefore 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_KEYis set and the domain is registered in the Cloudflare dashboard success: falsewitherror-codes: ["invalid-input-response"]→ token was already used or has expired; reset the widgetsuccess: falsewitherror-codes: ["invalid-input-secret"]→ wrong secret key in your environment variable