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
| Key | Type | Default | Description |
|---|---|---|---|
siteKey | string | — | Client-side key for the widget. |
secretKey | string | — | Server-side key used to verify the submitted token. |
enabled | boolean | true | Set false to keep the config but hide the field from admins. |
lazy | boolean | true | Defer 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:
- the form approaches the viewport (an
IntersectionObserverwith 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); - the visitor focuses, clicks or edits any field of that form;
- 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).