Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 39 additions & 3 deletions docs/content/scripts/google-analytics.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,13 @@ proxy.gtag('event', 'page_view')

The proxy exposes the `gtag` and `dataLayer` properties, and you should use them following Google Analytics best practices.

### Consent Mode
## Consent Mode

Google Analytics natively consumes [GCMv2 consent state](https://developers.google.com/tag-platform/security/guides/consent). Set the default with `defaultConsent` (fires `gtag('consent', 'default', ...)`{lang="ts"} before `gtag('js', ...)`{lang="ts"}) and call `consent.update()`{lang="ts"} at runtime:
Google Analytics natively consumes [GCMv2 consent state](https://developers.google.com/tag-platform/security/guides/consent). Set the default with `defaultConsent` (fires `gtag('consent', 'default', state)`{lang="ts"} before `gtag('js', ...)`{lang="ts"}) and call `consent.update()`{lang="ts"} at runtime to flip categories.

::callout{icon="i-heroicons-play" to="https://stackblitz.com/github/nuxt/scripts/tree/main/examples/regional-consent" target="_blank"}
Try the live [Regional Consent Example](https://stackblitz.com/github/nuxt/scripts/tree/main/examples/regional-consent) on [StackBlitz](https://stackblitz.com).
::

```vue
<script setup lang="ts">
Expand Down Expand Up @@ -68,7 +72,39 @@ function savePreferences(choices: { analytics: boolean, marketing: boolean }) {

`consent.update()`{lang="ts"} accepts any `Partial<ConsentState>`{lang="ts"}; missing categories stay at their current value. For pre-`gtag('js')`{lang="ts"} setup beyond consent defaults, `onBeforeGtagStart` remains available as a general escape hatch.

### Customer/Consumer ID Tracking
### Per-region defaults

Pass an array to `defaultConsent` to fire one `gtag('consent','default', state)`{lang="ts"} per entry. This matches Google's [region-specific consent pattern](https://developers.google.com/tag-platform/security/guides/consent?consentmode=advanced#region-specific-behavior): more specific regions (e.g. `US-CA`) override broader ones (`US`); an entry with no `region` is the unscoped global fallback.

```vue
<script setup lang="ts">
useScriptGoogleAnalytics({
id: 'G-XXXXXXXX',
defaultConsent: [
{
// EEA + UK + Switzerland β€” start denied, wait 500ms for the user's choice
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
analytics_storage: 'denied',
region: ['AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR', 'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE', 'GB', 'IS', 'LI', 'NO', 'CH'],
wait_for_update: 500,
},
{
// Everywhere else β€” granted by default
ad_storage: 'granted',
ad_user_data: 'granted',
ad_personalization: 'granted',
analytics_storage: 'granted',
},
],
})
</script>
```

The module forwards each entry verbatim, in input order. Precedence between region-scoped and unscoped defaults is enforced by gtag at runtime, not by ordering.

## Customer/Consumer ID Tracking

For e-commerce or multi-tenant applications where you need to track customer-specific analytics alongside your main tracking:

Expand Down
38 changes: 35 additions & 3 deletions docs/content/scripts/google-tag-manager.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,10 @@ useScriptEventPage(({ title, path }) => {

## Consent Mode

Google Tag Manager natively consumes [GCMv2 consent state](https://developers.google.com/tag-platform/security/guides/consent?consentmode=basic). Set the default with `defaultConsent` (pushes `['consent','default', state]` onto the dataLayer before the `gtm.js` event) and call `consent.update()`{lang="ts"} at runtime.
Google Tag Manager natively consumes [GCMv2 consent state](https://developers.google.com/tag-platform/security/guides/consent?consentmode=basic). Set the default with `defaultConsent` (pushes `['consent','default', state]` onto the dataLayer before the `gtm.js` event) and call `consent.update()`{lang="ts"} at runtime. Pass an **array** to `defaultConsent` to fire multiple defaults, for example [region-specific defaults](https://developers.google.com/tag-platform/security/guides/consent?consentmode=advanced#region-specific-behavior) where each entry targets different countries via `region`.

::callout{icon="i-heroicons-play" to="https://stackblitz.com/github/nuxt/scripts/tree/main/examples/cookie-consent" target="_blank"}
Try the live [Cookie Consent Example](https://stackblitz.com/github/nuxt/scripts/tree/main/examples/cookie-consent) or [Granular Consent Example](https://stackblitz.com/github/nuxt/scripts/tree/main/examples/granular-consent) on [StackBlitz](https://stackblitz.com).
Try the live [Cookie Consent Example](https://stackblitz.com/github/nuxt/scripts/tree/main/examples/cookie-consent), [Granular Consent Example](https://stackblitz.com/github/nuxt/scripts/tree/main/examples/granular-consent), or [Regional Consent Example](https://stackblitz.com/github/nuxt/scripts/tree/main/examples/regional-consent) on [StackBlitz](https://stackblitz.com).
::

### Consent Mode v2 Signals
Expand Down Expand Up @@ -97,7 +97,39 @@ useScriptEventPage(({ title, path }) => {
</script>
```

`onBeforeGtmStart` remains available as a general escape hatch for any other pre-`gtm.start` setup (only when the GTM ID is passed directly to the composable, not via `nuxt.config`).
### Per-region defaults

Pass an array to `defaultConsent` to fire one `['consent','default', state]` push per entry, in order. This matches Google's [region-specific consent pattern](https://developers.google.com/tag-platform/security/guides/consent?consentmode=advanced#region-specific-behavior): more specific regions (e.g. `US-CA`) override broader ones (`US`); an entry with no `region` is the unscoped global fallback.

```vue
<script setup lang="ts">
useScriptGoogleTagManager({
id: 'GTM-XXXXXX',
defaultConsent: [
{
// EEA + UK + Switzerland β€” start denied, wait 500ms for the user's choice
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
analytics_storage: 'denied',
region: ['AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR', 'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE', 'GB', 'IS', 'LI', 'NO', 'CH'],
wait_for_update: 500,
},
{
// Everywhere else β€” granted by default
ad_storage: 'granted',
ad_user_data: 'granted',
ad_personalization: 'granted',
analytics_storage: 'granted',
},
],
})
</script>
```

The module forwards each entry verbatim, in input order. Precedence between region-scoped and unscoped defaults is enforced by gtag at runtime, not by ordering.

`consent.update()`{lang="ts"} accepts any `Partial<ConsentState>`{lang="ts"}; missing categories stay at their current value. `onBeforeGtmStart` remains available as a general escape hatch for any other pre-`gtm.start` setup (only when the GTM ID is passed directly to the composable, not via `nuxt.config`).

::script-types
::
Expand Down
54 changes: 54 additions & 0 deletions examples/regional-consent/app.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<script lang="ts" setup>
useScriptGoogleTagManager({
id: 'GTM-DEMO123',
defaultConsent: [
{
// EEA + UK + Switzerland β€” denied by default, gtag waits 500ms for an update.
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
analytics_storage: 'denied',
region: ['AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR', 'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE', 'GB', 'IS', 'LI', 'NO', 'CH'],
wait_for_update: 500,
},
{
// Unscoped fallback β€” everywhere else, granted by default.
ad_storage: 'granted',
ad_user_data: 'granted',
ad_personalization: 'granted',
analytics_storage: 'granted',
},
],
})
</script>

<template>
<UApp>
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
<div class="max-w-2xl mx-auto p-8">
<h1 class="text-3xl font-bold mb-4">
Region-specific Consent Mode example
</h1>
<p class="text-gray-600 dark:text-gray-400 mb-8">
Pass an array to <code>defaultConsent</code> to fire multiple
<code>['consent','default', state]</code> pushes β€” one per region group.
gtag picks the most specific region match at runtime.
</p>

<UCard>
<template #header>
<h2 class="font-semibold">
How it works
</h2>
</template>
<ul class="list-disc list-inside space-y-2 text-sm">
<li>The first entry targets the EEA + UK + Switzerland via <code>region</code> and starts denied</li>
<li>The second entry has no <code>region</code> and is the unscoped global fallback (granted)</li>
<li>Order in the array does not matter β€” Google's "more specific region wins" rule is enforced by gtag at runtime</li>
<li>Open DevTools β†’ Network &amp; <code>window.dataLayer</code> to see two consent-default pushes before <code>gtm.js</code></li>
</ul>
</UCard>
</div>
</div>
</UApp>
</template>
2 changes: 2 additions & 0 deletions examples/regional-consent/assets/css/main.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
@import "tailwindcss";
@import "@nuxt/ui";
7 changes: 7 additions & 0 deletions examples/regional-consent/nuxt.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export default defineNuxtConfig({
modules: ['@nuxt/scripts', '@nuxt/ui'],

devtools: { enabled: true },
css: ['~/assets/css/main.css'],
compatibilityDate: '2025-01-01',
})
17 changes: 17 additions & 0 deletions examples/regional-consent/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "nuxt-scripts-regional-consent-example",
"type": "module",
"private": true,
"scripts": {
"dev": "nuxt dev",
"build": "nuxt build",
"preview": "nuxt preview"
},
"dependencies": {
"@nuxt/scripts": "latest",
"@nuxt/ui": "^4.7.1",
"nuxt": "^4.4.4",
"tailwindcss": "^4.2.4",
"vue": "^3.5.33"
}
}
3 changes: 3 additions & 0 deletions examples/regional-consent/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": "./.nuxt/tsconfig.json"
}
12 changes: 6 additions & 6 deletions packages/script/src/registry-types.json
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@
{
"name": "GoogleAnalyticsOptions",
"kind": "const",
"code": "export const GoogleAnalyticsOptions = object({\n /**\n * The GA4 measurement ID.\n * @example 'G-XXXXXXXX'\n * @see https://developers.google.com/analytics/devguides/collection/gtagjs\n */\n id: optional(string()),\n /**\n * Global name for the dataLayer variable.\n * @default 'dataLayer'\n * @see https://developers.google.com/analytics/devguides/collection/gtagjs/setting-up-gtag#rename_the_data_layer\n */\n l: optional(string()),\n /**\n * Default GCMv2 consent state fired as `gtag('consent', 'default', ...)` before `gtag('js', ...)`.\n * @see https://developers.google.com/tag-platform/security/guides/consent\n */\n defaultConsent: optional(gcmConsentState),\n})"
"code": "export const GoogleAnalyticsOptions = object({\n /**\n * The GA4 measurement ID.\n * @example 'G-XXXXXXXX'\n * @see https://developers.google.com/analytics/devguides/collection/gtagjs\n */\n id: optional(string()),\n /**\n * Global name for the dataLayer variable.\n * @default 'dataLayer'\n * @see https://developers.google.com/analytics/devguides/collection/gtagjs/setting-up-gtag#rename_the_data_layer\n */\n l: optional(string()),\n /**\n * Default GCMv2 consent state(s) fired as `gtag('consent', 'default', state)` before\n * `gtag('js', ...)`. Pass an array to fire multiple defaults β€” for example, different\n * defaults per `region` (more specific regions override broader ones at runtime).\n * @see https://developers.google.com/tag-platform/security/guides/consent?consentmode=advanced#region-specific-behavior\n */\n defaultConsent: optional(union([gcmConsentState, array(gcmConsentState)])),\n})"
},
{
"name": "GoogleAnalyticsConsent",
Expand Down Expand Up @@ -503,7 +503,7 @@
{
"name": "GoogleTagManagerOptions",
"kind": "const",
"code": "export const GoogleTagManagerOptions = object({\n /**\n * GTM container ID (format: GTM-XXXXXX)\n * @see https://developers.google.com/tag-platform/tag-manager/web#install-the-container\n */\n id: string(),\n\n /**\n * Optional dataLayer variable name\n * @default 'dataLayer'\n * @see https://developers.google.com/tag-platform/tag-manager/web/datalayer#rename_the_data_layer\n */\n l: optional(string()),\n\n /**\n * Authentication token for environment-specific container versions\n * @see https://support.google.com/tagmanager/answer/6328337\n */\n auth: optional(string()),\n\n /**\n * Preview environment name\n * @see https://support.google.com/tagmanager/answer/6328337\n */\n preview: optional(string()),\n\n /** Forces GTM cookies to take precedence when true */\n cookiesWin: optional(union([boolean(), literal('x')])),\n\n /**\n * Enables debug mode when true\n * @see https://support.google.com/tagmanager/answer/6107056\n */\n debug: optional(union([boolean(), literal('x')])),\n\n /**\n * No Personal Advertising - disables advertising features when true\n * @see https://developers.google.com/tag-platform/tag-manager/templates/consent-apis\n */\n npa: optional(union([boolean(), literal('1')])),\n\n /** Custom dataLayer name (alternative to \"l\" property) */\n dataLayer: optional(string()),\n\n /**\n * Environment name for environment-specific container\n * @see https://support.google.com/tagmanager/answer/6328337\n */\n envName: optional(string()),\n\n /** Referrer policy for analytics requests */\n authReferrerPolicy: optional(string()),\n\n /**\n * Default consent settings for GTM\n * @see https://developers.google.com/tag-platform/tag-manager/templates/consent-apis\n */\n defaultConsent: optional(record(string(), union([string(), number()]))),\n})"
"code": "export const GoogleTagManagerOptions = object({\n /**\n * GTM container ID (format: GTM-XXXXXX)\n * @see https://developers.google.com/tag-platform/tag-manager/web#install-the-container\n */\n id: string(),\n\n /**\n * Optional dataLayer variable name\n * @default 'dataLayer'\n * @see https://developers.google.com/tag-platform/tag-manager/web/datalayer#rename_the_data_layer\n */\n l: optional(string()),\n\n /**\n * Authentication token for environment-specific container versions\n * @see https://support.google.com/tagmanager/answer/6328337\n */\n auth: optional(string()),\n\n /**\n * Preview environment name\n * @see https://support.google.com/tagmanager/answer/6328337\n */\n preview: optional(string()),\n\n /** Forces GTM cookies to take precedence when true */\n cookiesWin: optional(union([boolean(), literal('x')])),\n\n /**\n * Enables debug mode when true\n * @see https://support.google.com/tagmanager/answer/6107056\n */\n debug: optional(union([boolean(), literal('x')])),\n\n /**\n * No Personal Advertising - disables advertising features when true\n * @see https://developers.google.com/tag-platform/tag-manager/templates/consent-apis\n */\n npa: optional(union([boolean(), literal('1')])),\n\n /** Custom dataLayer name (alternative to \"l\" property) */\n dataLayer: optional(string()),\n\n /**\n * Environment name for environment-specific container\n * @see https://support.google.com/tagmanager/answer/6328337\n */\n envName: optional(string()),\n\n /** Referrer policy for analytics requests */\n authReferrerPolicy: optional(string()),\n\n /**\n * Default GCMv2 consent state(s) fired as `['consent','default', state]` onto the dataLayer\n * before the `gtm.js` event. Pass an array to fire multiple defaults β€” for example,\n * different defaults per `region` (more specific regions override broader ones at runtime).\n * @see https://developers.google.com/tag-platform/tag-manager/templates/consent-apis\n * @see https://developers.google.com/tag-platform/security/guides/consent?consentmode=advanced#region-specific-behavior\n */\n defaultConsent: optional(union([gcmConsentState, array(gcmConsentState)])),\n})"
},
{
"name": "GoogleTagManagerConsent",
Expand Down Expand Up @@ -1443,9 +1443,9 @@
},
{
"name": "defaultConsent",
"type": "unknown",
"type": "unknown | unknown[]",
"required": false,
"description": "Default GCMv2 consent state fired as `gtag('consent', 'default', ...)` before `gtag('js', ...)`."
"description": "Default GCMv2 consent state(s) fired as `gtag('consent', 'default', state)` before `gtag('js', ...)`. Pass an array to fire multiple defaults β€” for example, different defaults per `region` (more specific regions override broader ones at runtime)."
}
],
"GoogleMapsOptions": [
Expand Down Expand Up @@ -1632,9 +1632,9 @@
},
{
"name": "defaultConsent",
"type": "Record<string, string | number>",
"type": "unknown | unknown[]",
"required": false,
"description": "Default consent settings for GTM"
"description": "Default GCMv2 consent state(s) fired as `['consent','default', state]` onto the dataLayer before the `gtm.js` event. Pass an array to fire multiple defaults β€” for example, different defaults per `region` (more specific regions override broader ones at runtime)."
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
],
"HotjarOptions": [
Expand Down
9 changes: 7 additions & 2 deletions packages/script/src/runtime/registry/google-analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,13 @@ export function useScriptGoogleAnalytics<T extends GoogleAnalyticsApi>(_options?
// eslint-disable-next-line prefer-rest-params
w[dataLayerName].push(arguments)
}
if (options?.defaultConsent)
w.gtag('consent', 'default', options.defaultConsent)
if (options?.defaultConsent) {
const entries = Array.isArray(options.defaultConsent)
? options.defaultConsent
: [options.defaultConsent]
for (const entry of entries)
w.gtag('consent', 'default', entry)
}
// eslint-disable-next-line ts/ban-ts-comment
// @ts-ignore
_options?.onBeforeGtagStart?.(w.gtag)
Expand Down
13 changes: 10 additions & 3 deletions packages/script/src/runtime/registry/google-tag-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,12 +141,19 @@ export function useScriptGoogleTagManager<T extends GoogleTagManagerApi>(
// Assign gtag to window for global access
(window as any).gtag = gtag

// Set consent defaults before any user-callback gtag/dataLayer pushes,
// so custom events in onBeforeGtmStart honor the configured consent state.
if (opts.defaultConsent) {
const entries = Array.isArray(opts.defaultConsent)
? opts.defaultConsent
: [opts.defaultConsent]
for (const entry of entries)
gtag('consent', 'default', entry)
}

// Allow custom initialization
options?.onBeforeGtmStart?.(gtag)

if (opts.defaultConsent)
gtag('consent', 'default', opts.defaultConsent)

// Push the standard GTM initialization event
;(window as any)[dataLayerName].push({
'gtm.start': Date.now(),
Expand Down
Loading
Loading