|
1 | 1 | --- |
2 | 2 | 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. |
4 | 4 | --- |
5 | 5 |
|
6 | 6 | ::callout{icon="i-heroicons-play" to="https://stackblitz.com/github/nuxt/scripts/tree/main/examples/cookie-consent" target="_blank"} |
7 | 7 | 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). |
8 | 8 | :: |
9 | 9 |
|
10 | | -## Background |
| 10 | +## Two complementary primitives |
11 | 11 |
|
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: |
13 | 13 |
|
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. |
15 | 16 |
|
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. |
17 | 18 |
|
18 | | -See the [API](/docs/api/use-script-trigger-consent) docs for full details on the available options. |
| 19 | +## Binary load gate |
19 | 20 |
|
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. |
25 | 22 |
|
26 | 23 | ::code-group |
27 | 24 |
|
28 | 25 | ```ts [utils/cookie.ts] |
29 | | -export const agreedToCookiesScriptConsent = useScriptTriggerConsent() |
| 26 | +export const scriptsConsent = useScriptTriggerConsent() |
30 | 27 | ``` |
31 | 28 |
|
32 | 29 | ```vue [app.vue] |
33 | 30 | <script setup lang="ts"> |
34 | | -import { agreedToCookiesScriptConsent } from '#imports' |
| 31 | +import { scriptsConsent } from '#imports' |
35 | 32 |
|
36 | 33 | useScript('https://www.google-analytics.com/analytics.js', { |
37 | | - trigger: agreedToCookiesScriptConsent |
| 34 | + trigger: scriptsConsent, |
38 | 35 | }) |
39 | 36 | </script> |
40 | 37 | ``` |
41 | 38 |
|
42 | 39 | ```vue [components/cookie-banner.vue] |
43 | 40 | <script setup lang="ts"> |
44 | | -import { agreedToCookiesScriptConsent } from '#imports' |
| 41 | +import { scriptsConsent } from '#imports' |
45 | 42 | </script> |
46 | 43 |
|
47 | 44 | <template> |
48 | | - <button @click="agreedToCookiesScriptConsent.accept()"> |
| 45 | + <button @click="scriptsConsent.accept()"> |
49 | 46 | Accept Cookies |
50 | 47 | </button> |
51 | 48 | </template> |
52 | 49 | ``` |
53 | 50 |
|
54 | 51 | :: |
55 | 52 |
|
56 | | -### Accepting as a resolvable boolean |
| 53 | +### Reactive source |
57 | 54 |
|
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. |
59 | 56 |
|
60 | 57 | ```ts |
61 | 58 | 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 }) |
67 | 60 | ``` |
68 | 61 |
|
69 | | -### Revoking Consent |
| 62 | +### Revoking |
70 | 63 |
|
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. |
77 | 65 |
|
| 66 | +```vue |
78 | 67 | <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()"> |
82 | 70 | Revoke Consent |
83 | 71 | </button> |
84 | 72 | </div> |
85 | | - <button v-else @click="agreedToCookiesScriptConsent.accept()"> |
| 73 | + <button v-else @click="scriptsConsent.accept()"> |
86 | 74 | Accept Cookies |
87 | 75 | </button> |
88 | 76 | </template> |
89 | 77 | ``` |
90 | 78 |
|
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 | +``` |
92 | 89 |
|
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 |
94 | 91 |
|
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. |
96 | 93 |
|
97 | 94 | ```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', |
106 | 106 | }) |
| 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) |
107 | 231 | }) |
108 | 232 | ``` |
0 commit comments