@bliztek/turnstile
v1.0.0
Published
Headless, zero-dependency Cloudflare Turnstile integration for React
Maintainers
Readme
@bliztek/turnstile
Headless, zero-dependency Cloudflare Turnstile integration for React.
Installation
pnpm add @bliztek/turnstileReact is an optional peer dependency — only required for the client-side hook. The server-side verifyCaptcha function works without React.
Entry Points
| Import | What you get |
|---|---|
| @bliztek/turnstile | useTurnstile hook + client types |
| @bliztek/turnstile/server | verifyCaptcha function + server types |
Client: useTurnstile
A React hook that handles script loading, widget rendering, token state, and cleanup.
"use client";
import { useTurnstile } from "@bliztek/turnstile";
function ContactForm() {
const { ref, token, isReady, reset } = useTurnstile({
siteKey: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!,
theme: "dark",
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!token) return;
const res = await fetch("/api/contact", {
method: "POST",
body: JSON.stringify({ turnstileToken: token, /* ...form data */ }),
});
if (res.ok) {
reset(); // clears token and resets the widget
}
};
return (
<form onSubmit={handleSubmit}>
{/* form fields */}
<div ref={ref} className="min-h-[65px]" />
<button type="submit" disabled={!token}>Submit</button>
</form>
);
}Options
| Option | Type | Default | Description |
|---|---|---|---|
| siteKey | string | required | Your Cloudflare Turnstile site key |
| theme | "light" \| "dark" \| "auto" | "auto" | Widget appearance |
| size | "normal" \| "compact" | "normal" | Widget size |
| appearance | "always" \| "execute" \| "interaction-only" | "always" | When to show the widget |
| retry | "auto" \| "never" | "auto" | Retry behavior on failure |
| retry-interval | number | 8000 | Retry interval in ms |
| action | string | — | Action identifier for analytics |
| cData | string | — | Custom data passed to verification response |
| language | string | — | BCP 47 language code (e.g. "en", "de") |
| tabindex | number | — | Tab index for accessibility |
| onSuccess | (token: string) => void | — | Called when a token is obtained |
| onExpire | () => void | — | Called when the token expires |
| onError | (error: Error) => void | — | Called when the script fails to load |
Return Value
| Property | Type | Description |
|---|---|---|
| ref | RefCallback<HTMLElement> | Attach to the container element |
| token | string \| null | Current token, or null |
| isReady | boolean | Whether the Turnstile script has loaded |
| reset | () => void | Reset the widget and clear the token |
How It Works
- On mount, the hook loads the Turnstile script (
render=explicitmode) via a singleton promise — multiple hook instances share one script tag. - When the container element mounts (via ref callback) and the script is ready, the widget renders automatically.
- On successful challenge completion,
tokenupdates andonSuccessfires. - On unmount, the widget is removed to prevent memory leaks.
- SSR-safe — all browser APIs are guarded behind
typeof windowchecks. - If the script fails to load, the singleton resets so future attempts can retry.
Server: verifyCaptcha
Verifies a Turnstile token against the Cloudflare siteverify API. Uses only fetch — no SDK required.
import { verifyCaptcha } from "@bliztek/turnstile/server";
export async function POST(request: Request) {
const { turnstileToken } = await request.json();
const result = await verifyCaptcha({
token: turnstileToken,
secretKey: process.env.TURNSTILE_SECRET_KEY!,
idempotencyKey: crypto.randomUUID(), // prevent replay attacks
});
if (!result.success) {
return Response.json(
{ error: "Verification failed", details: result["error-codes"] },
{ status: 422 }
);
}
// Token is valid — proceed with the request
}Options
| Option | Type | Default | Description |
|---|---|---|---|
| token | string | required | The token from the client widget |
| secretKey | string | required | Your Cloudflare Turnstile secret key |
| endpoint | string | Cloudflare URL | Override for testing |
| idempotencyKey | string | — | Prevents replay attacks (recommended) |
Error Handling
verifyCaptcha never throws. It always returns a TurnstileVerificationResponse:
- Missing
tokenorsecretKey→{ success: false, "error-codes": ["missing-input-response"] } - HTTP error from Cloudflare →
{ success: false, "error-codes": ["http-error-500"] } - Network failure →
{ success: false, "error-codes": ["network-error"] }
Response
interface TurnstileVerificationResponse {
success: boolean;
"error-codes"?: string[];
challenge_ts?: string;
hostname?: string;
action?: string;
cdata?: string;
}Types
Client types (from @bliztek/turnstile):
UseTurnstileOptions— All hook optionsUseTurnstileReturn— Hook return valueTurnstileRenderOptions— Widget render options (subset ofUseTurnstileOptions)TurnstileTheme,TurnstileSize,TurnstileAppearance,TurnstileRetry— Union typesTurnstileInstance,TurnstileWidgetOptions— Low-level Cloudflare API types
Server types (from @bliztek/turnstile/server):
VerifyCaptchaOptionsTurnstileVerificationResponse
Environment Variables
| Variable | Side | Description |
|---|---|---|
| NEXT_PUBLIC_TURNSTILE_SITE_KEY | Client | Public site key (safe to expose) |
| TURNSTILE_SECRET_KEY | Server | Secret key (never expose to client) |
License
MIT
