From 2127d33b53d584b75ed0a409f250037547949a83 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Sat, 20 Dec 2025 15:57:55 +1100 Subject: [PATCH 1/6] feat(registry): add PostHog analytics script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add useScriptPostHog composable using npm package pattern - Add posthog-js as optional peer dependency - Support US/EU region configuration - Add common config options (autocapture, capturePageview, etc.) - Add documentation with usage examples 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- docs/content/scripts/analytics/posthog.md | 188 ++++++++++++++++++++++ package.json | 10 +- pnpm-lock.yaml | 41 +++++ src/registry.ts | 11 ++ src/runtime/registry/posthog.ts | 73 +++++++++ src/runtime/types.ts | 2 + 6 files changed, 323 insertions(+), 2 deletions(-) create mode 100644 docs/content/scripts/analytics/posthog.md create mode 100644 src/runtime/registry/posthog.ts diff --git a/docs/content/scripts/analytics/posthog.md b/docs/content/scripts/analytics/posthog.md new file mode 100644 index 00000000..c281acbb --- /dev/null +++ b/docs/content/scripts/analytics/posthog.md @@ -0,0 +1,188 @@ +--- +title: PostHog +description: Use PostHog in your Nuxt app. +links: +- label: Source + icon: i-simple-icons-github + to: https://github.com/nuxt/scripts/blob/main/src/runtime/registry/posthog.ts + size: xs +--- + +[PostHog](https://posthog.com) is an open-source product analytics platform that provides analytics, session replay, feature flags, A/B testing, and more. + +Nuxt Scripts provides a registry script composable `useScriptPostHog` to easily integrate PostHog in your Nuxt app. + +## Installation + +You must install the `posthog-js` dependency: + +```bash +pnpm add posthog-js +``` + +### Nuxt Config Setup + +::code-group + +```ts [Always enabled] +export default defineNuxtConfig({ + scripts: { + registry: { + posthog: { + apiKey: 'YOUR_API_KEY' + } + } + } +}) +``` + +```ts [Production only] +export default defineNuxtConfig({ + $production: { + scripts: { + registry: { + posthog: { + apiKey: 'YOUR_API_KEY' + } + } + } + } +}) +``` + +:: + +#### With Environment Variables + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + scripts: { + registry: { + posthog: true, + } + }, + runtimeConfig: { + public: { + scripts: { + posthog: { + apiKey: '', // NUXT_PUBLIC_SCRIPTS_POSTHOG_API_KEY + }, + }, + }, + }, +}) +``` + +## useScriptPostHog + +```ts +const { proxy } = useScriptPostHog({ + apiKey: 'YOUR_API_KEY' +}) + +// Capture an event +proxy.posthog.capture('button_clicked', { + button_name: 'signup' +}) +``` + +Please follow the [Registry Scripts](/docs/guides/registry-scripts) guide to learn more about advanced usage. + +### PostHogApi + +```ts +import type { PostHog } from 'posthog-js' + +export interface PostHogApi { + posthog: PostHog +} +``` + +### Config Schema + +```ts +export const PostHogOptions = object({ + apiKey: string(), + region: optional(union([literal('us'), literal('eu')])), + autocapture: optional(boolean()), + capturePageview: optional(boolean()), + capturePageleave: optional(boolean()), + disableSessionRecording: optional(boolean()), + config: optional(object({})), // Full PostHogConfig passthrough +}) +``` + +## Example + +Using PostHog to track a signup event. + +::code-group + +```vue [SignupForm.vue] + + + +``` + +:: + +## EU Hosting + +To use PostHog's EU cloud: + +```ts +export default defineNuxtConfig({ + scripts: { + registry: { + posthog: { + apiKey: 'YOUR_API_KEY', + region: 'eu' + } + } + } +}) +``` + +## Feature Flags + +```ts +const { proxy } = useScriptPostHog() + +// Check a feature flag +if (proxy.posthog.isFeatureEnabled('new-dashboard')) { + // Show new dashboard +} + +// Get flag payload +const payload = proxy.posthog.getFeatureFlagPayload('experiment-config') +``` + +## Disabling Session Recording + +```ts +export default defineNuxtConfig({ + scripts: { + registry: { + posthog: { + apiKey: 'YOUR_API_KEY', + disableSessionRecording: true + } + } + } +}) +``` diff --git a/package.json b/package.json index de849863..64ba0630 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,8 @@ "esbuild", "unimport", "#nuxt-scripts/types", - "#nuxt-scripts-validator" + "#nuxt-scripts-validator", + "posthog-js" ] }, "peerDependencies": { @@ -78,7 +79,8 @@ "@types/google.maps": "^3.58.1", "@types/vimeo__player": "^2.18.3", "@types/youtube": "^0.1.0", - "@unhead/vue": "^2.0.3" + "@unhead/vue": "^2.0.3", + "posthog-js": "^1.0.0" }, "peerDependenciesMeta": { "@googlemaps/markerclusterer": { @@ -98,6 +100,9 @@ }, "@types/youtube": { "optional": true + }, + "posthog-js": { + "optional": true } }, "dependencies": { @@ -126,6 +131,7 @@ "@nuxt/scripts": "workspace:*", "@nuxt/test-utils": "3.19.2", "@paypal/paypal-js": "^9.1.0", + "posthog-js": "^1.222.0", "@types/semver": "^7.7.1", "@typescript-eslint/typescript-estree": "^8.50.0", "@vue/test-utils": "^2.4.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0df67d53..94fd44cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -135,6 +135,9 @@ importers: playwright-core: specifier: ^1.57.0 version: 1.57.0 + posthog-js: + specifier: ^1.222.0 + version: 1.309.1 shiki: specifier: ^3.20.0 version: 3.20.0 @@ -1787,6 +1790,9 @@ packages: '@poppinss/exception@1.2.2': resolution: {integrity: sha512-m7bpKCD4QMlFCjA/nKTs23fuvoVFoA83brRKmObCUNmi/9tVu8Ve3w4YQAnJu4q3Tjf5fr685HYIC/IA2zHRSg==} + '@posthog/core@1.8.1': + resolution: {integrity: sha512-jfzBtQIk9auRi/biO+G/gumK5KxqsD5wOr7XpYMROE/I3pazjP4zIziinp21iQuIQJMXrDvwt9Af3njgOGwtew==} + '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} @@ -3564,6 +3570,9 @@ packages: core-js-compat@3.46.0: resolution: {integrity: sha512-p9hObIIEENxSV8xIu+V68JjSeARg6UVMG5mR+JEUguG3sI6MsiS1njz2jHmyJDvA+8jX/sytkBHup6kxhM9law==} + core-js@3.47.0: + resolution: {integrity: sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -4199,6 +4208,9 @@ packages: picomatch: optional: true + fflate@0.4.8: + resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==} + fflate@0.7.4: resolution: {integrity: sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==} @@ -5852,6 +5864,12 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + posthog-js@1.309.1: + resolution: {integrity: sha512-JUJcQhYzNNKO0cgnSbowCsVi2RTu75XGZ2EmnTQti4tMGRCTOv/HCnZasdFniBGZ0rLugQkaScYca/84Ta2u5Q==} + + preact@10.28.0: + resolution: {integrity: sha512-rytDAoiXr3+t6OIP3WGlDd0ouCUG1iCWzkcY3++Nreuoi17y6T5i/zRhe6uYfoVcxq6YU+sBtJouuRDsq8vvqA==} + prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} @@ -7125,6 +7143,9 @@ packages: web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + web-vitals@4.2.4: + resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -9392,6 +9413,10 @@ snapshots: '@poppinss/exception@1.2.2': {} + '@posthog/core@1.8.1': + dependencies: + cross-spawn: 7.0.6 + '@quansync/fs@1.0.0': dependencies: quansync: 1.0.0 @@ -11288,6 +11313,8 @@ snapshots: dependencies: browserslist: 4.28.1 + core-js@3.47.0: {} + core-util-is@1.0.3: {} crc-32@1.2.2: {} @@ -11978,6 +12005,8 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fflate@0.4.8: {} + fflate@0.7.4: {} figures@6.1.0: @@ -14257,6 +14286,16 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + posthog-js@1.309.1: + dependencies: + '@posthog/core': 1.8.1 + core-js: 3.47.0 + fflate: 0.4.8 + preact: 10.28.0 + web-vitals: 4.2.4 + + preact@10.28.0: {} + prebuild-install@7.1.3: dependencies: detect-libc: 2.1.2 @@ -15820,6 +15859,8 @@ snapshots: web-namespaces@2.0.1: {} + web-vitals@4.2.4: {} + webidl-conversions@3.0.1: {} webpack-sources@3.3.3: {} diff --git a/src/registry.ts b/src/registry.ts index 7fbcebda..638475c7 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -38,6 +38,17 @@ export async function registry(resolve?: (path: string, opts?: ResolvePathOption from: await resolve('./runtime/registry/cloudflare-web-analytics'), }, }, + { + label: 'PostHog', + src: false, + scriptBundling: false, + category: 'analytics', + logo: ``, + import: { + name: 'useScriptPostHog', + from: await resolve('./runtime/registry/posthog'), + }, + }, { label: 'Fathom Analytics', scriptBundling: false, // breaks script diff --git a/src/runtime/registry/posthog.ts b/src/runtime/registry/posthog.ts new file mode 100644 index 00000000..3fd130e5 --- /dev/null +++ b/src/runtime/registry/posthog.ts @@ -0,0 +1,73 @@ +import type { PostHog, PostHogConfig } from 'posthog-js' +import { useRegistryScript } from '../utils' +import { string, object, optional, boolean, union, literal } from '#nuxt-scripts-validator' +import type { RegistryScriptInput } from '#nuxt-scripts/types' + +export const PostHogOptions = object({ + apiKey: string(), + region: optional(union([literal('us'), literal('eu')])), + autocapture: optional(boolean()), + capturePageview: optional(boolean()), + capturePageleave: optional(boolean()), + disableSessionRecording: optional(boolean()), + config: optional(object({})), +}) + +export type PostHogInput = RegistryScriptInput + +export interface PostHogApi { + posthog: PostHog +} + +declare global { + interface Window { + posthog?: PostHog + } +} + +let posthogInstance: PostHog | undefined +let initPromise: Promise | undefined + +export function useScriptPostHog(_options?: PostHogInput) { + return useRegistryScript('posthog', options => ({ + scriptInput: { + src: '', // No external script - using npm package + }, + schema: import.meta.dev ? PostHogOptions : undefined, + scriptOptions: { + use() { + return posthogInstance ? { posthog: posthogInstance } : undefined + }, + }, + clientInit: import.meta.server + ? undefined + : () => { + if (initPromise) + return + + const region = options?.region || 'us' + const apiHost = region === 'eu' + ? 'https://eu.i.posthog.com' + : 'https://us.i.posthog.com' + + initPromise = import('posthog-js').then(({ default: posthog }) => { + const config: Partial = { + api_host: apiHost, + ...options?.config as Partial, + } + if (typeof options?.autocapture === 'boolean') + config.autocapture = options.autocapture + if (typeof options?.capturePageview === 'boolean') + config.capture_pageview = options.capturePageview + if (typeof options?.capturePageleave === 'boolean') + config.capture_pageleave = options.capturePageleave + if (typeof options?.disableSessionRecording === 'boolean') + config.disable_session_recording = options.disableSessionRecording + + posthogInstance = posthog.init(options?.apiKey || '', config) + window.posthog = posthogInstance + return posthogInstance + }) + }, + }), _options) +} diff --git a/src/runtime/types.ts b/src/runtime/types.ts index ae28b899..89f61ae7 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 { PostHogInput } from './registry/posthog' import { object } from '#nuxt-scripts-validator' export type WarmupStrategy = false | 'preload' | 'preconnect' | 'dns-prefetch' @@ -147,6 +148,7 @@ export interface ScriptRegistry { hotjar?: HotjarInput intercom?: IntercomInput paypal?: PayPalInput + posthog?: PostHogInput matomoAnalytics?: MatomoAnalyticsInput rybbitAnalytics?: RybbitAnalyticsInput redditPixel?: RedditPixelInput From d43a233e1a5fb2f0f6b863910c82add11bad985e Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Sat, 20 Dec 2025 16:08:30 +1100 Subject: [PATCH 2/6] fix(posthog): use window for state instead of module-level vars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move state management to window object to handle HMR correctly and prevent shared state issues across multiple useScriptPostHog calls. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/runtime/registry/posthog.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/runtime/registry/posthog.ts b/src/runtime/registry/posthog.ts index 3fd130e5..b7b5e873 100644 --- a/src/runtime/registry/posthog.ts +++ b/src/runtime/registry/posthog.ts @@ -22,12 +22,10 @@ export interface PostHogApi { declare global { interface Window { posthog?: PostHog + __posthogInitPromise?: Promise } } -let posthogInstance: PostHog | undefined -let initPromise: Promise | undefined - export function useScriptPostHog(_options?: PostHogInput) { return useRegistryScript('posthog', options => ({ scriptInput: { @@ -36,13 +34,14 @@ export function useScriptPostHog(_options?: PostHogInput) schema: import.meta.dev ? PostHogOptions : undefined, scriptOptions: { use() { - return posthogInstance ? { posthog: posthogInstance } : undefined + return window.posthog ? { posthog: window.posthog } : undefined }, }, clientInit: import.meta.server ? undefined : () => { - if (initPromise) + // Use window for state to handle HMR correctly + if (window.__posthogInitPromise || window.posthog) return const region = options?.region || 'us' @@ -50,7 +49,7 @@ export function useScriptPostHog(_options?: PostHogInput) ? 'https://eu.i.posthog.com' : 'https://us.i.posthog.com' - initPromise = import('posthog-js').then(({ default: posthog }) => { + window.__posthogInitPromise = import('posthog-js').then(({ default: posthog }) => { const config: Partial = { api_host: apiHost, ...options?.config as Partial, @@ -64,9 +63,8 @@ export function useScriptPostHog(_options?: PostHogInput) if (typeof options?.disableSessionRecording === 'boolean') config.disable_session_recording = options.disableSessionRecording - posthogInstance = posthog.init(options?.apiKey || '', config) - window.posthog = posthogInstance - return posthogInstance + window.posthog = posthog.init(options?.apiKey || '', config) + return window.posthog }) }, }), _options) From f2431cfabb41d3f5136f4de14dc7cb47ceea4957 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Sat, 20 Dec 2025 16:35:37 +1100 Subject: [PATCH 3/6] fix(posthog): use() waits for init promise --- src/runtime/registry/posthog.ts | 6 +- todo/posthog.md | 357 ++++++++++++++++++++++++++++++++ todo/recaptcha.md | 291 ++++++++++++++++++++++++++ todo/tiktok-pixel.md | 332 +++++++++++++++++++++++++++++ 4 files changed, 985 insertions(+), 1 deletion(-) create mode 100644 todo/posthog.md create mode 100644 todo/recaptcha.md create mode 100644 todo/tiktok-pixel.md diff --git a/src/runtime/registry/posthog.ts b/src/runtime/registry/posthog.ts index b7b5e873..8e9f2db2 100644 --- a/src/runtime/registry/posthog.ts +++ b/src/runtime/registry/posthog.ts @@ -34,7 +34,11 @@ export function useScriptPostHog(_options?: PostHogInput) schema: import.meta.dev ? PostHogOptions : undefined, scriptOptions: { use() { - return window.posthog ? { posthog: window.posthog } : undefined + if (window.posthog) + return { posthog: window.posthog } + if (window.__posthogInitPromise) + return { posthog: window.__posthogInitPromise.then(() => window.posthog) } + return undefined }, }, clientInit: import.meta.server diff --git a/todo/posthog.md b/todo/posthog.md new file mode 100644 index 00000000..764e9ce0 --- /dev/null +++ b/todo/posthog.md @@ -0,0 +1,357 @@ +# PostHog Implementation Plan + +## Research Summary + +**NPM Package**: Official `posthog-js` package. User installs it, we use it directly. + +**Approach**: Peer dependency pattern - user installs `posthog-js`, we import and initialize it. No CDN script loading. Add to module externals. + +**Key Decision**: Use `defaults: '2025-11-30'` by default (PostHog's recommended 2025 settings). Document this, users can customize. + +## Implementation + +### 1. Package.json Updates + +```json +"peerDependencies": { + "posthog-js": "^1.0.0" +}, +"peerDependenciesMeta": { + "posthog-js": { + "optional": true + } +} +``` + +### 2. Module Externals (`src/module.ts` or build config) + +Add `posthog-js` to externals so it's not bundled: +```ts +externals: [ + // ... existing + 'posthog-js', +] +``` + +### 3. Registry Script (`src/runtime/registry/posthog.ts`) + +```ts +import type { PostHog, PostHogConfig } from 'posthog-js' +import { useRegistryScript } from '../utils' +import { string, object, optional, boolean, union, literal } from '#nuxt-scripts-validator' +import type { RegistryScriptInput } from '#nuxt-scripts/types' + +export const PostHogOptions = object({ + apiKey: string(), + // Region determines api_host + region: optional(union([literal('us'), literal('eu')])), // default 'us' + // Common config options (camelCase, mapped internally) + autocapture: optional(boolean()), + capturePageview: optional(boolean()), + capturePageleave: optional(boolean()), + disableSessionRecording: optional(boolean()), + // Full config passthrough (unvalidated, use PostHog's snake_case) + config: optional(object({})), +}) + +export type PostHogInput = RegistryScriptInput + +export interface PostHogApi { + posthog: PostHog +} + +declare global { + interface Window { + posthog?: PostHog + } +} + +let posthogInstance: PostHog | undefined + +export function useScriptPostHog(_options?: PostHogInput) { + return useRegistryScript('posthog', options => { + const region = options?.region || 'us' + const apiHost = region === 'eu' + ? 'https://eu.i.posthog.com' + : 'https://us.i.posthog.com' + + return { + scriptInput: { + src: '', // No external script - using npm package + }, + schema: import.meta.dev ? PostHogOptions : undefined, + scriptOptions: { + use() { + return { posthog: posthogInstance! } + }, + }, + clientInit: import.meta.server + ? undefined + : async () => { + const { default: posthog } = await import('posthog-js') + + // Build config + const config: Partial = { + api_host: apiHost, + defaults: '2025-11-30', // PostHog's 2025 recommended defaults + ...options?.config as Partial, + } + // Map camelCase options to snake_case + if (typeof options?.autocapture === 'boolean') config.autocapture = options.autocapture + if (typeof options?.capturePageview === 'boolean') config.capture_pageview = options.capturePageview + if (typeof options?.capturePageleave === 'boolean') config.capture_pageleave = options.capturePageleave + if (typeof options?.disableSessionRecording === 'boolean') config.disable_session_recording = options.disableSessionRecording + + posthogInstance = posthog.init(options?.apiKey!, config) + window.posthog = posthogInstance + }, + } + }, _options) +} +``` + +### 4. Types (`src/runtime/types.ts`) + +```ts +import type { PostHogInput } from './registry/posthog' + +// in ScriptRegistry interface +posthog?: PostHogInput +``` + +### 5. Registry Entry (`src/registry.ts`) + +```ts +{ + label: 'PostHog', + category: 'analytics', + src: false, // No external script + scriptBundling: false, + logo: `...`, // PostHog hedgehog logo + import: { + name: 'useScriptPostHog', + from: await resolve('./runtime/registry/posthog'), + }, +} +``` + +### 6. Documentation (`docs/content/scripts/analytics/posthog.md`) + +```md +--- +title: PostHog +description: Use PostHog in your Nuxt app. +links: +- label: Source + icon: i-simple-icons-github + to: https://github.com/nuxt/scripts/blob/main/src/runtime/registry/posthog.ts + size: xs +--- + +[PostHog](https://posthog.com) is an open-source product analytics platform that provides analytics, session replay, feature flags, A/B testing, and more. + +Nuxt Scripts provides a registry script composable `useScriptPostHog` to easily integrate PostHog in your Nuxt app. + +## Installation + +You must install the `posthog-js` dependency: + +\`\`\`bash +pnpm add posthog-js +\`\`\` + +### Nuxt Config Setup + +::code-group + +\`\`\`ts [Always enabled] +export default defineNuxtConfig({ + scripts: { + registry: { + posthog: { + apiKey: 'YOUR_API_KEY' + } + } + } +}) +\`\`\` + +\`\`\`ts [Production only] +export default defineNuxtConfig({ + $production: { + scripts: { + registry: { + posthog: { + apiKey: 'YOUR_API_KEY' + } + } + } + } +}) +\`\`\` + +:: + +#### With Environment Variables + +\`\`\`ts [nuxt.config.ts] +export default defineNuxtConfig({ + scripts: { + registry: { + posthog: true, + } + }, + runtimeConfig: { + public: { + scripts: { + posthog: { + apiKey: '', // NUXT_PUBLIC_SCRIPTS_POSTHOG_API_KEY + }, + }, + }, + }, +}) +\`\`\` + +## useScriptPostHog + +\`\`\`ts +const { proxy } = useScriptPostHog({ + apiKey: 'YOUR_API_KEY' +}) + +// Capture an event +proxy.posthog.capture('button_clicked', { + button_name: 'signup' +}) +\`\`\` + +Please follow the [Registry Scripts](/docs/guides/registry-scripts) guide to learn more about advanced usage. + +### PostHogApi + +\`\`\`ts +import type { PostHog } from 'posthog-js' + +export interface PostHogApi { + posthog: PostHog +} +\`\`\` + +### Config Schema + +\`\`\`ts +export const PostHogOptions = object({ + apiKey: string(), + region: optional(union([literal('us'), literal('eu')])), + autocapture: optional(boolean()), + capturePageview: optional(boolean()), + capturePageleave: optional(boolean()), + disableSessionRecording: optional(boolean()), + config: optional(object({})), // Full PostHogConfig passthrough +}) +\`\`\` + +::callout +Nuxt Scripts sets `defaults: '2025-11-30'` by default for PostHog's recommended 2025 configuration. You can override this via the `config` option. +:: + +## Example + +Using PostHog to track a signup event. + +::code-group + +\`\`\`vue [SignupForm.vue] + + + +\`\`\` + +:: + +## EU Hosting + +To use PostHog's EU cloud: + +\`\`\`ts +export default defineNuxtConfig({ + scripts: { + registry: { + posthog: { + apiKey: 'YOUR_API_KEY', + region: 'eu' + } + } + } +}) +\`\`\` + +## Feature Flags + +\`\`\`ts +const { proxy } = useScriptPostHog() + +// Check a feature flag +if (proxy.posthog.isFeatureEnabled('new-dashboard')) { + // Show new dashboard +} + +// Get flag payload +const payload = proxy.posthog.getFeatureFlagPayload('experiment-config') +\`\`\` + +## Disabling Session Recording + +\`\`\`ts +export default defineNuxtConfig({ + scripts: { + registry: { + posthog: { + apiKey: 'YOUR_API_KEY', + disableSessionRecording: true + } + } + } +}) +\`\`\` +``` + +### 7. Testing + +E2E test: +- Page with PostHog loaded +- Verify `posthog.capture()` works +- Verify init called with correct config + +## Files to Create/Modify + +- [ ] `package.json` (add posthog-js peer dep + peerDependenciesMeta) +- [ ] `src/module.ts` or build config (add posthog-js to externals) +- [ ] `src/runtime/registry/posthog.ts` (new) +- [ ] `src/runtime/types.ts` (add import + ScriptRegistry) +- [ ] `src/registry.ts` (add entry with src: false) +- [ ] `docs/content/scripts/analytics/posthog.md` (new) + +## Notes + +- No CDN script - uses `import('posthog-js')` dynamically on client +- `posthog-js` must be installed by user as peer dependency +- Added to externals so Nuxt doesn't bundle it +- `src: false` in registry indicates no external script URL +- Types come directly from `posthog-js` package diff --git a/todo/recaptcha.md b/todo/recaptcha.md new file mode 100644 index 00000000..72e0a5b1 --- /dev/null +++ b/todo/recaptcha.md @@ -0,0 +1,291 @@ +# Google reCAPTCHA Implementation Plan + +## Research Summary + +**NPM Package**: No official browser package from Google. Load directly from Google's CDN. + +**Script URL**: `https://www.google.com/recaptcha/api.js` (v3) or `https://www.google.com/recaptcha/enterprise.js` (Enterprise) + +**Scope**: v3 only (score-based, invisible). No v2 checkbox/invisible support - simplifies implementation, no component needed. + +## Implementation + +### 1. Registry Script (`src/runtime/registry/google-recaptcha.ts`) + +```ts +import { withQuery } from 'ufo' +import { useRegistryScript } from '../utils' +import { object, string, optional, boolean } from '#nuxt-scripts-validator' +import type { RegistryScriptInput } from '#nuxt-scripts/types' + +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-specific (same shape, different namespace) + 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('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 + : () => { + // Queue pattern for deferred ready callbacks + 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) +} +``` + +### 2. Types (`src/runtime/types.ts`) + +```ts +import type { GoogleRecaptchaInput } from './registry/google-recaptcha' + +// in ScriptRegistry interface +googleRecaptcha?: GoogleRecaptchaInput +``` + +### 3. Registry Entry (`src/registry.ts`) + +```ts +{ + label: 'Google reCAPTCHA', + category: 'utility', + scriptBundling: (options?: GoogleRecaptchaInput) => { + const baseUrl = options?.recaptchaNet + ? 'https://www.recaptcha.net/recaptcha' + : 'https://www.google.com/recaptcha' + return `${baseUrl}/${options?.enterprise ? 'enterprise.js' : 'api.js'}` + }, + logo: `...`, // reCAPTCHA logo + import: { + name: 'useScriptGoogleRecaptcha', + from: await resolve('./runtime/registry/google-recaptcha'), + }, +} +``` + +### 4. Documentation (`docs/content/scripts/utility/google-recaptcha.md`) + +```md +--- +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. +:: + +### Nuxt Config Setup + +::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' + } + } + } + } +}) +\`\`\` + +:: + +#### With Environment Variables + +\`\`\`ts [nuxt.config.ts] +export default defineNuxtConfig({ + scripts: { + registry: { + googleRecaptcha: true, + } + }, + runtimeConfig: { + public: { + scripts: { + googleRecaptcha: { + siteKey: '', // NUXT_PUBLIC_SCRIPTS_GOOGLE_RECAPTCHA_SITE_KEY + }, + }, + }, + }, +}) +\`\`\` + +## useScriptGoogleRecaptcha + +\`\`\`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 + +\`\`\`ts +export const GoogleRecaptchaOptions = object({ + siteKey: string(), + enterprise: optional(boolean()), + recaptchaNet: optional(boolean()), + hl: optional(string()), +}) +\`\`\` + +## Example + +Using reCAPTCHA v3 to protect a form submission. + +::code-group + +\`\`\`vue [ContactForm.vue] + + + +\`\`\` + +:: + +## 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. +

+\`\`\` +``` + +### 5. Testing + +E2E test in `test/fixtures/basic/pages/recaptcha.vue`: +- Load script with siteKey +- Verify `grecaptcha.ready()` fires +- Verify `grecaptcha.execute()` returns promise + +## Files to Create/Modify + +- [ ] `src/runtime/registry/google-recaptcha.ts` (new) +- [ ] `src/runtime/types.ts` (add import + ScriptRegistry) +- [ ] `src/registry.ts` (add entry with scriptBundling) +- [ ] `docs/content/scripts/utility/google-recaptcha.md` (new) diff --git a/todo/tiktok-pixel.md b/todo/tiktok-pixel.md new file mode 100644 index 00000000..7222298a --- /dev/null +++ b/todo/tiktok-pixel.md @@ -0,0 +1,332 @@ +# TikTok Pixel Implementation Plan + +## Research Summary + +**NPM Package**: No official TikTok npm package. Load directly from TikTok's CDN. + +**Script URL**: `https://analytics.tiktok.com/i18n/pixel/events.js` + +**Pattern**: Queue-based like Meta Pixel. Simplified from TikTok's full SDK snippet. + +## Implementation + +### 1. Registry Script (`src/runtime/registry/tiktok-pixel.ts`) + +```ts +import { useRegistryScript } from '../utils' +import { object, string, optional, boolean } from '#nuxt-scripts-validator' +import type { RegistryScriptInput } from '#nuxt-scripts/types' + +type StandardEvents = + | 'ViewContent' + | 'ClickButton' + | 'Search' + | 'AddToWishlist' + | 'AddToCart' + | 'InitiateCheckout' + | 'AddPaymentInfo' + | 'CompletePayment' + | 'PlaceAnOrder' + | 'Contact' + | 'Download' + | 'SubmitForm' + | 'CompleteRegistration' + | 'Subscribe' + +interface EventProperties { + content_id?: string + content_type?: string + content_name?: string + contents?: Array<{ content_id: string, content_type?: string, content_name?: string, price?: number, quantity?: number }> + currency?: string + value?: number + description?: string + query?: string + [key: string]: any +} + +interface IdentifyProperties { + email?: string + phone_number?: string + external_id?: string +} + +type TtqFns = + & ((cmd: 'track', event: StandardEvents | string, properties?: EventProperties) => void) + & ((cmd: 'page') => void) + & ((cmd: 'identify', properties: IdentifyProperties) => void) + & ((cmd: string, ...args: any[]) => void) + +export interface TikTokPixelApi { + ttq: TtqFns & { + push: TtqFns + loaded: boolean + queue: any[] + } +} + +declare global { + interface Window extends TikTokPixelApi {} +} + +export const TikTokPixelOptions = object({ + id: string(), + trackPageView: optional(boolean()), // default true +}) + +export type TikTokPixelInput = RegistryScriptInput + +export function useScriptTikTokPixel(_options?: TikTokPixelInput) { + return useRegistryScript('tiktokPixel', options => ({ + scriptInput: { + src: 'https://analytics.tiktok.com/i18n/pixel/events.js', + crossorigin: false, + }, + schema: import.meta.dev ? TikTokPixelOptions : undefined, + scriptOptions: { + use() { + return { ttq: window.ttq } + }, + }, + clientInit: import.meta.server + ? undefined + : () => { + const ttq: TikTokPixelApi['ttq'] = window.ttq = function (...params: any[]) { + // @ts-expect-error untyped + if (ttq.callMethod) { + // @ts-expect-error untyped + ttq.callMethod(...params) + } + else { + ttq.queue.push(params) + } + } as any + ttq.push = ttq + ttq.loaded = true + ttq.queue = [] + ttq('init', options?.id) + if (options?.trackPageView !== false) { + ttq('page') + } + }, + }), _options) +} +``` + +### 2. Types (`src/runtime/types.ts`) + +```ts +import type { TikTokPixelInput } from './registry/tiktok-pixel' + +// in ScriptRegistry interface +tiktokPixel?: TikTokPixelInput +``` + +### 3. Registry Entry (`src/registry.ts`) + +```ts +{ + label: 'TikTok Pixel', + src: 'https://analytics.tiktok.com/i18n/pixel/events.js', + category: 'tracking', + logo: `...`, + import: { + name: 'useScriptTikTokPixel', + from: await resolve('./runtime/registry/tiktok-pixel'), + }, +} +``` + +### 4. Documentation (`docs/content/scripts/tracking/tiktok-pixel.md`) + +```md +--- +title: TikTok Pixel +description: Use TikTok Pixel in your Nuxt app. +links: +- label: Source + icon: i-simple-icons-github + to: https://github.com/nuxt/scripts/blob/main/src/runtime/registry/tiktok-pixel.ts + size: xs +--- + +[TikTok Pixel](https://ads.tiktok.com/help/article/tiktok-pixel) lets you measure, optimize and build audiences for your TikTok ad campaigns. + +Nuxt Scripts provides a registry script composable `useScriptTikTokPixel` to easily integrate TikTok Pixel in your Nuxt app. + +### Nuxt Config Setup + +::code-group + +\`\`\`ts [Always enabled] +export default defineNuxtConfig({ + scripts: { + registry: { + tiktokPixel: { + id: 'YOUR_PIXEL_ID' + } + } + } +}) +\`\`\` + +\`\`\`ts [Production only] +export default defineNuxtConfig({ + $production: { + scripts: { + registry: { + tiktokPixel: { + id: 'YOUR_PIXEL_ID' + } + } + } + } +}) +\`\`\` + +:: + +#### With Environment Variables + +\`\`\`ts [nuxt.config.ts] +export default defineNuxtConfig({ + scripts: { + registry: { + tiktokPixel: true, + } + }, + runtimeConfig: { + public: { + scripts: { + tiktokPixel: { + id: '', // NUXT_PUBLIC_SCRIPTS_TIKTOK_PIXEL_ID + }, + }, + }, + }, +}) +\`\`\` + +## useScriptTikTokPixel + +\`\`\`ts +const { proxy } = useScriptTikTokPixel({ + id: 'YOUR_PIXEL_ID' +}) + +// Track an event +proxy.ttq('track', 'ViewContent', { + content_id: '123', + content_name: 'Product Name', + value: 99.99, + currency: 'USD' +}) +\`\`\` + +Please follow the [Registry Scripts](/docs/guides/registry-scripts) guide to learn more about advanced usage. + +### TikTokPixelApi + +\`\`\`ts +export interface TikTokPixelApi { + ttq: TtqFns & { + push: TtqFns + loaded: boolean + queue: any[] + } +} + +type TtqFns = + & ((cmd: 'track', event: StandardEvents | string, properties?: EventProperties) => void) + & ((cmd: 'page') => void) + & ((cmd: 'identify', properties: IdentifyProperties) => void) + & ((cmd: string, ...args: any[]) => void) + +type StandardEvents = + | 'ViewContent' | 'ClickButton' | 'Search' | 'AddToWishlist' + | 'AddToCart' | 'InitiateCheckout' | 'AddPaymentInfo' | 'CompletePayment' + | 'PlaceAnOrder' | 'Contact' | 'Download' | 'SubmitForm' + | 'CompleteRegistration' | 'Subscribe' +\`\`\` + +### Config Schema + +\`\`\`ts +export const TikTokPixelOptions = object({ + id: string(), + trackPageView: optional(boolean()), // default: true +}) +\`\`\` + +## Example + +Using TikTok Pixel to track a purchase event. + +::code-group + +\`\`\`vue [PurchaseButton.vue] + + + +\`\`\` + +:: + +## Identifying Users + +You can identify users for advanced matching: + +\`\`\`ts +const { proxy } = useScriptTikTokPixel() + +proxy.ttq('identify', { + email: 'user@example.com', + phone_number: '+1234567890' +}) +\`\`\` + +## Disabling Auto Page View + +By default, TikTok Pixel tracks page views automatically. To disable: + +\`\`\`ts +export default defineNuxtConfig({ + scripts: { + registry: { + tiktokPixel: { + id: 'YOUR_PIXEL_ID', + trackPageView: false + } + } + } +}) +\`\`\` +``` + +### 5. Testing + +E2E test: +- Page with TikTok Pixel loaded +- Verify `ttq.track()` queues properly + +## Files to Create/Modify + +- [ ] `src/runtime/registry/tiktok-pixel.ts` (new) +- [ ] `src/runtime/types.ts` (add import + ScriptRegistry) +- [ ] `src/registry.ts` (add entry) +- [ ] `docs/content/scripts/tracking/tiktok-pixel.md` (new) From a16be2e7a3c27c3bc2ba8514e2b80b4df3d7a50c Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Sat, 20 Dec 2025 16:38:33 +1100 Subject: [PATCH 4/6] docs(posthog): fix feature flags example to use onLoaded --- docs/content/scripts/analytics/posthog.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/content/scripts/analytics/posthog.md b/docs/content/scripts/analytics/posthog.md index c281acbb..9dd3ad7e 100644 --- a/docs/content/scripts/analytics/posthog.md +++ b/docs/content/scripts/analytics/posthog.md @@ -160,16 +160,20 @@ export default defineNuxtConfig({ ## Feature Flags +Feature flag methods return values, so you need to wait for PostHog to load first: + ```ts -const { proxy } = useScriptPostHog() +const { onLoaded } = useScriptPostHog() -// Check a feature flag -if (proxy.posthog.isFeatureEnabled('new-dashboard')) { - // Show new dashboard -} +onLoaded(({ posthog }) => { + // Check a feature flag + if (posthog.isFeatureEnabled('new-dashboard')) { + // Show new dashboard + } -// Get flag payload -const payload = proxy.posthog.getFeatureFlagPayload('experiment-config') + // Get flag payload + const payload = posthog.getFeatureFlagPayload('experiment-config') +}) ``` ## Disabling Session Recording From 7043fd34045c9fc36b78f63f219ed7c216510b67 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Sat, 20 Dec 2025 16:41:27 +1100 Subject: [PATCH 5/6] feat(playground): add PostHog demo page --- playground/pages/index.vue | 1 + .../third-parties/posthog/nuxt-scripts.vue | 46 +++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 playground/pages/third-parties/posthog/nuxt-scripts.vue diff --git a/playground/pages/index.vue b/playground/pages/index.vue index 6deb1828..3d3cc920 100644 --- a/playground/pages/index.vue +++ b/playground/pages/index.vue @@ -20,6 +20,7 @@ function getPlaygroundPath(script: any): string | null { 'cloudflare-web-analytics': '/third-parties/cloudflare-web-analytics/nuxt-scripts', 'fathom-analytics': '/third-parties/fathom-analytics', 'plausible-analytics': '/third-parties/plausible-analytics', + 'posthog': '/third-parties/posthog/nuxt-scripts', 'matomo-analytics': '/third-parties/matomo-analytics/nuxt-scripts', 'rybbit-analytics': '/third-parties/rybbit-analytics', 'databuddy-analytics': '/third-parties/databuddy-analytics', diff --git a/playground/pages/third-parties/posthog/nuxt-scripts.vue b/playground/pages/third-parties/posthog/nuxt-scripts.vue new file mode 100644 index 00000000..5fb8b0ab --- /dev/null +++ b/playground/pages/third-parties/posthog/nuxt-scripts.vue @@ -0,0 +1,46 @@ + + + From 54440eec0420b329cc0d7a96832386617d4fc6f4 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Sat, 20 Dec 2025 16:52:01 +1100 Subject: [PATCH 6/6] fix: remove console.log --- playground/pages/third-parties/posthog/nuxt-scripts.vue | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/playground/pages/third-parties/posthog/nuxt-scripts.vue b/playground/pages/third-parties/posthog/nuxt-scripts.vue index 5fb8b0ab..24bc4b2d 100644 --- a/playground/pages/third-parties/posthog/nuxt-scripts.vue +++ b/playground/pages/third-parties/posthog/nuxt-scripts.vue @@ -5,7 +5,7 @@ useHead({ title: 'PostHog', }) -const { status, proxy, onLoaded } = useScriptPostHog({ +const { status, proxy } = useScriptPostHog({ apiKey: 'phc_YOUR_API_KEY', }) @@ -21,10 +21,6 @@ function identifyUser() { name: 'Test User', }) } - -onLoaded(({ posthog }) => { - console.log('PostHog loaded:', posthog) -})