From 67ca7f29e61457d977c63375eb005a24dd25d2fd Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Sat, 20 Dec 2025 15:54:28 +1100 Subject: [PATCH 1/5] feat(registry): add Google reCAPTCHA v3 script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add useScriptGoogleRecaptcha composable for reCAPTCHA v3 integration - Support enterprise mode and recaptcha.net domain (China) - Add clientInit for grecaptcha.ready queue pattern - Add documentation with usage examples 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../scripts/utility/google-recaptcha.md | 209 ++++++++++++++++++ src/registry.ts | 19 ++ src/runtime/registry/google-recaptcha.ts | 66 ++++++ src/runtime/types.ts | 2 + 4 files changed, 296 insertions(+) create mode 100644 docs/content/scripts/utility/google-recaptcha.md create mode 100644 src/runtime/registry/google-recaptcha.ts diff --git a/docs/content/scripts/utility/google-recaptcha.md b/docs/content/scripts/utility/google-recaptcha.md new file mode 100644 index 00000000..81ed39bc --- /dev/null +++ b/docs/content/scripts/utility/google-recaptcha.md @@ -0,0 +1,209 @@ +--- +title: Google reCAPTCHA +description: Use Google reCAPTCHA v3 in your Nuxt app. +links: + - label: Source + icon: i-simple-icons-github + to: https://github.com/nuxt/scripts/blob/main/src/runtime/registry/google-recaptcha.ts + size: xs +--- + +[Google reCAPTCHA](https://www.google.com/recaptcha/about/) protects your site from spam and abuse using advanced risk analysis. + +Nuxt Scripts provides a registry script composable `useScriptGoogleRecaptcha` to easily integrate reCAPTCHA v3 in your Nuxt app. + +::callout +This integration supports reCAPTCHA v3 (score-based, invisible) only. For v2 checkbox, use the standard reCAPTCHA integration. +:: + +### Loading Globally + +::code-group + +```ts [Always enabled] +export default defineNuxtConfig({ + scripts: { + registry: { + googleRecaptcha: { + siteKey: 'YOUR_SITE_KEY' + } + } + } +}) +``` + +```ts [Production only] +export default defineNuxtConfig({ + $production: { + scripts: { + registry: { + googleRecaptcha: { + siteKey: 'YOUR_SITE_KEY' + } + } + } + } +}) +``` + +```ts [Environment Variables] +export default defineNuxtConfig({ + scripts: { + registry: { + googleRecaptcha: true, + } + }, + runtimeConfig: { + public: { + scripts: { + googleRecaptcha: { + // .env + // NUXT_PUBLIC_SCRIPTS_GOOGLE_RECAPTCHA_SITE_KEY= + siteKey: '', + }, + }, + }, + }, +}) +``` + +:: + +## useScriptGoogleRecaptcha + +The `useScriptGoogleRecaptcha` composable lets you have fine-grain control over when and how reCAPTCHA is loaded on your site. + +```ts +const { proxy } = useScriptGoogleRecaptcha({ + siteKey: 'YOUR_SITE_KEY' +}) + +// Execute reCAPTCHA and get token +proxy.grecaptcha.ready(async () => { + const token = await proxy.grecaptcha.execute('YOUR_SITE_KEY', { action: 'submit' }) + // Send token to your server for verification +}) +``` + +Please follow the [Registry Scripts](/docs/guides/registry-scripts) guide to learn more about advanced usage. + +### GoogleRecaptchaApi + +```ts +export interface GoogleRecaptchaApi { + grecaptcha: { + ready: (callback: () => void) => void + execute: (siteKey: string, options: { action: string }) => Promise + enterprise?: { + ready: (callback: () => void) => void + execute: (siteKey: string, options: { action: string }) => Promise + } + } +} +``` + +### Config Schema + +You must provide the options when setting up the script for the first time. + +```ts +export const GoogleRecaptchaOptions = object({ + /** + * Your reCAPTCHA site key from the Google reCAPTCHA admin console. + */ + siteKey: string(), + /** + * Use reCAPTCHA Enterprise instead of standard reCAPTCHA. + */ + enterprise: optional(boolean()), + /** + * Load from recaptcha.net instead of google.com (works in China). + */ + recaptchaNet: optional(boolean()), + /** + * Language code for the reCAPTCHA widget. + */ + hl: optional(string()), +}) +``` + +## Example + +Using reCAPTCHA v3 to protect a form submission. + +::code-group + +```vue [ContactForm.vue] + + + +``` + +:: + +## Enterprise + +For reCAPTCHA Enterprise, set the `enterprise` option to `true`: + +```ts +export default defineNuxtConfig({ + scripts: { + registry: { + googleRecaptcha: { + siteKey: 'YOUR_SITE_KEY', + enterprise: true + } + } + } +}) +``` + +## China Support + +For sites that need to work in China, use `recaptchaNet: true` to load from `recaptcha.net` instead of `google.com`: + +```ts +export default defineNuxtConfig({ + scripts: { + registry: { + googleRecaptcha: { + siteKey: 'YOUR_SITE_KEY', + recaptchaNet: true + } + } + } +}) +``` + +## Hiding the Badge + +reCAPTCHA v3 displays a badge in the corner of your site. You can hide it with CSS, but you must include attribution in your form: + +```css +.grecaptcha-badge { visibility: hidden; } +``` + +```html +

This site is protected by reCAPTCHA and the Google + Privacy Policy and + Terms of Service apply. +

+``` diff --git a/src/registry.ts b/src/registry.ts index 7fbcebda..6a92a965 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -8,6 +8,7 @@ import type { PlausibleAnalyticsInput } from './runtime/registry/plausible-analy import type { RegistryScript } from './runtime/types' import type { GoogleAdsenseInput } from './runtime/registry/google-adsense' import type { ClarityInput } from './runtime/registry/clarity' +import type { GoogleRecaptchaInput } from './runtime/registry/google-recaptcha' // avoid nuxt/kit dependency here so we can use in docs @@ -289,6 +290,24 @@ export async function registry(resolve?: (path: string, opts?: ResolvePathOption from: await resolve('./runtime/registry/npm'), }, }, + { + label: 'Google reCAPTCHA', + category: 'utility', + logo: ``, + import: { + name: 'useScriptGoogleRecaptcha', + from: await resolve('./runtime/registry/google-recaptcha'), + }, + scriptBundling(options?: GoogleRecaptchaInput) { + if (!options?.siteKey) { + return false + } + const baseUrl = options?.recaptchaNet + ? 'https://www.recaptcha.net/recaptcha' + : 'https://www.google.com/recaptcha' + return `${baseUrl}/${options?.enterprise ? 'enterprise.js' : 'api.js'}` + }, + }, { label: 'Google Tag Manager', category: 'tracking', diff --git a/src/runtime/registry/google-recaptcha.ts b/src/runtime/registry/google-recaptcha.ts new file mode 100644 index 00000000..f0d46f63 --- /dev/null +++ b/src/runtime/registry/google-recaptcha.ts @@ -0,0 +1,66 @@ +import { withQuery } from 'ufo' +import { useRegistryScript } from '#nuxt-scripts/utils' +import type { RegistryScriptInput } from '#nuxt-scripts/types' +import { object, string, optional, boolean } from '#nuxt-scripts-validator' + +export const GoogleRecaptchaOptions = object({ + siteKey: string(), + // Use enterprise.js instead of api.js + enterprise: optional(boolean()), + // Use recaptcha.net (works in China) + recaptchaNet: optional(boolean()), + // Language code + hl: optional(string()), +}) + +export type GoogleRecaptchaInput = RegistryScriptInput + +export interface GoogleRecaptchaApi { + grecaptcha: { + ready: (callback: () => void) => void + execute: (siteKey: string, options: { action: string }) => Promise + enterprise?: { + ready: (callback: () => void) => void + execute: (siteKey: string, options: { action: string }) => Promise + } + } +} + +declare global { + interface Window extends GoogleRecaptchaApi {} +} + +export function useScriptGoogleRecaptcha(_options?: GoogleRecaptchaInput) { + return useRegistryScript(_options?.key || 'googleRecaptcha', (options) => { + const baseUrl = options?.recaptchaNet + ? 'https://www.recaptcha.net/recaptcha' + : 'https://www.google.com/recaptcha' + const scriptPath = options?.enterprise ? 'enterprise.js' : 'api.js' + + return { + scriptInput: { + src: withQuery(`${baseUrl}/${scriptPath}`, { + render: options?.siteKey, + hl: options?.hl, + }), + crossorigin: false, + }, + schema: import.meta.dev ? GoogleRecaptchaOptions : undefined, + scriptOptions: { + use() { + return { grecaptcha: window.grecaptcha } + }, + }, + clientInit: import.meta.server + ? undefined + : () => { + const w = window as any + w.grecaptcha = w.grecaptcha || {} + w.grecaptcha.ready = w.grecaptcha.ready || function (cb: () => void) { + (w.___grecaptcha_cfg = w.___grecaptcha_cfg || {}).fns + = (w.___grecaptcha_cfg.fns || []).concat([cb]) + } + }, + } + }, _options) +} diff --git a/src/runtime/types.ts b/src/runtime/types.ts index ae28b899..7eb55b9e 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -31,6 +31,7 @@ import type { UmamiAnalyticsInput } from './registry/umami-analytics' import type { RybbitAnalyticsInput } from './registry/rybbit-analytics' import type { RedditPixelInput } from './registry/reddit-pixel' import type { PayPalInput } from './registry/paypal' +import type { GoogleRecaptchaInput } from './registry/google-recaptcha' import { object } from '#nuxt-scripts-validator' export type WarmupStrategy = false | 'preload' | 'preconnect' | 'dns-prefetch' @@ -142,6 +143,7 @@ export interface ScriptRegistry { googleAdsense?: GoogleAdsenseInput googleAnalytics?: GoogleAnalyticsInput googleMaps?: GoogleMapsInput + googleRecaptcha?: GoogleRecaptchaInput lemonSqueezy?: LemonSqueezyInput googleTagManager?: GoogleTagManagerInput hotjar?: HotjarInput From fac2ff7b7a39098db5d29ebbd787add0735df4d6 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Sat, 20 Dec 2025 16:07:23 +1100 Subject: [PATCH 2/5] fix(google-recaptcha): add enterprise mode queue support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Set up grecaptcha.enterprise.ready queue when enterprise mode is enabled, so calls before script loads work correctly. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/runtime/registry/google-recaptcha.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/runtime/registry/google-recaptcha.ts b/src/runtime/registry/google-recaptcha.ts index f0d46f63..46f05390 100644 --- a/src/runtime/registry/google-recaptcha.ts +++ b/src/runtime/registry/google-recaptcha.ts @@ -56,10 +56,16 @@ export function useScriptGoogleRecaptcha(_options? : () => { const w = window as any w.grecaptcha = w.grecaptcha || {} - w.grecaptcha.ready = w.grecaptcha.ready || function (cb: () => void) { + const readyFn = function (cb: () => void) { (w.___grecaptcha_cfg = w.___grecaptcha_cfg || {}).fns = (w.___grecaptcha_cfg.fns || []).concat([cb]) } + w.grecaptcha.ready = w.grecaptcha.ready || readyFn + // Enterprise mode uses grecaptcha.enterprise.ready + if (options?.enterprise) { + w.grecaptcha.enterprise = w.grecaptcha.enterprise || {} + w.grecaptcha.enterprise.ready = w.grecaptcha.enterprise.ready || readyFn + } }, } }, _options) From 4ae4f45067027a96a5df01363a4c577b547ae458 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Sat, 20 Dec 2025 16:43:01 +1100 Subject: [PATCH 3/5] feat(playground): add reCAPTCHA demo page and server verification docs --- .../scripts/utility/google-recaptcha.md | 64 +++++++++++++++++++ playground/pages/index.vue | 1 + .../google-recaptcha/nuxt-scripts.vue | 42 ++++++++++++ 3 files changed, 107 insertions(+) create mode 100644 playground/pages/third-parties/google-recaptcha/nuxt-scripts.vue diff --git a/docs/content/scripts/utility/google-recaptcha.md b/docs/content/scripts/utility/google-recaptcha.md index 81ed39bc..0e50ea38 100644 --- a/docs/content/scripts/utility/google-recaptcha.md +++ b/docs/content/scripts/utility/google-recaptcha.md @@ -193,6 +193,70 @@ export default defineNuxtConfig({ }) ``` +## Server-Side Verification + +reCAPTCHA tokens must be verified on your server. Create an API endpoint to validate the token: + +::code-group + +```ts [server/api/verify-recaptcha.post.ts] +export default defineEventHandler(async (event) => { + const { token } = await readBody(event) + const secretKey = process.env.RECAPTCHA_SECRET_KEY + + const response = await $fetch('https://www.google.com/recaptcha/api/siteverify', { + method: 'POST', + body: new URLSearchParams({ + secret: secretKey, + response: token, + }), + }) + + if (!response.success || response.score < 0.5) { + throw createError({ + statusCode: 400, + message: 'reCAPTCHA verification failed', + }) + } + + return { success: true, score: response.score } +}) +``` + +```ts [Enterprise - server/api/verify-recaptcha.post.ts] +export default defineEventHandler(async (event) => { + const { token } = await readBody(event) + const projectId = process.env.RECAPTCHA_PROJECT_ID + const apiKey = process.env.RECAPTCHA_API_KEY + const siteKey = process.env.NUXT_PUBLIC_SCRIPTS_GOOGLE_RECAPTCHA_SITE_KEY + + const response = await $fetch( + `https://recaptchaenterprise.googleapis.com/v1/projects/${projectId}/assessments?key=${apiKey}`, + { + method: 'POST', + body: { + event: { token, siteKey, expectedAction: 'submit' }, + }, + } + ) + + if (!response.tokenProperties?.valid || response.riskAnalysis?.score < 0.5) { + throw createError({ + statusCode: 400, + message: 'reCAPTCHA verification failed', + }) + } + + return { success: true, score: response.riskAnalysis.score } +}) +``` + +:: + +::callout{type="warning"} +Never expose your secret key on the client. Always verify tokens server-side. +:: + ## Hiding the Badge reCAPTCHA v3 displays a badge in the corner of your site. You can hide it with CSS, but you must include attribution in your form: diff --git a/playground/pages/index.vue b/playground/pages/index.vue index 6deb1828..11fb593e 100644 --- a/playground/pages/index.vue +++ b/playground/pages/index.vue @@ -41,6 +41,7 @@ function getPlaygroundPath(script: any): string | null { 'vimeo-player': '/third-parties/vimeo/nuxt-scripts', 'youtube-player': '/third-parties/youtube/nuxt-scripts', 'google-maps': '/third-parties/google-maps/nuxt-scripts', + 'google-recaptcha': '/third-parties/google-recaptcha/nuxt-scripts', 'npm': '/npm/js-confetti', } diff --git a/playground/pages/third-parties/google-recaptcha/nuxt-scripts.vue b/playground/pages/third-parties/google-recaptcha/nuxt-scripts.vue new file mode 100644 index 00000000..30564a4a --- /dev/null +++ b/playground/pages/third-parties/google-recaptcha/nuxt-scripts.vue @@ -0,0 +1,42 @@ + + + From 9fc606de0b3063ad3d0ec6ac5f4fd8cc5df093e3 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Sat, 20 Dec 2025 16:49:06 +1100 Subject: [PATCH 4/5] docs(recaptcha): add complete client+server verification example --- .../scripts/utility/google-recaptcha.md | 70 ++++++++++++++++--- 1 file changed, 60 insertions(+), 10 deletions(-) diff --git a/docs/content/scripts/utility/google-recaptcha.md b/docs/content/scripts/utility/google-recaptcha.md index 0e50ea38..6fce66c9 100644 --- a/docs/content/scripts/utility/google-recaptcha.md +++ b/docs/content/scripts/utility/google-recaptcha.md @@ -129,34 +129,84 @@ export const GoogleRecaptchaOptions = object({ ## Example -Using reCAPTCHA v3 to protect a form submission. +Using reCAPTCHA v3 to protect a form submission with server-side verification. ::code-group ```vue [ContactForm.vue]