logoAvailable for work
How to Add Cloudflare Turnstile to Your Next.js App

How to Add Cloudflare Turnstile to Your Next.js App: A Complete Guide


15 min readtutorials

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?

FeatureTurnstilereCAPTCHA
PrivacyNo trackingTracks for ads
User experienceUsually invisibleOften requires puzzles
Free tier1M/month10K/month
Load time<100ms200-500ms
GDPRBuilt-inExtra 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

  1. Go to dash.cloudflare.com and create an account
  2. Find Turnstile in the sidebar, click Add site
  3. Fill in:
    • Site name: Something descriptive
    • Domain: localhost for development (add production domain later)
    • Widget mode: Choose Managed (best balance of security and UX)
  4. 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

  1. Widget Renders: When your form loads, the Turnstile widget initializes and starts analyzing the browser environment
  2. 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)
  3. 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
  4. Form Submission: The token (a cryptographic proof of challenge completion) is sent with your form data
  5. 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:

  1. Initial Render: <Turnstile> component loads Cloudflare's script and initializes the widget iframe
  2. Challenge Execution:
    • Widget analyzes browser silently (most users stop here)
    • If needed, shows interactive challenge
    • Cloudflare's servers validate the challenge attempt
  3. Token Generation: When complete, Cloudflare generates a cryptographically signed token (a long random string like 0.aB1cD2eF3gH4...)
  4. Callback: onSuccess fires with the token, we store it in React state
  5. Button Activation: With turnstileToken populated, submit button becomes enabled
  6. 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:

  1. Token Extraction: We pull the token from FormData (remember the hidden input?)
  2. Server → Cloudflare: Our verifyTurnstileToken function sends:
    {
      "secret": "your_secret_key",
      "response": "0.aB1cD2eF3gH4..." // The user's token
    }
    
  3. 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?
  4. Response: Cloudflare returns:
    {
      "success": true,  // or false
      "error-codes": [] // or ["timeout-or-duplicate", etc.]
    }
    
  5. Decision: If success: true, we proceed. If false, 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 onSuccess callback is setting state correctly
  • Console.log the token to verify it's being set

Token doesn't reset?

  • Make sure you're clearing turnstileToken state 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!


Share this post:
All Posts
Share this article:
Contact
Me