[ ENGINEERING_GUIDE ][ RECAPTCHA ][ SECURITY ][ FORMS ][ NEXTJS ]

reCAPTCHA v3 — invisible form spam protection (complete guide 2026)

June 10, 202611 min read
Author: DevStudio.itWeb & AI Studio

Client-side token, API route verification, 0.5 score threshold, lazyOnload in layout.tsx, GDPR disclosure, and honeypot/rate-limit layers — production Next.js pattern.

READ_TIME: 11 MIN_COMPLEXITY: MED_
STAMP: VERIFIED_BY_DS_

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.tsxlazyOnload

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.

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:

  1. No token → HTTP 400 (bots often skip JS).
  2. success: false (e.g. timeout-or-duplicate, wrong secret) → 400 + log error-codes.
  3. score < 0.5 → 400 (bot / suspicious traffic).
  4. 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-After header

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 — CSS position: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):

  1. Extract verifyRecaptchaToken(token, minScore) into src/lib/recaptcha.ts.
  2. In the chatbot component — the same ready + execute block with action submit_chatbot_form.
  3. In POST /api/chatbot/submit — the same verification path as submissions.
  4. 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_timeout on the client,
  • score too low with 0.1–0.4 on a sensible project description,
  • “form doesn't work” complaints only on Firefox with ETP.

Remediation:

  1. Message suggesting refresh / direct email (contact@...) — better than a generic 400.
  2. Lower the threshold temporarily + monitor.
  3. Fallback: phone or Calendly visible beside the form.
  4. 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

  1. Key type: v3, domains: production + optional localhost.
  2. Copy site key (public) and secretRECAPTCHA_SECRET_KEY on Vercel.
  3. After deploy: Overview tab — requests growing for action submit_contact_form.

DevTools

  1. Network → form submit → JSON payload with recaptchaToken (long string).
  2. Response 200 only when score OK.
  3. 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 on ready
  • 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)

  1. Secret in .env.local only, missing on Vercel — works locally, production returns 500 “reCAPTCHA configuration incomplete”. After every new environment, verify RECAPTCHA_SECRET_KEY in the hosting dashboard.
  2. Verify only when token exists — in a solid pattern, missing token means block, not “pass because adblock”. In /api/submissions, empty recaptchaToken ends in 400.
  3. Same token twice — user double-clicks Submit; second request gets timeout-or-duplicate. UI should disable the button after first click (isSubmitting in React).
  4. No action in execute — harder analysis in Google admin; always use meaningful action names per form.
  5. 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

About the author

We build fast websites, web/mobile apps, AI chatbots and hosting setups — with a focus on SEO and conversion.

Recommended links

From theory to production — Branchly, our hosting stack, care plans and shipped work.

LIKE HOW WE THINK? LET'S BUILD SOMETHING TOGETHER.

[ START_PROJECT_CONFIGURATION ]