Docs / Guides / React
React + Vite CAPTCHA guide
The clean SPA integration shape: load the widget once, mount it inside the form you care about, then verify the playtcha-token on your own backend before the expensive work.
When this guide fits
Use this guide if your frontend is a client-rendered React app, commonly with Vite, and your form already posts to your own backend or API. The main engineering decision is not whether React can host the widget. It can. The real decision is how to keep the human token tied to the form submit without accidentally moving verification into the browser.
If you run App Router on Vercel instead, read the Next.js guide. If your main pain is signup abuse on Supabase Auth, read the Supabase Auth guide.
1. Load the widget once
In a Vite app, the simplest path is to load the widget script once in index.html or in your root app shell. The widget then upgrades any .playtcha container you render later.
<!-- index.html -->
<script src="https://playtcha.com/v1/widget.js" defer></script>That keeps your React code small. You do not need a wrapper SDK just to mount the widget.
2. SPA path (recommended)
In a controlled React component, capture the result token via the onSuccess callback, hold it in state, and send it as JSON when the user submits. Pass responseFieldName: null so the widget does not mutate any ancestor <form>.
import { useEffect, useRef, useState } from "react";
export function ContactForm() {
const mountRef = useRef<HTMLDivElement>(null);
const [token, setToken] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!mountRef.current || !window.playtcha) return;
window.playtcha.render(mountRef.current, {
responseFieldName: null, // SPA mode: skip the hidden input
onSuccess: (resultToken) => setToken(resultToken),
onExpired: () => setToken(null),
onError: (err) => setError(String(err)),
});
return () => window.playtcha?.reset(mountRef.current!);
}, []);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
if (!token) { setError("Please complete the challenge first."); return; }
const form = e.target as HTMLFormElement;
const fd = new FormData(form);
const res = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: fd.get("email"),
message: fd.get("message"),
playtchaToken: token,
}),
});
if (!res.ok) setError("Submit failed.");
}
return (
<form onSubmit={onSubmit}>
<input name="email" type="email" required />
<textarea name="message" required />
<div
ref={mountRef}
className="playtcha"
data-sitekey={import.meta.env.VITE_PLAYTCHA_SITE_KEY}
/>
<button type="submit" disabled={!token}>Send</button>
{error && <p role="alert">{error}</p>}
</form>
);
}Holding the token in state means the submit button can be gated on it, and your backend reads req.body.playtchaToken from a normal JSON payload — no FormData required.
3. Plain-form alternative
If you do not need the controlled SPA experience and your endpoint accepts multipart/form-data or application/x-www-form-urlencoded, drop the widget inside a real <form> and submit normally. The widget injects a hidden <input name="playtcha-token"> on success — the same contract reCAPTCHA, Turnstile, and hCaptcha use.
export function ContactForm() {
return (
<form method="post" action="/api/contact">
<input name="email" type="email" required />
<textarea name="message" required />
<div className="playtcha" data-sitekey={import.meta.env.VITE_PLAYTCHA_SITE_KEY}></div>
<button type="submit">Send</button>
</form>
);
}On the server, read req.body["playtcha-token"] and run the same verify call. Pick this path when your form already submits to a stateful backend route and you do not need to gate the submit button on token state.
4. Verify on your backend
Your browser sends the token to your backend. Your backend sends it to https://playtcha.com/v1/verify with the secret key. That boundary is what keeps the secret secret.
// Express / Fastify / Hono shape — JSON SPA endpoint.
// 5xx / network -> fail open (logged). 4xx -> reject deterministically.
const failClosed = process.env.PLAYTCHA_FAIL_CLOSED === "1";
app.post("/api/contact", async (req, res) => {
const token = req.body.playtchaToken; // JSON path; use req.body["playtcha-token"] for plain forms
if (!token) return res.status(400).json({ 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 }),
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 res.status(503).json({ error: "verifier_unavailable" });
// fall through — treat as verified
} else if (!data.success) {
// 4xx token_invalid, token_expired, already_redeemed, bad_secret, etc.
return res.status(400).json({ error: "captcha_failed" });
}
// data?.degraded === true -> log, do not block.
} catch (err) {
console.warn("playtcha.verify.unreachable", { err: String(err) });
if (failClosed) return res.status(503).json({ error: "verifier_unavailable" });
}
return res.json({ ok: true });
});For a production-ready helper shape, use the Node verify reference.
5. Choose failure modes deliberately
Default policy: fail open on 5xx and network errors, fail closed on 4xx. A Playtcha outage must not break a customer’s form. A bad or replayed token always must.
| Situation | Recommended default |
|---|---|
| Missing token | Return a clear 400 and tell the user to retry the challenge. |
| 5xx or network error | Log and treat as verified (fail open). Opt into strict mode with PLAYTCHA_FAIL_CLOSED=1 for high-fraud paths. |
410 token_expired | Ask the user to play a fresh challenge. Result tokens have a 5-minute TTL. |
409 already_redeemed | Security signal. Replay attack, stuck retry loop, or duplicate POST. Reject and log request_id. |
429 rate_limited_ip | Honor the Retry-After header; do not retry tighter than 1s. |
This is where product and security meet. Your code path should reflect the actual risk of the form, not a generic default you forgot to revisit.
6. Mobile and accessibility
The widget is a minigame, not a 78×78px checkbox. Budget at least 240px of vertical space in your layout so the play surface stays usable on a phone in portrait. The games respect prefers-reduced-motion and accept pointer, touch, and keyboard input. If you constrain the mount with a fixed-height card, you will get a clipped game — let it grow.
FAQ
Do I need a React wrapper component?
No. You can use one if you want a local abstraction, but the base contract is just a script tag plus a mount node.
Should I verify from the browser in an SPA?
No. The browser is not trusted, and verification requires the secret key. Keep verify on the server. If you are evaluating the broader rollout shape, read CAPTCHA for single-page apps.
What should I read next?
For the broader product fit, read CAPTCHA for React apps. If the protected flow is a signup form, read the signup guide. If mobile browsers are the main concern, read CAPTCHA for mobile web apps. If you want the shorter integration path first, go back to quickstart.