A simple, stable, and fully-typed Cloudflare Turnstile CAPTCHA integration for Next.js applications.
- ✅ 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
npm install nextjs-turnstile# .env.local
NEXT_PUBLIC_TURNSTILE_SITE_KEY=your_site_key_here
TURNSTILE_SECRET_KEY=your_secret_key_hereGet your keys from the Cloudflare Dashboard.
"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>
);
}// 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 });
}<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 }}
// 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
/>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>
);
}| 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 |
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);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"]
}
}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 widgetoptions(object, optional):secretKey: Override the default secret key (falls back toTURNSTILE_SECRET_KEYenv var)ip: User's IP address (auto-detected from headers if omitted)headers: Request headers for IP detection (Pages Router)action: Reject tokens whoseactionfield doesn't matchhostname: Reject tokens whosehostnamefield doesn't matchtimeout: 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 |
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 | Dimensions | Use Case |
|---|---|---|
normal |
300×65px | Standard forms |
compact |
150×140px | Space-constrained layouts |
flexible |
100% width (min 300px), 65px | Responsive designs |
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>
);
}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>
);
}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>
);
}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
Turnstilecomponent replaces bothTurnstileImplicitandTurnstileExplicit - 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+
- Check that your site key is correct
- Ensure you're running on
http://orhttps://(notfile://) - Check the browser console for errors
- Verify your secret key is correct
- Tokens expire after 5 minutes - ensure quick submission
- Each token can only be verified once
This usually happens when React re-renders the component. Ensure:
- The
siteKeyprop is stable (not recreated each render) - Parent components don't unmount/remount the Turnstile component
- Use
keyprop if you need to force a reset
MIT © Davod Mozafari