Vue 3 makes CAPTCHA integration almost too easy — drop a component, listen for @success, post the token to your API. The trick is doing it so the widget cleans up properly when the user navigates away, the token is verified on the backend (not just trusted from the browser), and the form behaves correctly when validation fails. This post does the full thing with @captchala/vue and CaptchaLa.
We'll build a SignupForm.vue you can copy-paste into a real project.
Quick start (2 minutes)
npm install @captchala/vueThe package ships a <Captchala> component plus a loadCaptchalaSDK() helper if you'd rather load the script imperatively. For 95% of cases the component is what you want.
The form component
<!-- SignupForm.vue -->
<script setup lang="ts">
import { ref, onBeforeUnmount } from 'vue';
import { Captchala } from '@captchala/vue';
const email = ref('');
const password = ref('');
const error = ref<string | null>(null);
const captchaToken = ref<string | null>(null);
const widgetRef = ref<InstanceType<typeof Captchala> | null>(null);
const onSuccess = (res: { token: string }) => {
captchaToken.value = res.token;
};
const onErr = (err: { message: string }) => {
error.value = err.message;
};
const submit = async () => {
error.value = null;
if (!captchaToken.value) {
error.value = 'Please complete verification first.';
return;
}
const res = await fetch('/api/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: email.value,
password: password.value,
captchaToken: captchaToken.value,
}),
});
const json = await res.json();
if (!json.ok) {
error.value = json.error ?? 'Signup failed';
// token is single-use — reset for retry
widgetRef.value?.reset();
captchaToken.value = null;
}
};
onBeforeUnmount(() => {
widgetRef.value?.destroy();
});
</script>
<template>
<form @submit.prevent="submit">
<input v-model="email" type="email" required />
<input v-model="password" type="password" required />
<Captchala
ref="widgetRef"
:app-key="appKey"
product="popup"
action="signup"
lang="en"
:theme="{ custom: { brightness: 'light' } }"
@success="onSuccess"
@error="onErr"
/>
<button type="submit">Sign up</button>
<p v-if="error" role="alert">{{ error }}</p>
</form>
</template>
<script lang="ts">
const appKey = import.meta.env.VITE_CAPTCHALA_APP_KEY;
</script>The component mounts the widget on render, emits success with the token, and is destroyed cleanly when the user navigates away thanks to onBeforeUnmount. The token sits in a ref, not reactive — you don't want deep reactivity on a string.
The backend (any Node-shaped runtime)
// server.ts (works in Express, Fastify, Hono, anywhere fetch exists)
async function verifyCaptcha(token: string, action: string, clientIp: string) {
const res = 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, client_ip: clientIp }),
});
const json = await res.json();
return {
ok: json?.code === 0 && json?.data?.valid === true && json?.data?.action === action,
riskScore: json?.data?.risk_score ?? null,
offline: json?.data?.offline ?? false,
};
}
// usage in a route handler
app.post('/api/signup', async (req, res) => {
const ip = (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() ?? req.ip;
const v = await verifyCaptcha(req.body.captchaToken, 'signup', ip);
if (!v.ok) return res.status(403).json({ ok: false, error: 'captcha rejected' });
if (v.riskScore && v.riskScore > 70) {
// optional: extra friction for high-risk signups
}
// proceed with signup
res.json({ ok: true });
});Same verify endpoint as every other CaptchaLa integration — the framework on top doesn't matter, only the contract: token in, validity + risk score out.
Vue Router cleanup
onBeforeUnmount handles regular route changes — Vue Router unmounts the matched component when the route changes, and the cleanup runs. If you're using <keep-alive>, swap to onDeactivated, otherwise the widget stays alive across cache hits and throws "already initialized" warnings.
Common pitfalls
- Using
reactive()for the token. Tokens are flat strings.ref()is correct.reactive()produces a Proxy and you'll spend an hour wondering whyJSON.stringifyreturns{}. - Not destroying the widget on unmount. Without cleanup, fast route changes leak DOM nodes and event listeners. Both
onBeforeUnmount(regular) andonDeactivated(with keep-alive) need handling. - Trusting
@successas the security check. It isn't. It's a UX signal. The backend verify call to captcha.la is the only thing that actually proves the user is human. We've seen production sites ship Vue forms where the only validation was the client emit — anyone with DevTools can fake that in seconds. - Token reuse on retry. When your form fails for an unrelated reason (server error, validation), the captcha token is already consumed. Call
widgetRef.value?.reset()so the user can re-verify and retry. Otherwise the second submit ships a stale token. - SSR (Nuxt) without a client guard. The widget reads
window. If you import it server-side without<ClientOnly>orprocess.clientguards, Nuxt will throw during render. The captcha.la Vue package no-ops on the server, but only if it's loaded the right way — see the Nuxt notes oncaptcha.la/docs.
Where to go next
- Vue package full reference, including the
loadCaptchalaSDK()helper and the imperative API: captcha.la/docs - Pricing: captcha.la/pricing
- For Nuxt 3 specifically, the
<ClientOnly>wrapper around<Captchala>is the simplest path; SSR-safe loading is documented at CaptchaLa
A signup form with real CAPTCHA verification in Vue 3 is about 60 lines of .vue plus 30 lines of server. Keep the widget mounted on initial render (so behavioral signals have time to accumulate), reset on submission failure, verify every token on the backend. That's the whole pattern — if you remember those three rules, the rest is plumbing.