Skip to content

Commit 0465940

Browse files
authored
feat: vendor-native consent controls for consent-aware scripts (#712)
1 parent c4bb80c commit 0465940

29 files changed

+1277
-232
lines changed
Lines changed: 169 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,108 +1,232 @@
11
---
22
title: Consent Management
3-
description: Learn how to get user consent before loading scripts.
3+
description: Gate scripts behind user consent and drive vendor-native consent APIs through a typed per-script `consent` object.
44
---
55

66
::callout{icon="i-heroicons-play" to="https://stackblitz.com/github/nuxt/scripts/tree/main/examples/cookie-consent" target="_blank"}
77
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).
88
::
99

10-
## Background
10+
## Two complementary primitives
1111

12-
Many third-party scripts include tracking cookies that require user consent under privacy laws. Nuxt Scripts simplifies this process with the [`useScriptTriggerConsent()`{lang="ts"}](/docs/api/use-script-trigger-consent){lang="ts"} composable, allowing scripts to load only after you receive user consent.
12+
Nuxt Scripts ships two consent primitives that work together:
1313

14-
## Usage
14+
1. **[`useScriptTriggerConsent()`{lang="ts"}](/docs/api/use-script-trigger-consent)**: a binary load gate. The script only starts loading after consent is granted.
15+
2. **Per-script `consent` object** returned from every consent-aware `useScriptX()`{lang="ts"}. A vendor-native, typed API for granting, revoking, or updating consent categories at runtime. Paired with each script's `defaultConsent` option for the initial policy applied *before* the vendor's init call.
1516

16-
The [`useScriptTriggerConsent()`{lang="ts"}](/docs/api/use-script-trigger-consent){lang="ts"} composable offers flexible interaction options suitable for various scenarios.
17+
Each vendor exposes its own consent dialect (Google Consent Mode v2 for GA/GTM/Bing, binary grant/revoke for Meta, three-state for TikTok, `setConsentGiven`/`forgetConsentGiven` for Matomo, `opt_in`/`opt_out` for Mixpanel/PostHog, cookie toggle for Clarity). You wire each explicitly.
1718

18-
See the [API](/docs/api/use-script-trigger-consent) docs for full details on the available options.
19+
## Binary load gate
1920

20-
### Accepting as a Function
21-
22-
The easiest way to make use of [`useScriptTriggerConsent()`{lang="ts"}](/docs/api/use-script-trigger-consent){lang="ts"} is by invoking the `accept` method when user consent is granted.
23-
24-
For an example of how you might lay out your code to handle this, see the following:
21+
The simplest usage matches the classic cookie-banner flow: load the script only after the user clicks accept.
2522

2623
::code-group
2724

2825
```ts [utils/cookie.ts]
29-
export const agreedToCookiesScriptConsent = useScriptTriggerConsent()
26+
export const scriptsConsent = useScriptTriggerConsent()
3027
```
3128

3229
```vue [app.vue]
3330
<script setup lang="ts">
34-
import { agreedToCookiesScriptConsent } from '#imports'
31+
import { scriptsConsent } from '#imports'
3532
3633
useScript('https://www.google-analytics.com/analytics.js', {
37-
trigger: agreedToCookiesScriptConsent
34+
trigger: scriptsConsent,
3835
})
3936
</script>
4037
```
4138

4239
```vue [components/cookie-banner.vue]
4340
<script setup lang="ts">
44-
import { agreedToCookiesScriptConsent } from '#imports'
41+
import { scriptsConsent } from '#imports'
4542
</script>
4643
4744
<template>
48-
<button @click="agreedToCookiesScriptConsent.accept()">
45+
<button @click="scriptsConsent.accept()">
4946
Accept Cookies
5047
</button>
5148
</template>
5249
```
5350

5451
::
5552

56-
### Accepting as a resolvable boolean
53+
### Reactive source
5754

58-
Alternatively, you can pass a reactive reference to the consent state to the [`useScriptTriggerConsent()`{lang="ts"}](/docs/api/use-script-trigger-consent){lang="ts"} composable. This will automatically load the script when the consent state is `true`.
55+
Pass a `Ref<boolean>`{lang="html"} if an external store owns the state.
5956

6057
```ts
6158
const agreedToCookies = ref(false)
62-
useScript('https://www.google-analytics.com/analytics.js', {
63-
trigger: useScriptTriggerConsent({
64-
consent: agreedToCookies
65-
})
66-
})
59+
const consent = useScriptTriggerConsent({ consent: agreedToCookies })
6760
```
6861

69-
### Revoking Consent
62+
### Revoking
7063

71-
You can revoke consent after it has been granted using the `revoke` method. Use the reactive `consented` ref to track the current consent state.
72-
73-
```vue [components/cookie-banner.vue]
74-
<script setup lang="ts">
75-
import { agreedToCookiesScriptConsent } from '#imports'
76-
</script>
64+
Consent revocation flips the reactive `consented` ref. Once the load-gate promise has resolved the script has loaded; watch `consented` if you need to tear down on revoke.
7765

66+
```vue
7867
<template>
79-
<div v-if="agreedToCookiesScriptConsent.consented.value">
80-
<p>Cookies accepted</p>
81-
<button @click="agreedToCookiesScriptConsent.revoke()">
68+
<div v-if="scriptsConsent.consented.value">
69+
<button @click="scriptsConsent.revoke()">
8270
Revoke Consent
8371
</button>
8472
</div>
85-
<button v-else @click="agreedToCookiesScriptConsent.accept()">
73+
<button v-else @click="scriptsConsent.accept()">
8674
Accept Cookies
8775
</button>
8876
</template>
8977
```
9078

91-
### Delaying script load after consent
79+
### Delaying the load after consent
80+
81+
```ts
82+
const consent = useScriptTriggerConsent({
83+
consent: agreedToCookies,
84+
postConsentTrigger: () => new Promise<void>(resolve =>
85+
setTimeout(resolve, 3000),
86+
),
87+
})
88+
```
9289

93-
There may be instances where you want to trigger the script load after a certain event, only if the user has consented.
90+
## Per-script consent API
9491

95-
For this you can use the `postConsentTrigger`, which shares the same API as `trigger` from the [`useScript()`{lang="ts"}](/docs/api/use-script){lang="ts"} composable.
92+
Every consent-aware `useScriptX()`{lang="ts"} returns a `consent` object typed to the vendor's native API. Combine it with `defaultConsent` for the initial policy (applied in `clientInit` before the vendor fires its first call) and call `consent.*` from your cookie banner to update.
9693

9794
```ts
98-
const agreedToCookies = ref(false)
99-
useScript('https://www.google-analytics.com/analytics.js', {
100-
trigger: useScriptTriggerConsent({
101-
consent: agreedToCookies,
102-
// load 3 seconds after consent is granted
103-
postConsentTrigger: () => new Promise<void>(resolve =>
104-
setTimeout(resolve, 3000),
105-
),
95+
const { consent } = useScriptGoogleAnalytics({
96+
id: 'G-XXXXXXXX',
97+
defaultConsent: { ad_storage: 'denied', analytics_storage: 'denied' },
98+
})
99+
100+
function onAcceptAll() {
101+
consent.update({
102+
ad_storage: 'granted',
103+
ad_user_data: 'granted',
104+
ad_personalization: 'granted',
105+
analytics_storage: 'granted',
106106
})
107+
}
108+
```
109+
110+
### Per-vendor surface
111+
112+
| Script | `defaultConsent` | Runtime `consent.*` |
113+
|---|---|---|
114+
| Google Analytics | `Partial<ConsentState>`{lang="html"} (GCMv2) | `consent.update(state)`{lang="ts"} |
115+
| Google Tag Manager | `Partial<ConsentState>`{lang="html"} (GCMv2) | `consent.update(state)`{lang="ts"} |
116+
| Bing UET | `{ ad_storage }` | `consent.update({ ad_storage })`{lang="ts"} |
117+
| Meta Pixel | `'granted' \| 'denied'` | `consent.grant()`{lang="ts"} / `consent.revoke()`{lang="ts"} |
118+
| TikTok Pixel | `'granted' \| 'denied' \| 'hold'` | `consent.grant()`{lang="ts"} / `consent.revoke()`{lang="ts"} / `consent.hold()`{lang="ts"} |
119+
| Matomo | `'required' \| 'given' \| 'not-required'` | `consent.give()`{lang="ts"} / `consent.forget()`{lang="ts"} *(requires `defaultConsent: 'required'` or `'given'`)* |
120+
| Mixpanel | `'opt-in' \| 'opt-out'` | `consent.optIn()`{lang="ts"} / `consent.optOut()`{lang="ts"} |
121+
| PostHog | `'opt-in' \| 'opt-out'` | `consent.optIn()`{lang="ts"} / `consent.optOut()`{lang="ts"} |
122+
| Clarity | `boolean \| Record<string, string>`{lang="html"} | `consent.set(value)`{lang="ts"} |
123+
124+
See each script's registry page for notes on lossy projections and vendor caveats.
125+
126+
### Fanning out to multiple scripts
127+
128+
When one cookie banner drives several vendors, wire them explicitly in your accept handler. No magic, fully typed, no lossy remapping:
129+
130+
```ts
131+
const ga = useScriptGoogleAnalytics({ id: 'G-XXX', defaultConsent: { ad_storage: 'denied', analytics_storage: 'denied' } })
132+
const meta = useScriptMetaPixel({ id: '123', defaultConsent: 'denied' })
133+
const matomo = useScriptMatomoAnalytics({ cloudId: 'foo.matomo.cloud', defaultConsent: 'required' })
134+
135+
function onAcceptAll() {
136+
ga.consent.update({
137+
ad_storage: 'granted',
138+
ad_user_data: 'granted',
139+
ad_personalization: 'granted',
140+
analytics_storage: 'granted',
141+
})
142+
meta.consent.grant()
143+
matomo.consent.give()
144+
}
145+
146+
function onDeclineAll() {
147+
meta.consent.revoke()
148+
matomo.consent.forget()
149+
}
150+
```
151+
152+
### Granular categories
153+
154+
If users can toggle categories individually (analytics, marketing, functional), the same pattern applies; each script gets only the categories it understands:
155+
156+
```ts
157+
function savePreferences(choices: { analytics: boolean, marketing: boolean }) {
158+
ga.consent.update({
159+
analytics_storage: choices.analytics ? 'granted' : 'denied',
160+
ad_storage: choices.marketing ? 'granted' : 'denied',
161+
ad_user_data: choices.marketing ? 'granted' : 'denied',
162+
ad_personalization: choices.marketing ? 'granted' : 'denied',
163+
})
164+
if (choices.marketing)
165+
meta.consent.grant()
166+
else meta.consent.revoke()
167+
if (choices.analytics)
168+
matomo.consent.give()
169+
else matomo.consent.forget()
170+
}
171+
```
172+
173+
## Third-party CMP recipes
174+
175+
When a dedicated Consent Management Platform owns the UI, bridge its events into each script's `consent` API.
176+
177+
### OneTrust
178+
179+
```ts
180+
const ga = useScriptGoogleAnalytics({ id: 'G-XXX', defaultConsent: { ad_storage: 'denied', analytics_storage: 'denied' } })
181+
const meta = useScriptMetaPixel({ id: '123', defaultConsent: 'denied' })
182+
183+
onNuxtReady(() => {
184+
function apply() {
185+
const groups = (window as any).OnetrustActiveGroups as string | undefined
186+
if (!groups)
187+
return
188+
const analytics = groups.includes('C0002')
189+
const marketing = groups.includes('C0004')
190+
ga.consent.update({
191+
analytics_storage: analytics ? 'granted' : 'denied',
192+
ad_storage: marketing ? 'granted' : 'denied',
193+
ad_user_data: marketing ? 'granted' : 'denied',
194+
ad_personalization: marketing ? 'granted' : 'denied',
195+
})
196+
if (marketing)
197+
meta.consent.grant()
198+
else meta.consent.revoke()
199+
}
200+
201+
apply()
202+
window.addEventListener('OneTrustGroupsUpdated', apply)
203+
})
204+
```
205+
206+
### Cookiebot
207+
208+
```ts
209+
const ga = useScriptGoogleAnalytics({ id: 'G-XXX', defaultConsent: { ad_storage: 'denied', analytics_storage: 'denied' } })
210+
const meta = useScriptMetaPixel({ id: '123', defaultConsent: 'denied' })
211+
212+
onNuxtReady(() => {
213+
function apply() {
214+
const cb = (window as any).Cookiebot
215+
if (!cb?.consent)
216+
return
217+
ga.consent.update({
218+
analytics_storage: cb.consent.statistics ? 'granted' : 'denied',
219+
ad_storage: cb.consent.marketing ? 'granted' : 'denied',
220+
ad_user_data: cb.consent.marketing ? 'granted' : 'denied',
221+
ad_personalization: cb.consent.marketing ? 'granted' : 'denied',
222+
})
223+
if (cb.consent.marketing)
224+
meta.consent.grant()
225+
else meta.consent.revoke()
226+
}
227+
228+
apply()
229+
window.addEventListener('CookiebotOnAccept', apply)
230+
window.addEventListener('CookiebotOnDecline', apply)
107231
})
108232
```

docs/content/scripts/bing-uet.md

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -57,22 +57,18 @@ function trackSignup() {
5757

5858
### Consent Mode
5959

60-
Bing UET supports [advanced consent mode](https://help.ads.microsoft.com/#apex/ads/en/60119/1-500). Use `onBeforeUetStart` to set the default consent state before the script loads. If consent is denied, UET only sends anonymous data.
60+
Bing UET supports [advanced consent mode](https://help.ads.microsoft.com/#apex/ads/en/60119/1-500). Only `ad_storage` is honoured; set the initial state with `defaultConsent` and update at runtime via `consent.update()`{lang="ts"}:
6161

6262
```vue
6363
<script setup lang="ts">
64-
const { proxy } = useScriptBingUet({
65-
onBeforeUetStart(uetq) {
66-
uetq.push('consent', 'default', {
67-
ad_storage: 'denied',
68-
})
69-
},
64+
const { consent } = useScriptBingUet({
65+
defaultConsent: { ad_storage: 'denied' },
7066
})
7167
7268
function grantConsent() {
73-
proxy.uetq.push('consent', 'update', {
74-
ad_storage: 'granted',
75-
})
69+
consent.update({ ad_storage: 'granted' })
7670
}
7771
</script>
7872
```
73+
74+
`onBeforeUetStart` remains available for any other pre-load setup.

docs/content/scripts/clarity.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,39 @@ links:
1818

1919
::script-types
2020
::
21+
22+
## Consent Mode
23+
24+
Clarity supports a cookie consent toggle (boolean) or an advanced consent vector (record). Set the initial value with `defaultConsent` and call `consent.set()`{lang="ts"} at runtime:
25+
26+
```vue
27+
<script setup lang="ts">
28+
const { consent } = useScriptClarity({
29+
id: 'YOUR_PROJECT_ID',
30+
defaultConsent: false, // disable cookies until user opts in
31+
})
32+
33+
function acceptAnalytics() {
34+
consent.set(true)
35+
}
36+
</script>
37+
```
38+
39+
`consent.set()`{lang="ts"} also accepts Clarity's advanced consent vector for fine-grained cookie categories:
40+
41+
```ts
42+
const { consent } = useScriptClarity({
43+
id: 'YOUR_PROJECT_ID',
44+
defaultConsent: {
45+
ad_storage: 'denied',
46+
analytics_storage: 'granted',
47+
},
48+
})
49+
50+
consent.set({
51+
ad_storage: 'granted',
52+
analytics_storage: 'granted',
53+
})
54+
```
55+
56+
See [Clarity cookie consent](https://learn.microsoft.com/en-us/clarity/setup-and-installation/cookie-consent) for details.

docs/content/scripts/google-analytics.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,44 @@ 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
34+
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:
36+
37+
```vue
38+
<script setup lang="ts">
39+
const { consent } = useScriptGoogleAnalytics({
40+
id: 'G-XXXXXXXX',
41+
defaultConsent: {
42+
ad_storage: 'denied',
43+
ad_user_data: 'denied',
44+
ad_personalization: 'denied',
45+
analytics_storage: 'denied',
46+
},
47+
})
48+
49+
function acceptAll() {
50+
consent.update({
51+
ad_storage: 'granted',
52+
ad_user_data: 'granted',
53+
ad_personalization: 'granted',
54+
analytics_storage: 'granted',
55+
})
56+
}
57+
58+
function savePreferences(choices: { analytics: boolean, marketing: boolean }) {
59+
consent.update({
60+
analytics_storage: choices.analytics ? 'granted' : 'denied',
61+
ad_storage: choices.marketing ? 'granted' : 'denied',
62+
ad_user_data: choices.marketing ? 'granted' : 'denied',
63+
ad_personalization: choices.marketing ? 'granted' : 'denied',
64+
})
65+
}
66+
</script>
67+
```
68+
69+
`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.
70+
3371
### Customer/Consumer ID Tracking
3472

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

0 commit comments

Comments
 (0)