Skip to content

Commit ada747d

Browse files
authored
fix(google-analytics, google-tag-manager): per-region consent defaults (#739)
1 parent 4b480b0 commit ada747d

13 files changed

Lines changed: 318 additions & 22 deletions

File tree

‎docs/content/scripts/google-analytics.md‎

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,13 @@ proxy.gtag('event', 'page_view')
3030

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

33-
###Consent Mode
33+
## Consent Mode
3434

35-
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:
35+
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.
36+
37+
::callout{icon="i-heroicons-play"to="https://stackblitz.com/github/nuxt/scripts/tree/main/examples/regional-consent"target="_blank"}
38+
Try the live [Regional Consent Example](https://stackblitz.com/github/nuxt/scripts/tree/main/examples/regional-consent) on [StackBlitz](https://stackblitz.com).
39+
::
3640

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

6973
`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.
7074

71-
### Customer/Consumer ID Tracking
75+
### Per-region defaults
76+
77+
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.
78+
79+
```vue
80+
<script setup lang="ts">
81+
useScriptGoogleAnalytics({
82+
id: 'G-XXXXXXXX',
83+
defaultConsent: [
84+
{
85+
// EEA + UK + Switzerland — start denied, wait 500ms for the user's choice
86+
ad_storage: 'denied',
87+
ad_user_data: 'denied',
88+
ad_personalization: 'denied',
89+
analytics_storage: 'denied',
90+
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'],
91+
wait_for_update: 500,
92+
},
93+
{
94+
// Everywhere else — granted by default
95+
ad_storage: 'granted',
96+
ad_user_data: 'granted',
97+
ad_personalization: 'granted',
98+
analytics_storage: 'granted',
99+
},
100+
],
101+
})
102+
</script>
103+
```
104+
105+
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.
106+
107+
## Customer/Consumer ID Tracking
72108

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

‎docs/content/scripts/google-tag-manager.md‎

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,10 @@ useScriptEventPage(({ title, path }) => {
4444

4545
## Consent Mode
4646

47-
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.
47+
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`.
4848

4949
::callout{icon="i-heroicons-play"to="https://stackblitz.com/github/nuxt/scripts/tree/main/examples/cookie-consent"target="_blank"}
50-
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).
50+
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).
5151
::
5252

5353
### Consent Mode v2 Signals
@@ -97,7 +97,39 @@ useScriptEventPage(({ title, path }) => {
9797
</script>
9898
```
9999

100-
`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`).
100+
### Per-region defaults
101+
102+
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.
103+
104+
```vue
105+
<script setup lang="ts">
106+
useScriptGoogleTagManager({
107+
id: 'GTM-XXXXXX',
108+
defaultConsent: [
109+
{
110+
// EEA + UK + Switzerland — start denied, wait 500ms for the user's choice
111+
ad_storage: 'denied',
112+
ad_user_data: 'denied',
113+
ad_personalization: 'denied',
114+
analytics_storage: 'denied',
115+
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'],
116+
wait_for_update: 500,
117+
},
118+
{
119+
// Everywhere else — granted by default
120+
ad_storage: 'granted',
121+
ad_user_data: 'granted',
122+
ad_personalization: 'granted',
123+
analytics_storage: 'granted',
124+
},
125+
],
126+
})
127+
</script>
128+
```
129+
130+
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.
131+
132+
`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`).
101133

102134
::script-types
103135
::

‎examples/regional-consent/app.vue‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<script lang="ts" setup>
2+
useScriptGoogleTagManager({
3+
id: 'GTM-DEMO123',
4+
defaultConsent: [
5+
{
6+
// EEA + UK + Switzerland — denied by default, gtag waits 500ms for an update.
7+
ad_storage: 'denied',
8+
ad_user_data: 'denied',
9+
ad_personalization: 'denied',
10+
analytics_storage: 'denied',
11+
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'],
12+
wait_for_update: 500,
13+
},
14+
{
15+
// Unscoped fallback — everywhere else, granted by default.
16+
ad_storage: 'granted',
17+
ad_user_data: 'granted',
18+
ad_personalization: 'granted',
19+
analytics_storage: 'granted',
20+
},
21+
],
22+
})
23+
</script>
24+
25+
<template>
26+
<UApp>
27+
<divclass="min-h-screen bg-gray-50 dark:bg-gray-900">
28+
<divclass="max-w-2xl mx-auto p-8">
29+
<h1class="text-3xl font-bold mb-4">
30+
Region-specific Consent Mode example
31+
</h1>
32+
<pclass="text-gray-600 dark:text-gray-400 mb-8">
33+
Pass an array to <code>defaultConsent</code> to fire multiple
34+
<code>['consent','default', state]</code> pushes — one per region group.
35+
gtag picks the most specific region match at runtime.
36+
</p>
37+
38+
<UCard>
39+
<template #header>
40+
<h2class="font-semibold">
41+
How it works
42+
</h2>
43+
</template>
44+
<ulclass="list-disc list-inside space-y-2 text-sm">
45+
<li>The first entry targets the EEA + UK + Switzerland via <code>region</code> and starts denied</li>
46+
<li>The second entry has no <code>region</code> and is the unscoped global fallback (granted)</li>
47+
<li>Order in the array does not matter — Google's "more specific region wins" rule is enforced by gtag at runtime</li>
48+
<li>Open DevTools → Network &amp; <code>window.dataLayer</code> to see two consent-default pushes before <code>gtm.js</code></li>
49+
</ul>
50+
</UCard>
51+
</div>
52+
</div>
53+
</UApp>
54+
</template>
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
@import"tailwindcss";
2+
@import"@nuxt/ui";
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
exportdefaultdefineNuxtConfig({
2+
modules: ['@nuxt/scripts','@nuxt/ui'],
3+
4+
devtools: {enabled: true},
5+
css: ['~/assets/css/main.css'],
6+
compatibilityDate: '2025-01-01',
7+
})
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"name": "nuxt-scripts-regional-consent-example",
3+
"type": "module",
4+
"private": true,
5+
"scripts": {
6+
"dev": "nuxt dev",
7+
"build": "nuxt build",
8+
"preview": "nuxt preview"
9+
},
10+
"dependencies": {
11+
"@nuxt/scripts": "latest",
12+
"@nuxt/ui": "^4.7.1",
13+
"nuxt": "^4.4.4",
14+
"tailwindcss": "^4.2.4",
15+
"vue": "^3.5.33"
16+
}
17+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"extends": "./.nuxt/tsconfig.json"
3+
}

‎packages/script/src/registry-types.json‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,7 @@
260260
{
261261
"name": "GoogleAnalyticsOptions",
262262
"kind": "const",
263-
"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})"
263+
"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})"
264264
},
265265
{
266266
"name": "GoogleAnalyticsConsent",
@@ -503,7 +503,7 @@
503503
{
504504
"name": "GoogleTagManagerOptions",
505505
"kind": "const",
506-
"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})"
506+
"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})"
507507
},
508508
{
509509
"name": "GoogleTagManagerConsent",
@@ -1443,9 +1443,9 @@
14431443
},
14441444
{
14451445
"name": "defaultConsent",
1446-
"type": "unknown",
1446+
"type": "unknown | unknown[]",
14471447
"required": false,
1448-
"description": "Default GCMv2 consent state fired as `gtag('consent', 'default', ...)` before `gtag('js', ...)`."
1448+
"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)."
14491449
}
14501450
],
14511451
"GoogleMapsOptions": [
@@ -1632,9 +1632,9 @@
16321632
},
16331633
{
16341634
"name": "defaultConsent",
1635-
"type": "Record<string, string | number>",
1635+
"type": "unknown | unknown[]",
16361636
"required": false,
1637-
"description": "Default consent settings for GTM"
1637+
"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)."
16381638
}
16391639
],
16401640
"HotjarOptions": [

‎packages/script/src/runtime/registry/google-analytics.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,13 @@ export function useScriptGoogleAnalytics<T extends GoogleAnalyticsApi>(_options?
141141
// eslint-disable-next-line prefer-rest-params
142142
w[dataLayerName].push(arguments)
143143
}
144-
if(options?.defaultConsent)
145-
w.gtag('consent','default',options.defaultConsent)
144+
if(options?.defaultConsent){
145+
constentries=Array.isArray(options.defaultConsent)
146+
? options.defaultConsent
147+
: [options.defaultConsent]
148+
for(constentryofentries)
149+
w.gtag('consent','default',entry)
150+
}
146151
// eslint-disable-next-line ts/ban-ts-comment
147152
// @ts-ignore
148153
_options?.onBeforeGtagStart?.(w.gtag)

‎packages/script/src/runtime/registry/google-tag-manager.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -141,12 +141,19 @@ export function useScriptGoogleTagManager<T extends GoogleTagManagerApi>(
141141
// Assign gtag to window for global access
142142
(windowasany).gtag=gtag
143143

144+
// Set consent defaults before any user-callback gtag/dataLayer pushes,
145+
// so custom events in onBeforeGtmStart honor the configured consent state.
146+
if(opts.defaultConsent){
147+
constentries=Array.isArray(opts.defaultConsent)
148+
? opts.defaultConsent
149+
: [opts.defaultConsent]
150+
for(constentryofentries)
151+
gtag('consent','default',entry)
152+
}
153+
144154
// Allow custom initialization
145155
options?.onBeforeGtmStart?.(gtag)
146156

147-
if(opts.defaultConsent)
148-
gtag('consent','default',opts.defaultConsent)
149-
150157
// Push the standard GTM initialization event
151158
;(windowasany)[dataLayerName].push({
152159
'gtm.start': Date.now(),

0 commit comments

Comments
 (0)