Skip to content

davodm/nextjs-turnstile

Repository files navigation

Next.js Turnstile

npm version License npm downloads

A simple, stable, and fully-typed Cloudflare Turnstile CAPTCHA integration for Next.js applications.

Features

  • Simple API - Single <Turnstile> component with sensible defaults
  • Fully Typed - Complete TypeScript support with JSDoc comments
  • Stable - Uses explicit rendering mode for reliable React lifecycle management
  • Imperative API - Control the widget programmatically via ref
  • SSR Safe - Works with Next.js App Router and Pages Router
  • Server Verification - Built-in token verification utility

Installation

npm install nextjs-turnstile

Quick Start

1. Set up environment variables

# .env.local
NEXT_PUBLIC_TURNSTILE_SITE_KEY=your_site_key_here
TURNSTILE_SECRET_KEY=your_secret_key_here

Get your keys from the Cloudflare Dashboard.

2. Add the widget to your form

"use client";

import { Turnstile } from "nextjs-turnstile";
import { useState } from "react";

export default function ContactForm() {
  const [token, setToken] = useState<string | null>(null);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    
    if (!token) {
      alert("Please complete the CAPTCHA");
      return;
    }

    // Send token to your API for verification
    const response = await fetch("/api/contact", {
      method: "POST",
      body: JSON.stringify({ token, /* ...form data */ }),
    });
    
    // Handle response...
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* Your form fields */}
      
      <Turnstile
        onSuccess={setToken}
        onError={() => console.error("Turnstile error")}
        onExpire={() => setToken(null)}
      />
      
      <button type="submit" disabled={!token}>
        Submit
      </button>
    </form>
  );
}

3. Verify the token on your server

// app/api/contact/route.ts (App Router)
import { verifyTurnstile } from "nextjs-turnstile";

export async function POST(request: Request) {
  const { token } = await request.json();

  const isValid = await verifyTurnstile(token);
  
  if (!isValid) {
    return Response.json(
      { error: "CAPTCHA verification failed" },
      { status: 400 }
    );
  }

  // Token is valid, continue with your logic...
  return Response.json({ success: true });
}

Component Props

<Turnstile
  // Site key (falls back to NEXT_PUBLIC_TURNSTILE_SITE_KEY env var)
  siteKey="your-site-key"
  
  // Appearance
  theme="auto"        // "auto" | "light" | "dark"
  size="normal"       // "normal" | "compact" | "flexible"
  appearance="always" // "always" | "execute" | "interaction-only"
  
  // Behavior
  execution="render"       // "render" | "execute"
  refreshExpired="auto"    // "auto" | "manual" | "never"
  refreshTimeout="auto"    // "auto" | "manual" | "never"
  retry="auto"             // "auto" | "never"
  retryInterval={8000}     // Retry interval in ms
  
  // Form integration
  responseFieldName="cf-turnstile-response"  // Name for hidden input, or false to disable
  
  // Analytics
  action="login"           // Custom action identifier (max 32 chars)
  cData="user-123"         // Custom data payload (max 255 chars)
  
  // Accessibility
  tabIndex={0}
  language="auto"          // ISO 639-1 code or "auto"
  
  // Styling
  className="my-turnstile"
  style={{ marginTop: 16 }}
  
  // Feedback
  feedbackEnabled={true}     // Allow Cloudflare feedback on failure (default: true)
  
  // Callbacks
  onSuccess={(token) => {}}  // Called with verification token
  onError={(code) => {}}     // Called on error
  onExpire={() => {}}        // Called when token expires (~5 min)
  onTimeout={() => {}}       // Called on interactive timeout
  onLoad={() => {}}          // Called when widget is ready
  onBeforeInteractive={() => {}}  // Called before interactive challenge
  onAfterInteractive={() => {}}   // Called after interactive challenge
  onUnsupported={() => {}}        // Called if browser not supported
/>

Imperative API (Ref)

Use a ref to control the widget programmatically:

import { Turnstile, TurnstileRef } from "nextjs-turnstile";
import { useRef } from "react";

function MyForm() {
  const turnstileRef = useRef<TurnstileRef>(null);

  const handleReset = () => {
    turnstileRef.current?.reset();
  };

  const handleSubmit = async () => {
    const token = turnstileRef.current?.getResponse();
    
    if (!token) {
      alert("Please complete the CAPTCHA");
      return;
    }

    // Submit form...
  };

  return (
    <form>
      <Turnstile ref={turnstileRef} onSuccess={console.log} />
      
      <button type="button" onClick={handleReset}>
        Reset CAPTCHA
      </button>
      <button type="button" onClick={handleSubmit}>
        Submit
      </button>
    </form>
  );
}

Ref Methods

Method Description
reset() Reset the widget for a new challenge
remove() Remove the widget from the page
getResponse() Get the current token (or null)
execute() Start the challenge (when execution="execute")
isReady() Check if the widget is ready
getWidgetId() Get the internal Cloudflare widget ID

Server-Side Verification

verifyTurnstile(token, options?)

Verifies a Turnstile token with Cloudflare's siteverify API. Returns true on success. Throws TurnstileError on failure with an errorCodes array describing what went wrong.

import { verifyTurnstile } from "nextjs-turnstile";

// Basic usage (uses TURNSTILE_SECRET_KEY env var)
const ok = await verifyTurnstile(token);

Error handling

import { verifyTurnstile, TurnstileError } from "nextjs-turnstile";

try {
  await verifyTurnstile(token);
} catch (e) {
  if (e instanceof TurnstileError) {
    console.error(e.errorCodes); // e.g. ["invalid-input-response"]
  }
}

Advanced options

const ok = await verifyTurnstile(token, {
  secretKey: "custom-secret-key",  // Override secret key
  ip: "1.2.3.4",                   // User's IP (auto-detected if omitted)
  headers: request.headers,        // For IP detection in Pages Router
  action: "login",                 // Reject if action doesn't match
  hostname: "example.com",         // Reject if hostname doesn't match
  timeout: 5000,                   // Fetch timeout in ms (default: 10 000)
});

Parameters:

  • token (string): The token from the Turnstile widget
  • options (object, optional):
    • secretKey: Override the default secret key (falls back to TURNSTILE_SECRET_KEY env var)
    • ip: User's IP address (auto-detected from headers if omitted)
    • headers: Request headers for IP detection (Pages Router)
    • action: Reject tokens whose action field doesn't match
    • hostname: Reject tokens whose hostname field doesn't match
    • timeout: Fetch timeout in milliseconds (default: 10000)

Returns: Promise<boolean>true when the token is valid

Throws: TurnstileError — when verification fails (inspect .errorCodes)

Error codes — in addition to Cloudflare's error codes, the following are added by this library:

Code Meaning
timeout-error The siteverify request timed out
action-mismatch Response action didn't match the expected value
hostname-mismatch Response hostname didn't match the expected value

Utility Functions

These client-side utilities are SSR-safe and can be imported anywhere:

import {
  // Script loading
  loadTurnstileScript,
  isTurnstileLoaded,

  // Widget control
  resetTurnstile,
  removeTurnstile,
  getTurnstileResponse,
  executeTurnstile,
  isTokenExpired,
  renderTurnstile,
} from "nextjs-turnstile";
Function Description
loadTurnstileScript() Load the Turnstile script (returns Promise)
isTurnstileLoaded() Check if script is loaded
resetTurnstile(widgetRef?) Reset a widget
removeTurnstile(widgetRef) Remove a widget from the page
getTurnstileResponse(widgetRef) Get token from a widget
executeTurnstile(widgetRef) Execute challenge on a widget
isTokenExpired(widgetRef) Check if token is expired
renderTurnstile(container, options) Render a widget (low-level API)

Size Options

Size Dimensions Use Case
normal 300×65px Standard forms
compact 150×140px Space-constrained layouts
flexible 100% width (min 300px), 65px Responsive designs

Examples

Deferred Execution

Only run the challenge when the user clicks submit:

function DeferredForm() {
  const turnstileRef = useRef<TurnstileRef>(null);
  const [token, setToken] = useState<string | null>(null);

  const handleSubmit = async () => {
    // Start the challenge
    turnstileRef.current?.execute();
    
    // Wait for token via onSuccess callback
    // The form will submit once token is set
  };

  useEffect(() => {
    if (token) {
      // Token received, submit the form
      submitForm(token);
    }
  }, [token]);

  return (
    <form>
      <Turnstile
        ref={turnstileRef}
        execution="execute"
        appearance="interaction-only"
        onSuccess={setToken}
      />
      <button type="button" onClick={handleSubmit}>
        Submit
      </button>
    </form>
  );
}

Invisible Mode

When your Turnstile widget type is set to Invisible in the Cloudflare Dashboard, the challenge runs entirely in the background with zero visual footprint. No widget is ever shown to the user.

Note: The widget type (Managed / Non-Interactive / Invisible) is configured in the Cloudflare Dashboard when creating your sitekey. The appearance and size props are ignored for invisible widgets.

Auto-run (recommended for most cases)

The challenge runs automatically when the component mounts. The token is passed to onSuccess as soon as it's available:

function InvisibleAutoForm() {
  const [token, setToken] = useState<string | null>(null);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!token) return;

    await fetch("/api/submit", {
      method: "POST",
      headers: { "cf-turnstile-response": token },
      body: JSON.stringify({ /* form data */ }),
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* Your form fields */}
      <Turnstile
        onSuccess={setToken}
        onExpire={() => setToken(null)}
        feedbackEnabled={false}
      />
      <button type="submit" disabled={!token}>Submit</button>
    </form>
  );
}

Deferred execution

Run the challenge only when the user submits, using execution="execute":

function InvisibleDeferredForm() {
  const turnstileRef = useRef<TurnstileRef>(null);
  const [token, setToken] = useState<string | null>(null);

  const handleSubmit = async () => {
    if (!turnstileRef.current?.isReady()) return;
    turnstileRef.current.execute();
  };

  useEffect(() => {
    if (token) {
      // Token received — submit form
      fetch("/api/submit", {
        method: "POST",
        headers: { "cf-turnstile-response": token },
        body: JSON.stringify({ /* form data */ }),
      });
    }
  }, [token]);

  return (
    <form>
      {/* Your form fields */}
      <Turnstile
        ref={turnstileRef}
        execution="execute"
        onSuccess={setToken}
        feedbackEnabled={false}
      />
      <button type="button" onClick={handleSubmit}>Submit</button>
    </form>
  );
}

Protecting API endpoints (Troy Hunt pattern)

Use an invisible widget to protect API calls by attaching the token as a header:

function ProtectedSearch() {
  const [token, setToken] = useState<string | null>(null);
  const turnstileRef = useRef<TurnstileRef>(null);

  const handleSearch = async (query: string) => {
    if (!token) return;

    const res = await fetch(`/api/search?q=${query}`, {
      headers: { "cf-turnstile-response": token },
    });

    // Reset the widget for the next request (tokens are single-use)
    turnstileRef.current?.reset();
    setToken(null);

    return res.json();
  };

  return (
    <div>
      <Turnstile
        ref={turnstileRef}
        onSuccess={setToken}
        onExpire={() => setToken(null)}
        feedbackEnabled={false}
      />
      <SearchInput onSearch={handleSearch} disabled={!token} />
    </div>
  );
}

With React Hook Form

import { useForm } from "react-hook-form";
import { Turnstile } from "nextjs-turnstile";

function HookFormExample() {
  const { register, handleSubmit, setValue, formState } = useForm();

  const onSubmit = async (data) => {
    const response = await fetch("/api/submit", {
      method: "POST",
      body: JSON.stringify(data),
    });
    // Handle response...
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("email")} />
      
      <Turnstile
        onSuccess={(token) => setValue("turnstileToken", token)}
        onExpire={() => setValue("turnstileToken", "")}
      />
      <input type="hidden" {...register("turnstileToken", { required: true })} />
      
      <button type="submit" disabled={!formState.isValid}>
        Submit
      </button>
    </form>
  );
}

Multiple Widgets

Each widget needs a unique key when using multiple on the same page:

function MultipleWidgets() {
  return (
    <div>
      <Turnstile
        key="widget-1"
        responseFieldName="captcha-1"
        onSuccess={(token) => console.log("Widget 1:", token)}
      />
      <Turnstile
        key="widget-2"
        responseFieldName="captcha-2"
        onSuccess={(token) => console.log("Widget 2:", token)}
      />
    </div>
  );
}

Migration from v0.x

Version 1.0.0 is a breaking change with a simplified API:

// Before (v0.x)
import { TurnstileImplicit, TurnstileExplicit } from "nextjs-turnstile";

<TurnstileImplicit
  responseFieldName="my-token"
  onSuccess={handleSuccess}
/>

// After (v1.0.0)
import { Turnstile } from "nextjs-turnstile";

<Turnstile
  responseFieldName="my-token"
  onSuccess={handleSuccess}
/>

Key changes:

  • Single Turnstile component replaces both TurnstileImplicit and TurnstileExplicit
  • Uses explicit rendering internally for better React compatibility
  • Added imperative API via ref
  • Added new props: execution, retry, retryInterval, action, cData, onLoad, etc.
  • Requires React 18+ and Next.js 13+

Troubleshooting

Widget not appearing

  1. Check that your site key is correct
  2. Ensure you're running on http:// or https:// (not file://)
  3. Check the browser console for errors

Token verification fails

  1. Verify your secret key is correct
  2. Tokens expire after 5 minutes - ensure quick submission
  3. Each token can only be verified once

Widget resets unexpectedly

This usually happens when React re-renders the component. Ensure:

  1. The siteKey prop is stable (not recreated each render)
  2. Parent components don't unmount/remount the Turnstile component
  3. Use key prop if you need to force a reset

Resources

License

MIT © Davod Mozafari

About

Integrate Cloudflare Turnstile CAPTCHA in Next.js applications

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors