
How to Add Cloudflare Turnstile to Your Next.js App: A Complete Guide
Bot traffic is everywhere. Whether you run a blog or a full application, protecting your forms from automated spam is essential. This guide shows you how to add Cloudflare Turnstile to your Next.js app, a better, privacy-first alternative to reCAPTCHA.
Why Turnstile Over reCAPTCHA?
| Feature | Turnstile | reCAPTCHA |
|---|---|---|
| Privacy | No tracking | Tracks for ads |
| User experience | Usually invisible | Often requires puzzles |
| Free tier | 1M/month | 10K/month |
| Load time | <100ms | 200-500ms |
| GDPR | Built-in | Extra setup needed |
Bottom line: Turnstile is faster, more private, and has a much more generous free tier.
What You'll Need
- Next.js 13+ with App Router
- Node.js 18+
- A free Cloudflare account
- 15 minutes
Get Your API Keys
- Go to dash.cloudflare.com and create an account
- Find Turnstile in the sidebar, click Add site
- Fill in:
- Site name: Something descriptive
- Domain:
localhostfor development (add production domain later) - Widget mode: Choose Managed (best balance of security and UX)
- Copy your two keys:
- Site Key (public) - goes in your frontend
- Secret Key (private) - stays on your server
⚠️ Never commit your Secret Key to git!
Installation
Install the React wrapper:
npm install @marsidev/react-turnstile
Create .env.local:
NEXT_PUBLIC_TURNSTILE_SITE_KEY=your_site_key_here
TURNSTILE_SECRET_KEY=your_secret_key_here
The NEXT_PUBLIC_ prefix makes the site key available to your browser. The secret key stays server-side only.
How It Works
Before we dive into code, let's understand what's happening behind the scenes.
The Turnstile Flow
- Widget Renders: When your form loads, the Turnstile widget initializes and starts analyzing the browser environment
- Challenge Decision: Cloudflare's system decides whether to show an interactive challenge based on:
- Browser fingerprinting (legitimate browsers have consistent patterns)
- User behavior signals (mouse movements, typing patterns)
- Network reputation (IP address history, request patterns)
- Device characteristics (screen size, plugins, capabilities)
- Token Generation:
- If you look human: Widget completes silently in ~100ms, generates a token automatically
- If suspicious: You see an interactive challenge (checkbox or puzzle) to prove you're human
- If clearly a bot: Challenge becomes very difficult or blocks entirely
- Form Submission: The token (a cryptographic proof of challenge completion) is sent with your form data
- Server Verification: Your backend asks Cloudflare "Is this token valid?" before processing the form
Why Sometimes No Click Is Needed
Turnstile uses adaptive challenges. Think of it like airport security:
- Trusted traveler (TSA PreCheck): If you have a good reputation and normal behavior, you breeze through with minimal friction, the widget completes instantly in the background
- Random screening: If something seems slightly off, you get a simple checkbox to click
- Full security check: If multiple red flags appear, you face harder challenges
This happens in real-time based on dozens of signals. Most legitimate users never see a challenge at all.
Implementation
Step 1: Server-Side Verification
Create lib/turnstile.ts:
'use server'
interface TurnstileResponse {
success: boolean
'error-codes': string[]
}
export async function verifyTurnstileToken(token: string): Promise<boolean> {
const secretKey = process.env.TURNSTILE_SECRET_KEY
if (!secretKey || !token) {
console.error('Missing secret key or token')
return false
}
try {
const response = await fetch(
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
secret: secretKey,
response: token,
}),
}
)
const data: TurnstileResponse = await response.json()
if (!data.success) {
console.error('Verification failed:', data['error-codes'])
}
return data.success
} catch (error) {
console.error('Verification error:', error)
return false
}
}
Step 2: Add Turnstile to Your Form
Create components/ContactForm.tsx:
"use client";
import { useActionState, useEffect, useRef, useState } from "react";
import { submitContactForm, type ContactFormState } from "@/lib/actions/contact";
import { Turnstile } from "@marsidev/react-turnstile";
export default function ContactForm() {
const formRef = useRef<HTMLFormElement>(null);
const [turnstileToken, setTurnstileToken] = useState("");
const [state, formAction, isPending] = useActionState(
submitContactForm,
{ success: false, message: "" }
);
// Reset form after successful submission
useEffect(() => {
if (state.success && formRef.current) {
formRef.current.reset();
setTurnstileToken(""); // Clear token to trigger new challenge
}
}, [state.success]);
return (
<form ref={formRef} action={formAction} className="space-y-4">
{/* Hidden input passes token to Server Action */}
<input type="hidden" name="turnstileToken" value={turnstileToken} />
<input
type="text"
name="fullName"
placeholder="Full Name"
required
disabled={isPending}
className="w-full px-4 py-3 border rounded-lg"
/>
<input
type="email"
name="email"
placeholder="Email"
required
disabled={isPending}
className="w-full px-4 py-3 border rounded-lg"
/>
<textarea
name="message"
placeholder="Your message"
rows={4}
required
disabled={isPending}
className="w-full px-4 py-3 border rounded-lg"
/>
{/* Turnstile Widget: Renders challenge and manages token */}
<div className="flex justify-center">
<Turnstile
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || ""}
onSuccess={(token) => setTurnstileToken(token)} // Token ready!
onError={() => setTurnstileToken("")} // Challenge failed
onExpire={() => setTurnstileToken("")} // Token expired (5 min)
/>
</div>
{state.message && (
<div className={state.success ? "text-green-600" : "text-red-600"}>
{state.message}
</div>
)}
{/* Submit disabled until we have a valid token */}
<button
type="submit"
disabled={isPending || !turnstileToken}
className="w-full py-3 bg-blue-600 text-white rounded-lg disabled:opacity-50"
>
{isPending ? "Sending..." : "Send Message"}
</button>
</form>
);
}
How the widget works:
- Initial Render:
<Turnstile>component loads Cloudflare's script and initializes the widget iframe - Challenge Execution:
- Widget analyzes browser silently (most users stop here)
- If needed, shows interactive challenge
- Cloudflare's servers validate the challenge attempt
- Token Generation: When complete, Cloudflare generates a cryptographically signed token (a long random string like
0.aB1cD2eF3gH4...) - Callback:
onSuccessfires with the token, we store it in React state - Button Activation: With
turnstileTokenpopulated, submit button becomes enabled - Form Submission: Hidden input sends token alongside form data to Server Action
Why the hidden input? Server Actions receive data as FormData. The hidden input ensures our token travels with the form submission automatically.
Step 3: Verify in Your Server Action
Create lib/actions/contact.ts:
'use server'
import { verifyTurnstileToken } from '@/lib/turnstile'
export type ContactFormState = {
success: boolean
message: string
}
export async function submitContactForm(
prevState: ContactFormState,
formData: FormData
): Promise<ContactFormState> {
// Extract the token from form data
const turnstileToken = formData.get('turnstileToken') as string
// CRITICAL: Verify with Cloudflare before doing anything else
const isValid = await verifyTurnstileToken(turnstileToken)
if (!isValid) {
return {
success: false,
message: 'Verification failed. Please try again.'
}
}
// Token is valid! Safe to process the form
const fullName = formData.get('fullName') as string
const email = formData.get('email') as string
const message = formData.get('message') as string
// Validate
if (!fullName || !email || !message) {
return {
success: false,
message: 'All fields are required'
}
}
if (!/\S+@\S+\.\S+/.test(email)) {
return {
success: false,
message: 'Invalid email address'
}
}
// Process the form (send email, save to DB, etc.)
try {
// Your business logic here
// await sendEmail({ fullName, email, message })
return {
success: true,
message: 'Message sent successfully!'
}
} catch (error) {
console.error('Form submission error:', error)
return {
success: false,
message: 'Something went wrong. Please try again.'
}
}
}
What happens during verification:
- Token Extraction: We pull the token from FormData (remember the hidden input?)
- Server → Cloudflare: Our
verifyTurnstileTokenfunction sends:{ "secret": "your_secret_key", "response": "0.aB1cD2eF3gH4..." // The user's token } - Cloudflare Checks: Their servers validate:
- Is this token signature valid?
- Was it issued by us (matching site key)?
- Has it been used before? (one-time use)
- Is it expired? (5-minute window)
- Does it match the expected challenge difficulty?
- Response: Cloudflare returns:
{ "success": true, // or false "error-codes": [] // or ["timeout-or-duplicate", etc.] } - Decision: If
success: true, we proceed. Iffalse, we reject the submission.
Why verify server-side? Client-side checks can be bypassed. A bot could send any token or skip the widget entirely. Server-side verification ensures Cloudflare confirms the token is legitimate before you process sensitive operations.
Testing
Development Keys
Cloudflare provides test keys for development:
Always passes:
NEXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
Always shows challenge:
NEXT_PUBLIC_TURNSTILE_SITE_KEY=3x00000000000000000000FF
TURNSTILE_SECRET_KEY=3x0000000000000000000000000000000FF
What to Test
- ✅ Button disabled until challenge completes
- ✅ Form submits successfully with valid token
- ✅ Error shown when verification fails
- ✅ Form resets after successful submission
- ✅ Works on mobile devices
Troubleshooting
Widget doesn't appear?
- Check your site key is correct
- Verify domain is whitelisted in Cloudflare dashboard
- Ensure component has
"use client"at the top
Verification always fails?
- Double-check your secret key (no extra spaces)
- Make sure you're using the secret key, not the site key
- Verify your server can reach
challenges.cloudflare.com
Button stays disabled?
- Check
onSuccesscallback is setting state correctly - Console.log the token to verify it's being set
Token doesn't reset?
- Make sure you're clearing
turnstileTokenstate after submission
Best Practices
Never hardcode keys:
// ✅ Good
<Turnstile siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || ""} />
// ❌ Bad
<Turnstile siteKey="1x00000000000000000000AA" />
Handle token expiration: Tokens expire after 5 minutes. For long forms, reset the token automatically:
useEffect(() => {
if (turnstileToken) {
const timeout = setTimeout(() => {
setTurnstileToken("");
}, 4 * 60 * 1000); // 4 minutes
return () => clearTimeout(timeout);
}
}, [turnstileToken]);
Reuse across forms:
The verifyTurnstileToken helper works for any form—contact, newsletter, comments, etc.
Summary
You've successfully added bot protection to your Next.js forms with Turnstile. The implementation is clean, the user experience is smooth, and your users' privacy is protected.
Key points:
- Always verify tokens server-side
- Use environment variables for your keys
- Reset tokens after each submission
- Test thoroughly before going live
For more details, check the Cloudflare Turnstile docs or the React Turnstile package.
Questions? Issues? Drop a comment below!