nextjs-turnstile
v1.0.5
Published
Cloudflare Turnstile CAPTCHA integration for Next.js - Simple, stable, and fully typed
Downloads
1,919
Maintainers
Readme
Next.js Turnstile
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-turnstileQuick Start
1. Set up environment variables
# .env.local
NEXT_PUBLIC_TURNSTILE_SITE_KEY=your_site_key_here
TURNSTILE_SECRET_KEY=your_secret_key_hereGet 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 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 |
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
appearanceandsizeprops 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
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+
Troubleshooting
Widget not appearing
- Check that your site key is correct
- Ensure you're running on
http://orhttps://(notfile://) - Check the browser console for errors
Token verification fails
- Verify your secret key is correct
- Tokens expire after 5 minutes - ensure quick submission
- Each token can only be verified once
Widget resets unexpectedly
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
Resources
License
MIT © Davod Mozafari
