Back to Blogs
10 min readMar 22, 2026

How to Replace reCAPTCHA with Cloudflare Turnstile in Next.js (Step-by-Step)

Complete guide to replacing Google reCAPTCHA with Cloudflare Turnstile in Next.js. Includes App Router, Server Actions, error handling, testing, and TypeScript examples. Migration takes 15 minutes.

cloudflare turnstile nextjsreplace recaptcha next.jsturnstile next.js implementationnextjs captcha integrationturnstile app routernextjs server actions captcha

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

  1. Log in to Cloudflare Dashboard

  2. Go to Turnstile (in the sidebar)

  3. Click Add Site

  4. Fill in the form:

    • Site name: Your project name
    • Domain: Your domain (use localhost for development)
    • Widget Mode: Select "Managed" (recommended)
  5. 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_here

Important: 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-turnstile

This 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:

  1. Different verification URL
  2. Turnstile uses JSON, reCAPTCHA uses form data
  3. 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 CodeMeaningSolution
missing-input-secretSecret key not providedCheck environment variable
invalid-input-secretSecret key is wrongVerify key in Cloudflare dashboard
missing-input-responseToken not providedEnsure token is sent from frontend
invalid-input-responseToken is invalid or expiredToken expired (5 min lifetime)
timeout-or-duplicateToken already usedTokens 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

  1. Start dev server:
npm run dev
  1. Test the form:

    • Fill out all fields
    • Wait for Turnstile widget to load
    • Submit form
    • Check console for verification result
  2. 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=3x0000000000000000000000000000000FF

Use 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:

  1. Verify NEXT_PUBLIC_TURNSTILE_SITE_KEY is correct
  2. Check browser console for errors
  3. Add domain in Cloudflare dashboard
  4. 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:

  1. Reset Turnstile widget after each submission
  2. Ensure tokens aren't reused
  3. Sync server time (use NTP)

Issue: High False Positive Rate

Causes:

  • Security level too strict
  • VPN/proxy users flagged
  • Bot Fight Mode enabled

Solution:

  1. Adjust security level in Cloudflare dashboard
  2. Lower Bot Fight Mode sensitivity
  3. 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>  );}

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.

Related Blogs

View all