Docs
Next

Captcha

reCAPTCHA and Turnstile verification and lazy loading.

On this page

Google reCAPTCHA v2 and Cloudflare Turnstile are configured per provider under the form-builder options. Both can be enabled at once (editors then pick which one a given form uses); a form accepts at most one captcha field. Keys are forwarded through the option from your own environment — the package never reads env vars itself.

forms: {
  fields: {
    recaptcha: {
      siteKey: process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY!,
      secretKey: process.env.RECAPTCHA_SECRET_KEY!,
    },
    turnstile: {
      siteKey: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!,
      secretKey: process.env.TURNSTILE_SECRET_KEY!,
      lazy: false, // opt out of deferred loading (default: true)
    },
  },
}

OptionsLink to this section

KeyTypeDefaultDescription
siteKeystring—Client-side key for the widget.
secretKeystring—Server-side key used to verify the submitted token.
enabledbooleantrueSet false to keep the config but hide the field from admins.
lazybooleantrueDefer the provider script until the form is actually needed.

Deferred loadingLink to this section

The provider scripts are heavy — a Turnstile widget pulls roughly half a megabyte of challenge-platform payload — which is pure waste on a page whose form sits below the fold and is never reached. With lazy the script and widget initialise on the first of three signals:

  1. the form approaches the viewport (an IntersectionObserver with a 300px margin — a form that is already on screen when the page hydrates loads immediately, so above-the-fold forms behave exactly as before);
  2. the visitor focuses, clicks or edits any field of that form;
  3. the visitor submits — the submit handler opens the gate and waits for the widget before validating, so a keyboard user tabbing straight to the button never sees a spurious "please complete the captcha".

Each form on a page tracks its own trigger (an untouched form never loads a widget because a sibling did), while the provider script itself is fetched once and shared. The widget's skeleton placeholder reserves its final size from first paint, so deferring never shifts the layout. Browsers without IntersectionObserver fall back to eager loading automatically.

Set lazy: false on a provider to restore the previous behaviour (script fetched during hydration).