Cloudflare Turnstile
Next Starter puts a Cloudflare Turnstile check (MANAGED mode) on the contact form, verified server-side and invisible for most visitors.
What Turnstile is
Turnstile is Cloudflare's free CAPTCHA, minus the traffic-light puzzles. It reads signals from the browser and decides in the background, so most visitors see nothing.
Once its check clears, the widget hands the browser a one-time token. That token travels to your server with the rest of the form, and your server asks Cloudflare whether it's real and unused before doing anything else. The token means nothing on its own, which is why verification has to be server-side. A script that posts straight to your action skips the widget, arrives with no token, and gets rejected. What you're buying is cost. Automating the form becomes expensive, not impossible.
In Next Starter, Turnstile guards the contact form and nothing else. Sign-in, register, and password reset don't use it. Those routes lean on Better Auth's built-in rate limiting (rateLimit: { enabled: true } in lib/auth.ts) and on email verification instead.
The starter assumes a MANAGED widget, which you pick in the Cloudflare dashboard. Cloudflare decides whether the visitor has to interact at all, and the answer comes back as a plain pass or fail. There's no 0.0 to 1.0 score to compare against and no threshold to tune.
Managed mode governs whether an interaction happens, not whether the widget is drawn. A managed widget is visible from page load by default. What keeps it out of sight here is appearance: "interaction-only" in components/captcha-widget.tsx, covered below.
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 by the schema (z.string().min(1) in lib/validations/env.ts), so a missing key throws during the build or on boot instead of quietly turning protection off. There's no "no secret, no check" bypass. 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. A separate dev widget is tidier, and it keeps localhost off the hostname list of the key you ship.
Server-side verification
validateTurnstile in lib/server/turnstile.ts (marked server-only, so it can't be pulled into a client bundle) POSTs the token to Cloudflare's siteverify endpoint and returns true only when result.success === true.
If the network call or the response parsing throws, it returns false. It fails closed, so an outage or a malformed response rejects the submission rather than waving it past. 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.
Client-side widget
The widget logic lives in one file, components/captcha-widget.tsx, built on the @marsidev/react-turnstile package. You work through its exports rather than the raw <Turnstile> component. Two of the three do the work:
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} />Three details about tokens and rendering:
- Tokens are single-use, and Cloudflare expires them after 300 seconds. Call
reset()on any path where a token was spent but the form stayed on screen, so the next attempt gets a fresh one. The contact form does this on a server rejection and on a thrown error.CaptchaWidgetalso clears the token ononExpireandonError. appearance: "interaction-only"means Turnstile draws nothing unless it decides interaction is needed, and the wrapper stayssr-onlyuntilonBeforeInteractivefires, so the widget takes up no room in the form until it has to.size: "flexible"then spans the form width.CAPTCHA_PROMPT, the third export, is 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 the browser makes 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. Beyond it you have the widget options (the starter sets size and appearance; Cloudflare documents many more, including theme and retry behaviour) and the mode you pick in the dashboard.
The CSP already allows challenges.cloudflare.com in script-src and frame-src, which is everything Cloudflare asks for, so adding Turnstile to another form on the same site needs no header change. See Headers and CSP.
Testing
For automated runs, Cloudflare publishes dummy keys that always pass or always fail. playwright.config.ts hands the always-passes secret 1x0000000000000000000000000000000AA to the server it launches. Two things limit how far that gets you. reuseExistingServer is on outside CI, so a server already running on port 3000 is reused and never sees the override, and test:e2e runs pnpm build first, so the build bakes your real NEXT_PUBLIC_TURNSTILE_SITE_KEY into the client bundle before Playwright starts anything.
The contact spec only loads the page and checks field validation, so no token is ever redeemed and the always-passes behaviour goes unused. To write a test that submits for real, set the matching test site key 1x00000000000000000000AA at build time as well. A test secret only accepts the dummy token a test site key produces. See Testing.
Security headers and 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.