Most React CAPTCHA tutorials stop at "render the component, get a token." That's the easy half. The interesting half is wiring the token through to your backend, dealing with StrictMode double-mounts, and not breaking SSR. This post walks the full path with CaptchaLa — the official @captchala/react package, a typed onSuccess handler, and a backend verify call that doesn't lie to you.
We'll build a sign-up form, but the same pattern works for login, password reset, contact forms, anything.
Quick start (2 minutes)
npm install @captchala/reactThen mount the widget on your page, send the token to your API on submit, verify server-side. Three steps.
The component
// SignupForm.tsx
import { useState, useRef } from 'react';
import { Captchala, type CaptchalaHandle } from '@captchala/react';
export default function SignupForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const tokenRef = useRef<string | null>(null);
const widgetRef = useRef<CaptchalaHandle | null>(null);
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!tokenRef.current) {
setError('Please complete the verification first.');
return;
}
const res = await fetch('/api/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email,
password,
captchaToken: tokenRef.current,
}),
});
const json = await res.json();
if (!json.ok) {
setError(json.error ?? 'Signup failed');
// token is single-use, reset the widget for retry
widgetRef.current?.reset();
tokenRef.current = null;
}
};
return (
<form onSubmit={onSubmit}>
<input value={email} onChange={(e) => setEmail(e.target.value)} type="email" required />
<input value={password} onChange={(e) => setPassword(e.target.value)} type="password" required />
<Captchala
ref={widgetRef}
appKey={process.env.NEXT_PUBLIC_CAPTCHALA_APP_KEY!}
product="popup"
action="signup"
lang="en"
theme={{ custom: { brightness: 'light' } }}
onSuccess={(res) => { tokenRef.current = res.token; }}
onError={(err) => setError(err.message)}
/>
<button type="submit">Sign up</button>
{error && <p role="alert">{error}</p>}
</form>
);
}Two things to notice. First, the token lives in a useRef, not state — re-rendering on every keystroke shouldn't reissue the widget. Second, on a server-side error we call widgetRef.current?.reset() because the token has been consumed and the user needs a fresh one.
The backend route
The React side gives you a token. The backend has to verify it. Here's a Node/Express route — adapt to whichever runtime you're on:
// api/signup.ts (Express)
import express from 'express';
const app = express();
app.use(express.json());
app.post('/api/signup', async (req, res) => {
const { email, password, captchaToken } = req.body;
const ip = (req.headers['x-forwarded-for'] as string)?.split(',')[0] ?? req.ip;
const verify = await fetch('https://apiv1.captcha.la/v1/challenge/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-App-Key': process.env.CAPTCHALA_APP_KEY!,
'X-App-Secret': process.env.CAPTCHALA_APP_SECRET!,
},
body: JSON.stringify({
token: captchaToken,
action: 'signup',
client_ip: ip,
}),
});
const data = await verify.json();
if (data?.code !== 0 || !data?.data?.valid || data?.data?.action !== 'signup') {
return res.status(403).json({ ok: false, error: 'captcha rejected' });
}
// Optionally inspect risk_score and apply additional friction
if (data.data.risk_score > 70) {
// e.g. require email confirmation before activating account
}
// proceed with real signup...
res.json({ ok: true });
});Same shape regardless of framework. Token in, valid/risk_score out, decide what to do.
Using the hook (if you don't want JSX)
Some teams want imperative control. The package also exposes useCaptchala:
import { useCaptchala } from '@captchala/react';
function CustomTrigger() {
const captcha = useCaptchala({
appKey: process.env.NEXT_PUBLIC_CAPTCHALA_APP_KEY!,
product: 'popup',
action: 'login',
});
return (
<button onClick={async () => {
const token = await captcha.execute();
// send token to backend
}}>
Log in
</button>
);
}Useful when verification needs to fire from a non-form context — a "delete account" confirmation, a one-tap purchase, a comment submission inside a longer thread.
Common pitfalls
- StrictMode double-mounting. In dev, React.StrictMode mounts components twice. The
@captchala/reactcomponent is idempotent and handles this, but if you're wrapping your own widget around the raw script, your code likely isn't. Use the package. - SSR / hydration mismatch. The widget uses
window. If you SSR the page (Next.js, Remix, Astro), the form component needs to be a client component. In Next App Router, mark it'use client'at the top. The captcha.la React package detects SSR and no-ops on the server. - Forgetting to reset after a failed retry. Tokens are single-use. If your backend rejects the form for any reason — bad password, taken email — the captcha token is already consumed. Call
widget.reset()and let the user try again. Otherwise the second submit ships a stale token and gets rejected. - Putting the secret in the bundle. Only the
appKeybelongs in client code. TheappSecretmust stay on the server. UseNEXT_PUBLIC_*for the public key and a non-prefixed env var for the secret. - Not passing
client_ipserver-side. Without it, the risk score on captcha.la's side runs without IP context and is less accurate. Always forward the real client IP fromX-Forwarded-For.
Where to go next
- React package readme on captcha.la/docs — covers TypeScript types, all props, and the imperative API
- Free tier and paid plans at captcha.la/pricing
- For Next.js App Router specifically, see the dedicated Next.js post — Server Actions and Route Handlers are slightly different from a plain Express setup
The React integration is small on purpose: a component, a token, a fetch. Where teams get into trouble is treating the client onSuccess as proof of validity. It isn't. The verify call to CaptchaLa is what actually defends the form. Wire it once, test it once, and you're done.