How to Replace reCAPTCHA with Cloudflare Turnstile in Next.js (Step-by-Step)
Switching from Google reCAPTCHA to Cloudflare Turnstile in Next.js takes about 15 minutes.
This guide covers everything: setup, implementation with App Router, Server Actions, error handling, TypeScript support, and testing.
By the end, you'll have invisible bot protection that improves your conversion rates instead of hurting them.
Why Switch from reCAPTCHA to Turnstile?
Before we dive into code, here's why this migration is worth your time:
User experience:
- ❌ reCAPTCHA: 60-80% of users see "select all traffic lights" challenges
- ✅ Turnstile: 5-15% of users see any challenge (95%+ invisible verification)
Conversion rates:
- ❌ reCAPTCHA: -15% to -30% conversion drop
- ✅ Turnstile: -2% to -5% conversion drop
Privacy:
- ❌ reCAPTCHA: Google tracking across sites
- ✅ Turnstile: No cookies, no cross-site tracking
Pricing:
- ❌ reCAPTCHA: Free (but collects user data for ads)
- ✅ Turnstile: 100% free with unlimited requests
Performance:
- ❌ reCAPTCHA: 150KB+ script size
- ✅ Turnstile: 18KB script size
Read more: Why Cloudflare Turnstile is Replacing reCAPTCHA
Prerequisites
Before starting:
✅ Next.js 13+ installed (this guide uses App Router)
✅ Cloudflare account (free — sign up at dash.cloudflare.com)
✅ Basic understanding of Next.js Server Actions
Note: This guide uses Next.js App Router. If you're on Pages Router, the concepts are the same but the file structure differs.
Step 1: Get Your Turnstile Credentials
Create a Turnstile Widget
-
Log in to Cloudflare Dashboard
-
Go to Turnstile (in the sidebar)
-
Click Add Site
-
Fill in the form:
- Site name: Your project name
- Domain: Your domain (use
localhostfor development) - Widget Mode: Select "Managed" (recommended)
-
Click Create
You'll get two keys:
- Site Key (public, goes in frontend)
- Secret Key (private, goes in backend)
Add Keys to Environment Variables
Create or update .env.local:
# .env.localNEXT_PUBLIC_TURNSTILE_SITE_KEY=your_site_key_hereTURNSTILE_SECRET_KEY=your_secret_key_hereImportant: NEXT_PUBLIC_ prefix makes the variable available in the browser. The secret key should NEVER have this prefix.
Step 2: Install Dependencies
Install the React Turnstile component:
npm install @marsidev/react-turnstile# orpnpm add @marsidev/react-turnstile# oryarn add @marsidev/react-turnstileThis is the official React wrapper for Turnstile.
Step 3: Create a Contact Form (Example)
Let's build a complete contact form with Turnstile verification.
Frontend Component
// app/contact/page.tsx'use client';import { useState } from 'react';import { Turnstile } from '@marsidev/react-turnstile';import { submitContactForm } from './actions';export default function ContactPage() { const [token, setToken] = useState<string>(''); const [loading, setLoading] = useState(false); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); async function handleSubmit(e: React.FormEvent<HTMLFormElement>) { e.preventDefault(); setLoading(true); setMessage(null); const formData = new FormData(e.currentTarget); formData.append('turnstile-token', token); const result = await submitContactForm(formData); if (result.success) { setMessage({ type: 'success', text: 'Message sent successfully!' }); e.currentTarget.reset(); setToken(''); // Reset Turnstile } else { setMessage({ type: 'error', text: result.error || 'Something went wrong' }); } setLoading(false); } return ( <div className="max-w-md mx-auto p-6"> <h1 className="text-2xl font-bold mb-6">Contact Us</h1> <form onSubmit={handleSubmit} className="space-y-4"> <div> <label htmlFor="name" className="block text-sm font-medium mb-1"> Name </label> <input type="text" id="name" name="name" required className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" /> </div> <div> <label htmlFor="email" className="block text-sm font-medium mb-1"> Email </label> <input type="email" id="email" name="email" required className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" /> </div> <div> <label htmlFor="message" className="block text-sm font-medium mb-1"> Message </label> <textarea id="message" name="message" rows={4} required className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" /> </div> {/* Turnstile Widget */} <Turnstile siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!} onSuccess={setToken} options={{ theme: 'light', size: 'normal', }} /> <button type="submit" disabled={!token || loading} className="w-full bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition" > {loading ? 'Sending...' : 'Send Message'} </button> {message && ( <div className={`p-4 rounded-lg ${ message.type === 'success' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800' }`} > {message.text} </div> )} </form> </div> );}Backend Server Action
// app/contact/actions.ts'use server';interface TurnstileResponse { success: boolean; 'error-codes'?: string[]; challenge_ts?: string; hostname?: string;}export async function submitContactForm(formData: FormData) { const token = formData.get('turnstile-token') as string; const name = formData.get('name') as string; const email = formData.get('email') as string; const message = formData.get('message') as string; // Validate input if (!token || !name || !email || !message) { return { success: false, error: 'Missing required fields' }; } // Verify Turnstile token const verificationResult = await verifyTurnstileToken(token); if (!verificationResult.success) { return { success: false, error: 'CAPTCHA verification failed. Please try again.' }; } // Process the form (send email, save to database, etc.) try { // Your business logic here console.log('Form submitted:', { name, email, message }); // Example: Send email using Resend // await resend.emails.send({ // from: 'contact@yourdomain.com', // to: 'team@yourdomain.com', // subject: `New contact from ${name}`, // text: message, // }); return { success: true }; } catch (error) { console.error('Form submission error:', error); return { success: false, error: 'Failed to send message' }; }}async function verifyTurnstileToken(token: string): Promise<TurnstileResponse> { 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, }), } ); const data: TurnstileResponse = await response.json(); return data;}Step 4: Migrating from Existing reCAPTCHA
If you already have reCAPTCHA implemented, here's the exact migration path.
Before (reCAPTCHA v2)
// Old reCAPTCHA implementationimport ReCAPTCHA from 'react-google-recaptcha';export default function ContactForm() { const [token, setToken] = useState(''); return ( <form> {/* Other form fields */} <ReCAPTCHA sitekey={process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY!} onChange={setToken} /> <button type="submit">Submit</button> </form> );}After (Turnstile)
// New Turnstile implementationimport { Turnstile } from '@marsidev/react-turnstile';export default function ContactForm() { const [token, setToken] = useState(''); return ( <form> {/* Other form fields */} <Turnstile siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!} onSuccess={setToken} /> <button type="submit">Submit</button> </form> );}Backend Changes
// Before (reCAPTCHA)const response = await fetch( 'https://www.google.com/recaptcha/api/siteverify', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ secret: process.env.RECAPTCHA_SECRET_KEY!, response: token, }), });// After (Turnstile)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, }), });Key differences:
- Different verification URL
- Turnstile uses JSON, reCAPTCHA uses form data
- Different environment variable names
Step 5: Handling Errors Properly
User-Facing Errors
'use client';import { Turnstile } from '@marsidev/react-turnstile';import { useState } from 'react';export default function ContactForm() { const [token, setToken] = useState(''); const [turnstileError, setTurnstileError] = useState(false); return ( <form> {/* Form fields */} <Turnstile siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!} onSuccess={(token) => { setToken(token); setTurnstileError(false); }} onError={() => { setTurnstileError(true); setToken(''); }} onExpire={() => { setToken(''); }} /> {turnstileError && ( <p className="text-red-600 text-sm mt-2"> Verification failed. Please refresh the page and try again. </p> )} <button type="submit" disabled={!token}> Submit </button> </form> );}Backend Error Handling
async function verifyTurnstileToken(token: string) { 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, }), } ); if (!response.ok) { console.error('Turnstile API error:', response.status); return { success: false }; } const data = await response.json(); if (!data.success) { console.error('Turnstile verification failed:', data['error-codes']); return { success: false, errors: data['error-codes'] }; } return { success: true }; } catch (error) { console.error('Turnstile verification exception:', error); return { success: false }; }}Common Error Codes
| Error Code | Meaning | Solution |
|---|---|---|
missing-input-secret | Secret key not provided | Check environment variable |
invalid-input-secret | Secret key is wrong | Verify key in Cloudflare dashboard |
missing-input-response | Token not provided | Ensure token is sent from frontend |
invalid-input-response | Token is invalid or expired | Token expired (5 min lifetime) |
timeout-or-duplicate | Token already used | Tokens are single-use only |
Step 6: Advanced Configuration
Custom Styling
<Turnstile siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!} onSuccess={setToken} options={{ theme: 'light', // 'light' | 'dark' | 'auto' size: 'normal', // 'normal' | 'compact' tabIndex: 0, responseField: true, responseFieldName: 'cf-turnstile-response', retry: 'auto', // 'auto' | 'never' retryInterval: 8000, // milliseconds appearance: 'always', // 'always' | 'execute' | 'interaction-only' }}/>Invisible Mode (Beta)
<Turnstile siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!} onSuccess={setToken} options={{ appearance: 'interaction-only', // Only shows if needed }}/>Programmatic Reset
'use client';import { Turnstile } from '@marsidev/react-turnstile';import { useRef } from 'react';export default function ContactForm() { const turnstileRef = useRef<any>(null); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); // Submit form... // Reset Turnstile after successful submission turnstileRef.current?.reset(); } return ( <form onSubmit={handleSubmit}> <Turnstile ref={turnstileRef} siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!} onSuccess={setToken} /> <button type="submit">Submit</button> </form> );}Step 7: Testing Your Implementation
Local Testing
- Start dev server:
npm run dev-
Test the form:
- Fill out all fields
- Wait for Turnstile widget to load
- Submit form
- Check console for verification result
-
Test error cases:
- Submit without filling Turnstile (should fail)
- Submit with expired token (wait 5+ minutes)
- Check network tab for API calls
Testing with Different Modes
Cloudflare provides test keys for development:
# Always passesNEXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AATURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA# Always failsNEXT_PUBLIC_TURNSTILE_SITE_KEY=2x00000000000000000000ABTURNSTILE_SECRET_KEY=2x0000000000000000000000000000000AB# Always shows challengeNEXT_PUBLIC_TURNSTILE_SITE_KEY=3x00000000000000000000FFTURNSTILE_SECRET_KEY=3x0000000000000000000000000000000FFUse these for automated testing.
Automated Testing (Playwright)
// tests/contact.spec.tsimport { test, expect } from '@playwright/test';test('contact form submission with Turnstile', async ({ page }) => { await page.goto('/contact'); // Fill form await page.fill('input[name="name"]', 'John Doe'); await page.fill('input[name="email"]', 'john@example.com'); await page.fill('textarea[name="message"]', 'Test message'); // Wait for Turnstile to load await page.waitForSelector('iframe[src*="challenges.cloudflare.com"]'); // In test environment, Turnstile should auto-verify with test keys await page.waitForTimeout(2000); // Submit form await page.click('button[type="submit"]'); // Check for success message await expect(page.locator('text=Message sent successfully')).toBeVisible();});Step 8: Production Deployment
Checklist Before Going Live
✅ Replace test keys with production keys
✅ Add production domain to Turnstile dashboard
✅ Test on production domain (not just localhost)
✅ Verify backend verification is working
✅ Check CSP headers allow Cloudflare domains
✅ Monitor form submission success rate
Content Security Policy (CSP)
If you use CSP headers, add these directives:
// next.config.jsconst cspHeader = ` script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com; frame-src 'self' https://challenges.cloudflare.com; connect-src 'self' https://challenges.cloudflare.com;`;module.exports = { async headers() { return [ { source: '/:path*', headers: [ { key: 'Content-Security-Policy', value: cspHeader.replace(/\n/g, ''), }, ], }, ]; },};Environment Variables in Production
Make sure to set these in your hosting platform:
- Vercel: Project Settings → Environment Variables
- Netlify: Site Settings → Build & Deploy → Environment
- Railway: Project → Variables
- Self-hosted: Add to
.env.production
Complete Working Example (Copy-Paste Ready)
Here's a complete, production-ready contact form:
// app/contact/page.tsx'use client';import { useState, useRef } from 'react';import { Turnstile } from '@marsidev/react-turnstile';import { submitContactForm } from './actions';export default function ContactPage() { const [token, setToken] = useState(''); const [loading, setLoading] = useState(false); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); const turnstileRef = useRef<any>(null); const formRef = useRef<HTMLFormElement>(null); async function handleSubmit(e: React.FormEvent<HTMLFormElement>) { e.preventDefault(); if (!token) { setMessage({ type: 'error', text: 'Please complete the verification' }); return; } setLoading(true); setMessage(null); const formData = new FormData(e.currentTarget); formData.append('turnstile-token', token); const result = await submitContactForm(formData); if (result.success) { setMessage({ type: 'success', text: 'Message sent successfully!' }); formRef.current?.reset(); turnstileRef.current?.reset(); setToken(''); } else { setMessage({ type: 'error', text: result.error || 'Something went wrong' }); turnstileRef.current?.reset(); } setLoading(false); } return ( <div className="min-h-screen bg-gray-50 py-12 px-4"> <div className="max-w-md mx-auto bg-white rounded-lg shadow-md p-8"> <h1 className="text-3xl font-bold text-gray-900 mb-6">Contact Us</h1> <form ref={formRef} onSubmit={handleSubmit} className="space-y-5"> <div> <label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1"> Name * </label> <input type="text" id="name" name="name" required className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none" /> </div> <div> <label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1"> Email * </label> <input type="email" id="email" name="email" required className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none" /> </div> <div> <label htmlFor="message" className="block text-sm font-medium text-gray-700 mb-1"> Message * </label> <textarea id="message" name="message" rows={5} required className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none resize-none" /> </div> <div> <Turnstile ref={turnstileRef} siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!} onSuccess={setToken} onError={() => setMessage({ type: 'error', text: 'Verification failed' })} onExpire={() => setToken('')} options={{ theme: 'light', size: 'normal', }} /> </div> <button type="submit" disabled={!token || loading} className="w-full bg-blue-600 text-white font-medium py-3 px-4 rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition duration-200" > {loading ? 'Sending...' : 'Send Message'} </button> {message && ( <div className={`p-4 rounded-lg border ${ message.type === 'success' ? 'bg-green-50 border-green-200 text-green-800' : 'bg-red-50 border-red-200 text-red-800' }`} > {message.text} </div> )} </form> </div> </div> );}// app/contact/actions.ts'use server';interface TurnstileResponse { success: boolean; 'error-codes'?: string[];}export async function submitContactForm(formData: FormData) { const token = formData.get('turnstile-token') as string; const name = formData.get('name') as string; const email = formData.get('email') as string; const message = formData.get('message') as string; // Validate if (!token) { return { success: false, error: 'CAPTCHA token missing' }; } if (!name || !email || !message) { return { success: false, error: 'All fields are required' }; } // Verify Turnstile try { const verification = 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, }), } ); const result: TurnstileResponse = await verification.json(); if (!result.success) { console.error('Turnstile verification failed:', result['error-codes']); return { success: false, error: 'Verification failed. Please try again.' }; } // Process form (send email, save to DB, etc.) console.log('Form data:', { name, email, message }); return { success: true }; } catch (error) { console.error('Form submission error:', error); return { success: false, error: 'Server error. Please try again later.' }; }}Monitoring & Analytics
Track Verification Success Rate
// lib/analytics.tsexport function trackTurnstileEvent(event: 'success' | 'error' | 'expire') { // Using Vercel Analytics if (typeof window !== 'undefined' && window.va) { window.va('event', { name: `turnstile_${event}` }); } // Or using Google Analytics if (typeof window !== 'undefined' && window.gtag) { window.gtag('event', `turnstile_${event}`); }}// In your component<Turnstile siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!} onSuccess={(token) => { setToken(token); trackTurnstileEvent('success'); }} onError={() => { trackTurnstileEvent('error'); }} onExpire={() => { trackTurnstileEvent('expire'); }}/>Troubleshooting Common Issues
Issue: Widget Not Showing
Causes:
- Site key is incorrect
- Domain not added to Turnstile dashboard
- CSP blocking Cloudflare domains
Solution:
- Verify
NEXT_PUBLIC_TURNSTILE_SITE_KEYis correct - Check browser console for errors
- Add domain in Cloudflare dashboard
- Update CSP headers
Issue: "Verification Failed" on Valid Submission
Causes:
- Token expired (5-minute lifetime)
- Token already used (single-use only)
- Server time out of sync
Solution:
- Reset Turnstile widget after each submission
- Ensure tokens aren't reused
- Sync server time (use NTP)
Issue: High False Positive Rate
Causes:
- Security level too strict
- VPN/proxy users flagged
- Bot Fight Mode enabled
Solution:
- Adjust security level in Cloudflare dashboard
- Lower Bot Fight Mode sensitivity
- Whitelist known good IPs
Performance Optimization
Lazy Load Turnstile
Only load Turnstile when form is visible:
'use client';import dynamic from 'next/dynamic';const Turnstile = dynamic( () => import('@marsidev/react-turnstile').then((mod) => mod.Turnstile), { ssr: false });export default function ContactForm() { return ( <form> {/* Form fields */} <Turnstile siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!} /> </form> );}Preconnect to Cloudflare
Add to app/layout.tsx:
export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html> <head> <link rel="preconnect" href="https://challenges.cloudflare.com" /> </head> <body>{children}</body> </html> );}Related Guides
Learn more about Cloudflare Turnstile:
👉 Why Cloudflare Turnstile is Replacing reCAPTCHA
Understand the technical and UX reasons behind the migration trend.
👉 Cloudflare Turnstile vs reCAPTCHA: Complete Comparison
Detailed comparison of features, pricing, security, and performance.
👉 hCaptcha vs Cloudflare Turnstile: Which Should You Choose?
Compare the two most popular reCAPTCHA alternatives.
👉 Top 5 reCAPTCHA Alternatives in 2026
Comprehensive guide to all major CAPTCHA solutions.
Conclusion
You've successfully replaced reCAPTCHA with Cloudflare Turnstile in Next.js.
What you've gained:
- ✅ Better user experience (95%+ invisible verification)
- ✅ Better conversion rates (-2% to -5% vs -15% to -30%)
- ✅ Better privacy (no Google tracking)
- ✅ Better performance (18KB vs 150KB+)
- ✅ $0 cost (vs reCAPTCHA's data collection)
Total migration time: 15-30 minutes
The best part? Your users will never see those annoying "select all traffic lights" challenges again.
Need help with Next.js development or security implementation? We build fast, secure web applications. Contact Websyro Agency for a free consultation.
