TL;DR
reCAPTCHA v3 does not show a “I'm not a robot” checkbox — it returns a 0.0–1.0 score and a short-lived token you must verify on the server (siteverify) before saving a lead or sending email. On a production Next.js 15 business site, the script loads with lazyOnload in layout.tsx (to protect LCP), and the contact form waits for grecaptcha.ready with a 12 s timeout — otherwise the token is often empty and the API returns 400. A 0.5 threshold is a sensible default; add IP rate limiting (5 submissions per minute in this project) and consider a honeypot, plus the same pattern for the chatbot. Below: the full flow, GDPR, false positives, and testing.
Who this is for
- Service businesses with a contact form or chatbot that generates leads
- Next.js / React developers who want protection without annoying v2 captchas
- People responsible for GDPR / privacy — disclosure at the form and data transfer to Google
- Teams still getting inbox spam despite “some captcha” in a WordPress theme — looking for a server-side pattern
Keywords (SEO)
recaptcha v3 contact form, protect form from spam, recaptcha nextjs api route, recaptcha score threshold, server side recaptcha verification, lazyOnload recaptcha layout, gdpr recaptcha google
Why v3 instead of the v2 checkbox
Google reCAPTCHA evolved from visible puzzles to invisible risk scoring. With v3, users usually click nothing — the script analyzes signals (domain interaction history, motion patterns, fingerprint) and returns a score. That is better UX on B2B sites where a “select all traffic lights” widget kills conversion.
| Aspect | reCAPTCHA v2 (checkbox) | reCAPTCHA v3 |
|---|---|---|
| UX | Visible challenge | Invisible; corner badge |
| Result | pass / fail | score 0.0–1.0 |
| Decision | mostly in Google UI | You on the server (threshold) |
| Performance | Heavier iframe | Lighter, still a third-party script |
v3 does not replace field validation, rate limits, or sanity checks — it is one layer in defence in depth.
Architecture on Next.js 15 (production pattern)
In a software-house project (multilingual site /pl, /en, /de), protection spans three places: global script in layout, execute() on form submit, verification in /api/submissions.
Script in layout.tsx — lazyOnload
The reCAPTCHA tag does not load afterInteractive like GA4 — intentionally lazyOnload so it does not compete with analytics for first paint or hurt LCP. The site key is public and appears in the URL:
<Script
src="https://www.google.com/recaptcha/api.js?render=6Lcwsx0sAAAAAO4sbP31qTdgjuoGLgMmp9HyxhYB"
strategy="lazyOnload"
/>The code comment is explicit: the script is lazy — the form must wait for ready, otherwise no token is produced.
Badge and legal notice — two levels
Google requires a visible reCAPTCHA badge. In layout, CSS crops the badge to a small icon in the bottom-left (overflow hidden), while the full legal sentence sits next to the contact form in page.tsx — with links to Google Privacy Policy and Terms (translations in messages/pl.json, en.json, de.json):
{translations.contact.form.recaptchaBeforePrivacy}
<a href="https://policies.google.com/privacy">...</a>
{translations.contact.form.recaptchaBetween}
<a href="https://policies.google.com/terms">...</a>
{translations.contact.form.recaptchaAfter}That matters for GDPR: users know data may go to Google LLC for anti-spam analysis — a hidden badge alone is not enough.
Client side — token on form submit
In the contact form's handleSubmit, do not call execute() on onClick without waiting — with lazyOnload, window.grecaptcha can be undefined for the first seconds.
Pattern: wait for ready, then execute
await new Promise((resolve, reject) => {
const t = setTimeout(() => reject(new Error('recaptcha_timeout')), 12000);
const done = () => { clearTimeout(t); resolve(); };
if (window.grecaptcha?.ready) {
window.grecaptcha.ready(done);
} else {
const iv = setInterval(() => {
if (window.grecaptcha?.ready) {
clearInterval(iv);
window.grecaptcha.ready(done);
}
}, 100);
setTimeout(() => {
clearInterval(iv);
if (!window.grecaptcha?.ready) reject(new Error('recaptcha_no_script'));
}, 12000);
}
});
const recaptchaToken = await window.grecaptcha.execute(
'6Lcwsx0sAAAAAO4sbP31qTdgjuoGLgMmp9HyxhYB',
{ action: 'submit_contact_form' }
);The action (submit_contact_form) shows up in the reCAPTCHA admin and logs — separate actions for the chatbot (submit_chatbot_form) make per-channel score analysis easier.
Send the token in the POST body with lead fields:
const response = await fetch('/api/submissions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...data, locale, recaptchaToken }),
});If execute fails, show a clear message (“Wait a moment after the page loads…”) — do not force-submit an empty token.
Keep TypeScript types for grecaptcha in src/types/gtag.d.ts next to gtag to avoid compile errors.
Server side — verification in the API route
The secret key never goes to the browser. In Vercel / .env, set RECAPTCHA_SECRET_KEY and in POST /api/submissions call:
const recaptchaResponse = await fetch(
'https://www.google.com/recaptcha/api/siteverify',
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `secret=${recaptchaSecret}&response=${recaptchaToken}`,
}
);
const recaptchaData = await recaptchaResponse.json();Decision order in production:
- No token → HTTP 400 (bots often skip JS).
success: false(e.g.timeout-or-duplicate, wrong secret) → 400 + logerror-codes.score < 0.5→ 400 (bot / suspicious traffic).- Only then field validation and Prisma save + transactional emails.
if (recaptchaData.score !== undefined && recaptchaData.score < 0.5) {
return NextResponse.json(
{ error: 'Verification failed. Refresh the page and try again.' },
{ status: 400 }
);
}The token is single-use and short-lived — do not cache it across requests. On network errors to Google, block the submission (fail closed); an open endpoint without verification invites spammers.
Score threshold — choosing 0.5, 0.7, or a “soft” band
Google treats score as a signal, not an absolute. In practice:
| Threshold | Effect | When |
|---|---|---|
| 0.3 | Fewer false rejects, more spam | Very low traffic, A/B tests |
| 0.5 | Balance (default in many tutorials) | Business sites, B2B leads |
| 0.7 | Aggressive filter | Forms with incentives (e-book, coupon) |
In this project, 0.5 is the starting point. After a week, check reCAPTCHA Admin → Analytics for the score distribution on action submit_contact_form. If 5% of legitimate leads score 0.45–0.49, consider 0.45 or an extra layer (rate limit) instead of a hard 0.7.
Soft threshold: score 0.3–0.5 → save to a “manual review” queue instead of hard reject — useful for Ads traffic from new devices (VPN, Safari ITP).
Rate limit — first line of defence (already in the project)
Before Google is even involved, /api/submissions calls checkEmailRateLimit(ip) from src/lib/email-rate-limit.ts:
- 5 requests per IP per 60 seconds
- on exceed: HTTP 429 +
Retry-Afterheader
That protects against simple scripts and floods that would exhaust transactional email quotas. The same helper guards other mail endpoints (chatbot, contracts, testimonials).
Rate limiting does not replace reCAPTCHA — a botnet with many IPs still gets through. Together they raise attack cost.
Honeypot — when to add a third layer
This repo does not have a honeypot field yet — fine with v3 + rate limit at moderate traffic. Add a honeypot when:
- you see repeat spam with decent scores (bot farms),
- you have a simple HTML form easy to scrape,
- you want zero cost (hidden field
website,company_url— CSSposition:absolute; left:-9999px,tabIndex={-1},autoComplete="off").
The server rejects when the honeypot is non-empty — without saying “you are a bot” (so attackers learn less). Order: rate limit → honeypot → reCAPTCHA → business validation.
Chatbot — same pattern, separate endpoint
The contact form uses /api/submissions with full reCAPTCHA verification. The chatbot endpoint /api/chatbot/submit currently has rate limiting but no token check — a typical gap once spammers discover the JSON API.
Recommended plan (aligned with the rest of the stack):
- Extract
verifyRecaptchaToken(token, minScore)intosrc/lib/recaptcha.ts. - In the chatbot component — the same
ready+executeblock with actionsubmit_chatbot_form. - In
POST /api/chatbot/submit— the same verification path as submissions. - reCAPTCHA disclosure on the chatbot's final step (short version of the form text).
One site key, one secret, two actions in the Google panel — easier debugging than two separate reCAPTCHA projects.
GDPR, cookies, and privacy policy
reCAPTCHA v3 processes user data at Google (US — transfer mechanisms: SCC, DPF depending on current law). Minimum compliance pack:
- Notice at the form (Google Privacy + Terms links) — already in the UI.
- In your site privacy policy: purpose (anti-spam), data categories (IP, Google cookies, behaviour signals), legal basis (legitimate interest Art. 6(1)(f) or consent — consult counsel if tied to marketing cookies).
- If a cookie banner blocks Google scripts until consent — reCAPTCHA may not run; either treat it as “essential” for anti-spam or load after consent with a clear form message.
- Retention: keep score logs in your API briefly; do not store reCAPTCHA tokens permanently in the leads table.
Do not pretend v3 is “cookie-free” — the badge and Google docs say otherwise.
False positives — what to do
Users on VPN, Tor, fresh browser profiles, ad blockers blocking google.com/recaptcha, or corporate proxies sometimes get low scores despite genuine intent.
Symptoms in logs:
recaptcha_no_script/recaptcha_timeouton the client,score too lowwith 0.1–0.4 on a sensible project description,- “form doesn't work” complaints only on Firefox with ETP.
Remediation:
- Message suggesting refresh / direct email (
contact@...) — better than a generic 400. - Lower the threshold temporarily + monitor.
- Fallback: phone or Calendly visible beside the form.
- Do not drop protection for honeypot-only because “captcha is bad” — you lose defence against targeted spam.
Test from the production domain — localhost must be on the reCAPTCHA domain list, otherwise success: false.
Testing — 15-minute checklist
Google reCAPTCHA panel
- Key type: v3, domains: production + optional localhost.
- Copy site key (public) and secret →
RECAPTCHA_SECRET_KEYon Vercel. - After deploy: Overview tab — requests growing for action
submit_contact_form.
DevTools
- Network → form submit → JSON payload with
recaptchaToken(long string). - Response 200 only when score OK.
- Deliberately submit without token (curl) → 400.
Simulating low score
Google does not offer a “be a bot” switch in v3; use test keys from Google docs or temporarily lower the threshold on staging.
Pre–Google Ads checklist
- Secret set on production (not local only)
-
lazyOnload+ 12 s timeout onready - Disclosure at the form in all locales
- Rate limit active
- Chatbot (if public) — same verify as the form
- Server logs without leaking the secret key
Performance vs. security
lazyOnload for reCAPTCHA is the same conscious trade-off as Google Ads tags: LCP matters more than instant captcha readiness. B2B forms are usually filled after 30+ seconds — by then the script is loaded. Exception: landing with an above-the-fold form and very short decision time — consider afterInteractive for reCAPTCHA only or preload on the contact page.
Do not load reCAPTCHA on every blog page if you only have one form on the home page — this project uses a global script (simpler), but for Performance tuning you can conditionally render <Script> only where <form> exists.
Common implementation mistakes (and how to avoid them)
- Secret in
.env.localonly, missing on Vercel — works locally, production returns 500 “reCAPTCHA configuration incomplete”. After every new environment, verifyRECAPTCHA_SECRET_KEYin the hosting dashboard. - Verify only when token exists — in a solid pattern, missing token means block, not “pass because adblock”. In
/api/submissions, emptyrecaptchaTokenends in 400. - Same token twice — user double-clicks Submit; second request gets
timeout-or-duplicate. UI should disable the button after first click (isSubmittingin React). - No action in
execute— harder analysis in Google admin; always use meaningful action names per form. - Logging full token in production — even one-time tokens should not land in public logs or Sentry breadcrumbs.
Multilingual setup (/pl, /en, /de)
The reCAPTCHA script in the shared layout.tsx covers all locales — do not duplicate keys per language. Disclosure text comes from translation files (messages/*.json), so each language version has correct legal links without hardcoding in the component. API error messages (400/429) should eventually be localized by locale from the body too — some are Polish regardless of /en today, worth fixing during backend i18n refactor.
FAQ
Can the site key live in the repo?
Yes — the site key is public (visible in HTML). Secret key never — server environment variables only.
Is client-side verification enough?
No. Tokens can be copied from DevTools or replayed. The decision must happen on the server after siteverify.
Why is the token empty despite the layout script?
Most often execute before grecaptcha.ready or the script blocked by an ad blocker. Fix: polling + timeout pattern as in page.tsx.
Is 0.5 an official Google recommendation?
Google explains how to interpret score, not one threshold. 0.5 is a practical default; calibrate on your traffic.
reCAPTCHA v3 vs Cloudflare Turnstile?
Turnstile can be lighter with simpler UX; reCAPTCHA v3 fits the Google ecosystem and many hosting panels. Brand matters less than server-side verify + rate limit.
Must I show the badge?
Yes — Google terms. You can style placement (as in this project: small icon + full text at the form).
Summary
Effective form protection is not pasting a tutorial script — it is a consistent pipeline: lazyOnload in layout, patient execute after ready, mandatory siteverify with a score threshold, rate limit on the API, GDPR disclosure at submit, and the same scheme for the chatbot. 0.5 is a starting point — tune from logs. For false positives, lower the threshold or add a honeypot instead of turning protection off entirely.
Want to secure your forms?
- Contact us — we will implement reCAPTCHA v3, rate limits, and an anti-spam audit for your site
- Business websites — Next.js, lead forms, and GDPR in one delivery
- See our work — production sites with invisible bot protection