Docs / Guides / Next.js
Next.js CAPTCHA on Vercel
The clean integration shape for App Router apps: load the widget once, let it write playtcha-token into the form, and verify that token in a Server Action or Route Handler before doing anything expensive.
When this guide fits
Use this guide if your frontend is Next.js App Router and you deploy on Vercel. That combination is common enough that the real questions are not "can I mount the widget?" but "where should verify run, which env vars are public, and what do I do when verification fails?"
If you are protecting Supabase Auth specifically, the more tailored guide is CAPTCHA with Supabase. If you are still evaluating the broader product fit for App Router flows, read CAPTCHA for Next.js apps. If you just want the shortest path first, start at quickstart.
1. Set the right env vars
You need one public key and one server-only secret. On Vercel, set them in Project Settings → Environment Variables before deploying.
NEXT_PUBLIC_PLAYTCHA_SITE_KEY=pk_live_...
PLAYTCHA_SECRET=sk_live_...- Public key: safe in HTML and Client Components.
- Secret key: server-only. Never expose it in browser code.
- Domain allowlist: make sure your Vercel production domain and any preview domains you actually use are configured correctly.
2. Mount the widget in App Router
Load the widget once in your root layout, then place the mount point inside the form that should be protected. On success, Playtcha writes a hidden input namedplaytcha-token into that form.
// app/layout.tsx
import Script from "next/script";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Script src="https://playtcha.com/v1/widget.js" strategy="afterInteractive" />
{children}
</body>
</html>
);
}// app/signup/page.tsx
import { submitAction } from "./actions";
export default function SignupPage() {
return (
<form action={submitAction}>
<input name="email" type="email" required autoComplete="email" />
<div className="playtcha" data-sitekey={process.env.NEXT_PUBLIC_PLAYTCHA_SITE_KEY!}></div>
<button type="submit">Create account</button>
</form>
);
}That is the whole browser-side contract. You do not need to call/v1/verify from the client, and you should not try. The security gate is server-side.
3. Verify in a Server Action
For standard App Router forms, this is the cleanest path. Read the token fromFormData, call https://playtcha.com/v1/verify with your secret, and reject the request unless success === true.
// app/signup/actions.ts
"use server";
import { redirect } from "next/navigation";
const failClosed = process.env.PLAYTCHA_FAIL_CLOSED === "1";
export async function submitAction(formData: FormData) {
const email = String(formData.get("email") ?? "");
const playtchaToken = String(formData.get("playtcha-token") ?? "");
if (!playtchaToken) {
redirect("/signup?error=captcha_missing");
}
try {
const verifyRes = await fetch("https://playtcha.com/v1/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
secret: process.env.PLAYTCHA_SECRET,
token: playtchaToken,
}),
signal: AbortSignal.timeout(5000),
});
const data = await verifyRes.json().catch(() => null);
if (verifyRes.status >= 500 || !data) {
console.warn("playtcha.verify.unavailable", { status: verifyRes.status });
if (failClosed) redirect("/signup?error=verifier_unavailable");
// fall through — treat as verified
} else if (!data.success) {
// 4xx: token_invalid, token_expired, already_redeemed, bad_secret, etc.
redirect("/signup?error=captcha_failed");
}
// data.degraded === true means usage is over the cap. Log it, don't block.
} catch (err) {
console.warn("playtcha.verify.unreachable", { err: String(err) });
if (failClosed) redirect("/signup?error=verifier_unavailable");
// fall through — treat as verified
}
// Human verified. Continue with your database write, auth flow, or email.
console.log("Verified submit for", email);
}- Verify first. Do not spend a DB call or third-party email call before the human gate passes.
- Use a timeout. Keep your blast radius small if the upstream is degraded.
- Fail open on 5xx / network, closed on 4xx. A Playtcha outage should not break your signup form. A bad token always should.
- Opt into strict mode with
PLAYTCHA_FAIL_CLOSED=1for high-fraud paths where you would rather block than admit a fraction of unverified traffic during an outage.
4. Route Handler alternative
If your app already posts to an API route instead of a Server Action, keep that shape. The verify contract is the same.
// app/api/signup/route.ts
import { NextResponse } from "next/server";
const failClosed = process.env.PLAYTCHA_FAIL_CLOSED === "1";
export async function POST(req: Request) {
// JSON SPA path: read playtchaToken from req.json() instead of FormData.
const formData = await req.formData();
const playtchaToken = String(formData.get("playtcha-token") ?? "");
if (!playtchaToken) {
return NextResponse.json({ error: "captcha_missing" }, { status: 400 });
}
try {
const verifyRes = await fetch("https://playtcha.com/v1/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
secret: process.env.PLAYTCHA_SECRET,
token: playtchaToken,
}),
signal: AbortSignal.timeout(5000),
});
const data = await verifyRes.json().catch(() => null);
if (verifyRes.status >= 500 || !data) {
console.warn("playtcha.verify.unavailable", { status: verifyRes.status });
if (failClosed) {
return NextResponse.json({ error: "verifier_unavailable" }, { status: 503 });
}
} else if (!data.success) {
return NextResponse.json({ error: "captcha_failed" }, { status: 400 });
}
// data?.degraded === true -> log, do not block.
} catch (err) {
console.warn("playtcha.verify.unreachable", { err: String(err) });
if (failClosed) {
return NextResponse.json({ error: "verifier_unavailable" }, { status: 503 });
}
}
return NextResponse.json({ ok: true });
}Pick one primary integration shape and stick to it. Most teams are better served by Server Actions for first-party forms and Route Handlers for API-style flows or JSON clients.
5. Vercel deploy checklist
- Add
NEXT_PUBLIC_PLAYTCHA_SITE_KEYandPLAYTCHA_SECRETto Vercel for Production, Preview, and Development as needed. - Make sure the allowed domain list in Playtcha matches the domains where the widget will run.
- Redeploy after env changes. Preview deployments do not retroactively pick up secrets from older builds.
- Test one real protected flow in a preview URL before promoting.
- Watch for
captcha_missingandcaptcha_failedin your logs during rollout.
If you use preview deployments heavily, be explicit about whether preview URLs are allowed to run the widget. Some teams only allow production domains; others keep a separate test project for preview and staging.
6. Failure modes to choose deliberately
The biggest implementation mistake is not code. It is letting the failure mode be accidental. Our policy is: fail open on 5xx and network errors, fail closed on 4xx. A Playtcha outage should never break a customer’s form. A bad or replayed token always should.
| Situation | Recommended default |
|---|---|
| Missing token | Block the request and return a clear user-facing error. |
| 5xx or network error from verify | Log and treat the user as verified (fail open). Opt into strict mode with PLAYTCHA_FAIL_CLOSED=1 for high-fraud paths. |
4xx token_invalid / token_expired | Reject. Ask the user to play a fresh challenge. |
4xx already_redeemed (409) | Security signal. Replay attack, stuck retry, or duplicate POST. Reject and log request_id at warn level. |
4xx bad_secret / unknown_project | Treat as an incident. Config error on your side. |
429 rate_limited_ip | Honor the Retry-After header; do not retry tighter than 1s. |
Response with degraded: true | Treat as verified (it is success). Log the degraded_reason for billing visibility. |
| Preview domain rejected | Fix the allowlist or use a separate project for staging. |
That policy choice should be made once by engineering, not rediscovered in every catch block. See the Node verify reference for the helper-shaped version.
Atomic signup with /v1/verify-dry
If the action that uses the captcha can itself fail for unrelated reasons (duplicate email, weak password, downstream provider error), call POST /v1/verify-dryfirst — same input shape, but it does not redeem the token. Then call /v1/verifyonly after your real side effect succeeds. The Supabase Auth guide shows the full two-phase pattern (read it).
Mobile and accessibility
The widget is a minigame. Budget at least 240px of vertical space on mobile breakpoints so the play surface stays usable on a phone in portrait. The games respectprefers-reduced-motion and route through pointer + touch + keyboard. If you are wrapping the mount in a card or panel, do not constrain its height to a single line — you will get a clipped game.
FAQ
Do I need a Client Component for the widget?
Not necessarily. The widget loader itself is client-side JavaScript, but the mount point can live in a normal App Router page or Server Component as plain markup.
Can I verify from the browser instead?
No. Verification belongs on the server because it requires the secret key and the browser is not a trusted environment.
Should I use a Server Action or a Route Handler?
Server Actions are usually cleaner for first-party forms. Route Handlers make sense when your client already posts to an API endpoint or you need a more explicit JSON contract.
What should I read next?
Start with quickstart if you have not shipped yet, then read the Next.js apps guide for the broader product trade. If auth is your main flow, read the Supabase guide and the in-app signup guide. For a cleaner backend helper and error-handling shape, read the Node verify reference.