Cloudflare Turnstile
Next Starter uses Cloudflare Turnstile (MANAGED mode) to block bots on the contact form, with a server-side token check and almost no friction for real users.
What Turnstile is
Turnstile is Cloudflare's free CAPTCHA. A CAPTCHA is a check that tells real people apart from bots before a form goes through, and Turnstile does it without the old "click all the traffic lights" puzzle. Most visitors see nothing. It watches how the page is used and decides in the background.
Here's the flow in plain terms:
- The form renders a small Turnstile widget, usually invisible.
- Once Turnstile is satisfied the visitor is human, it hands the browser a one-time token, a short signed string that proves the check passed.
- The form sends that token to your server with the rest of the data.
- Your server asks Cloudflare whether the token is real and unused before it does anything. No valid token, no submission.
A bot can't produce a valid token, so it can't get past that server check.
In Next Starter, Turnstile guards one thing: the contact form. That's the only public form where a stranger can trigger an email, so it's the one that draws spam. Sign-in, register, and password reset don't use Turnstile.
Next Starter runs it in MANAGED mode, which you pick in the Cloudflare dashboard. Cloudflare decides when to show a challenge: invisible for most people, a quick interactive step only when something looks off. The result is a plain pass or fail, not a score. There's no 0.0 to 1.0 number to compare, no threshold to tune, and no way to skip the check.
Environment variables
| Variable | Scope | Purpose |
|---|---|---|
NEXT_PUBLIC_TURNSTILE_SITE_KEY | Client | Public site key passed to the widget |
TURNSTILE_SECRET_KEY | Server | Secret key used to verify tokens on the server |
Both are required. They're validated at startup with Zod (z.string().min(1) in lib/validations/env.ts), so a missing key fails the build or boot right away instead of quietly turning protection off. See Environment Variables.
To create the keys, open the Cloudflare dashboard, go to Turnstile, add a widget, and choose MANAGED mode. Add your production domain and localhost under the widget's Hostnames so the same keys work in development.
Server-side verification
validateTurnstile in app/actions/turnstile.ts is the function that confirms a token with Cloudflare. It POSTs the token to Cloudflare's siteverify endpoint and returns true only when result.success === true. No score, no threshold. Just pass or fail.
If the network call or the response parsing throws, it returns false. It fails closed, so a glitch can never let a bot through. On that path it logs a turnstile_verification_error event using after() from next/server, which runs the log after the response is sent so it stays off the user's critical path.
export async function validateTurnstile(token: string): Promise<boolean> {
// POST { secret, response: token } to challenges.cloudflare.com/turnstile/v0/siteverify
// return result.success === true; on error, log and return false
}Client-side widget
The widget logic lives in one file, components/captcha-widget.tsx, built on the @marsidev/react-turnstile package. You rarely touch the raw <Turnstile> component. Instead you use the two exports:
useTurnstile()is a hook that owns the token and the widget ref. It returns{ ref, token, setToken, reset }.CaptchaWidgetis the widget itself. Pass itinstanceRef(the ref from the hook) andonToken(the hook'ssetToken).
In a form you call the hook, render the widget, and read token when you submit:
const { ref, token, setToken, reset } = useTurnstile();
// ...in the form JSX:
<CaptchaWidget instanceRef={ref} onToken={setToken} />A few things worth knowing:
- The token is single-use. Call
reset()after every submit or server rejection so the next attempt gets a fresh challenge. - Inside
CaptchaWidget,appearance: "interaction-only"keeps the widget invisible unless a challenge is actually needed, andsize: "flexible"makes it fit the form width. Change theseoptionsincomponents/captcha-widget.tsxfor a different look. - The file also exports
CAPTCHA_PROMPT, the message to show if someone submits before the token has resolved.
Protecting a new form
The contact form is the reference for any form you want to guard. app/(site)/contact/contact-form.tsx holds the client side and app/actions/contact.ts holds the server action. The token rides along in FormData and gets checked inside the action, so there's no extra round-trip.
To protect a new form, in this order:
- Call
useTurnstile()and render theCaptchaWidgetin the form, as shown above. - Add the hook's
tokentoFormDataasturnstile_tokenbefore you call your server action. - In the server action, read
turnstile_tokenand stop early whenvalidateTurnstilereturns false:
const turnstileToken = formData.get("turnstile_token") as string | null;
if (!turnstileToken || !(await validateTurnstile(turnstileToken))) {
return { success: false, error: "Captcha verification failed" };
}That's the whole pattern. The only other knobs are the widget options (size, appearance) and the mode you set in the Cloudflare dashboard. There's no score threshold to adjust.
Content Security Policy
The widget loads a script and an iframe from https://challenges.cloudflare.com. The Content Security Policy, the browser rule that lists which outside domains a page may load, already allows this in next.config.ts:
`script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""} https://challenges.cloudflare.com`,
"connect-src 'self' https://*.r2.cloudflarestorage.com https://challenges.cloudflare.com",
"frame-src 'self' https://challenges.cloudflare.com",So you don't need to change the CSP when you add Turnstile to another form on the same site. For more on how these headers are built, see Headers and CSP.
Testing in development
Both keys are required, so set NEXT_PUBLIC_TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY in .env or the app won't start. There's no "no secret, no check" bypass.
Create a MANAGED widget, add localhost to its Hostnames, and use those keys. Your production keys work locally as long as localhost is listed, or you can make a separate dev widget. In MANAGED mode the widget stays invisible for most interactions, so the contact form submits without a visible challenge while the server still verifies every token.
Security Headers & CSP
How Next Starter configures HTTP security headers and a Content Security Policy in next.config.ts, and how to add your own external domains.
Database Setup
How Next Starter connects PostgreSQL to Prisma 7: local Docker, migrations, managed providers, and connection pooling for serverless deployments.