CAPTCHAs have long been the standard safeguard against spam and bot traffic, but the experience for legitimate users has always been a quiet tax: blurry traffic lights, distorted text, awkward delays at the worst possible moment. Cloudflare Turnstile takes a different approach — verify humans invisibly, without making them prove they aren't robots.
In this post we'll wire Turnstile up end-to-end across a NextJS frontend and a NestJS backend, and we'll cover the parts the official docs gloss over: token rotation on form errors, server-side validation via /siteverify, and graceful degradation.
Why Turnstile over reCAPTCHA
- No user-facing puzzles in the vast majority of cases.
- Privacy-preserving — no third-party tracking cookies.
- Free at any scale, with the same anti-abuse signals Cloudflare uses internally.
- Drop-in replacement for hCaptcha and reCAPTCHA v2/v3.
How it stacks up
In rough order of impact for a typical product site:
- Fewer abandoned form submissions on slow connections.
- No CMP / cookie banner entries to maintain in the EU.
- Zero per-request cost at any traffic volume.
| Capability | Turnstile | reCAPTCHA v3 |
|---|---|---|
| Invisible by default | Yes | Yes |
| Cookie-free | Yes | No |
| Free at scale | Yes | Paid above 1M / mo |
Setting up the frontend
On the NextJS side we render the Turnstile widget inside the form and pass the resulting token along with the rest of the payload. The widget is a Client Component because it has to attach to the DOM.
Editor tip
Save the file with ⌘ + S and let the dev server re-render — you should see the widget appear in place of the placeholder div.
"use client";
import Script from "next/script";
import { useRef } from "react";
export function ContactForm() {
const tokenRef = useRef<string | null>(null);
return (
<>
<Script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async />
<form onSubmit={async (e) => {
e.preventDefault();
await fetch("/api/contact", {
method: "POST",
body: JSON.stringify({ token: tokenRef.current }),
});
}}>
<div
className="cf-turnstile"
data-sitekey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY}
data-callback={(token: string) => (tokenRef.current = token)}
/>
<button type="submit">Send</button>
</form>
</>
);
}Validating on the NestJS side
Every Turnstile token is single-use and tied to the originating IP. Validate it before trusting any data from the request.
@Injectable()
export class TurnstileGuard implements CanActivate {
async canActivate(ctx: ExecutionContext): Promise<boolean> {
const req = ctx.switchToHttp().getRequest();
const token = req.body?.token;
if (!token) throw new ForbiddenException("Missing Turnstile token");
const res = await fetch(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
secret: process.env.TURNSTILE_SECRET!,
response: token,
remoteip: req.ip,
}),
},
);
const data = await res.json();
if (!data.success) throw new ForbiddenException("Turnstile failed");
return true;
}
}“The best security is the kind your users never notice. Turnstile gets us most of the way there without the dark patterns.”
Wrapping up
Ship the guard, set NEXT_PUBLIC_TURNSTILE_SITE_KEY and TURNSTILE_SECRET in your environment, and you're done. A small investment for a meaningfully better first-time-user experience.
Written by
Anish Shrestha
Backend Engineer at Asteroid Studio
Ships product across web and mobile at Asteroid. Writes occasionally about the things that took the longest to figure out.