Next.js App Router changes how a CAPTCHA integration is wired — not the widget side (that's still a client component), but the verify side, where you now have a choice between a Server Action and a Route Handler. Both work. They have different failure modes. This post walks both with CaptchaLa and the @captchala/react package, then talks through the Edge runtime gotcha that catches a lot of people.
Targets Next.js 14+ with App Router. Pages Router is similar but the handler lives in pages/api/.
Quick start (2 minutes)
npm install @captchala/reactTwo env vars in .env.local:
NEXT_PUBLIC_CAPTCHALA_APP_KEY=your_public_key
CAPTCHALA_APP_SECRET=your_server_only_secretThe NEXT_PUBLIC_ prefix is required for the public app key — that's how Next exposes a value to client bundles. The secret stays unprefixed so it never reaches the browser.
The client component
// app/(auth)/signup/SignupForm.tsx
'use client';
import { useRef, useState } from 'react';
import { Captchala, type CaptchalaHandle } from '@captchala/react';
import { signupAction } from './actions';
export default function SignupForm() {
const tokenRef = useRef<string | null>(null);
const widgetRef = useRef<CaptchalaHandle | null>(null);
const [error, setError] = useState<string | null>(null);
const onSubmit = async (formData: FormData) => {
if (!tokenRef.current) {
setError('Please complete the verification.');
return;
}
formData.append('captchaToken', tokenRef.current);
const result = await signupAction(formData);
if (!result.ok) {
setError(result.error);
widgetRef.current?.reset(); // single-use, force re-verify
tokenRef.current = null;
}
};
return (
<form action={onSubmit}>
<input name="email" type="email" required />
<input name="password" type="password" required />
<Captchala
ref={widgetRef}
appKey={process.env.NEXT_PUBLIC_CAPTCHALA_APP_KEY!}
product="popup"
action="signup"
lang="en"
onSuccess={(res) => { tokenRef.current = res.token; }}
onError={(err) => setError(err.message)}
/>
<button type="submit">Sign up</button>
{error && <p role="alert">{error}</p>}
</form>
);
}The 'use client' directive matters — the widget needs window. The form's action prop calls a Server Action, where verification happens.
Option A: Server Action
// app/(auth)/signup/actions.ts
'use server';
import { headers } from 'next/headers';
export async function signupAction(formData: FormData) {
const token = formData.get('captchaToken')?.toString() ?? '';
const email = formData.get('email')?.toString() ?? '';
if (!token) return { ok: false as const, error: 'Captcha missing' };
const ip = headers().get('x-forwarded-for')?.split(',')[0]?.trim() ?? '';
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, action: 'signup', client_ip: ip }),
cache: 'no-store',
});
const json = await verify.json();
if (json?.code !== 0 || !json?.data?.valid || json?.data?.action !== 'signup') {
return { ok: false as const, error: 'Captcha rejected' };
}
// proceed with signup
return { ok: true as const };
}cache: 'no-store' is required — without it, Next's fetch cache may serve a stale verify response. process.env.CAPTCHALA_APP_KEY is unprefixed, which keeps it server-only.
Option B: Route Handler
If the endpoint also needs to serve a mobile app or non-Next client, use a Route Handler:
// app/api/signup/route.ts
import { NextResponse } from 'next/server';
export async function POST(req: Request) {
const { email, password, captchaToken } = await req.json();
if (!captchaToken) return NextResponse.json({ ok: false, error: 'captcha missing' }, { status: 400 });
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? '';
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 }),
cache: 'no-store',
});
const json = await verify.json();
if (json?.code !== 0 || !json?.data?.valid || json?.data?.action !== 'signup') {
return NextResponse.json({ ok: false, error: 'captcha rejected' }, { status: 403 });
}
return NextResponse.json({ ok: true });
}The client then calls it with a normal fetch('/api/signup', ...).
When to pick which
- Server Action — simpler, type-safe end-to-end, integrates with
useFormState. Recommended for forms only your Next app needs. - Route Handler — better when the same endpoint must also serve a mobile app, webhook, or non-Next client.
Both verify against the same upstream endpoint with the same payload. The choice is about call surface, not security.
Common pitfalls
- Missing
'use client'boundary. The captcha widget useswindow. If you forget the directive at the top of the form file, you get a "window is not defined" build error. The form is the client component; the action is the server module. NEXT_PUBLIC_confusion. Public app key gets the prefix (it's in the bundle anyway, that's fine — it's intentionally public). The secret does not get the prefix and stays inprocess.envserver-side only. Mixing these up either breaks the widget or leaks the secret.- Edge runtime fetch limits. If you set
export const runtime = 'edge'on the Route Handler, you lose Node-only APIs and some headers behave differently. The captcha verify call works on edge, but the body must be a string (not a stream) and the response must be consumed promptly. Default Node runtime is safer for this kind of work. - Forgetting
cache: 'no-store'. Next 14 cachesfetchresponses by default. A cached "valid: true" verify response replayed on a different token is a worst-case bug. Always passcache: 'no-store'on the verify call — easy to miss, hard to debug. - Token reuse across re-renders. Server Actions can be invoked multiple times (revalidation,
useTransition). Make sure your action doesn't accidentally callverifytwice with the same token — keep the call inside the action body and reset the widget on the client when the action returns failure.
Where to go next
- Next.js example repo with both Server Action and Route Handler variants: captcha.la/docs
- Free tier and pricing: captcha.la/pricing
- For App Router middleware-level verification (e.g. blocking abusive IPs before they hit any route), CaptchaLa supports server-token issuance you can validate inside
middleware.ts— useful for protecting whole route segments
The Next.js integration boils down to: client component renders the widget, gets a token, hands it to a Server Action or Route Handler, which calls the verify endpoint with the secret. Three moving pieces, one secret on the server, one public key in the bundle. Once the wiring's done, every new protected form is a five-line change.