-
Notifications
You must be signed in to change notification settings - Fork 86
feat: vendor-native consent controls for consent-aware scripts #712
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
c60a00d
feat(registry): add defaultConsent + consentAdapter to consent-aware β¦
harlan-zw 5000b98
feat(consent): unified consentAdapter + defaultConsent for GTM / Matoβ¦
harlan-zw fe1548b
feat(script): unified useScriptConsent composable
harlan-zw 787752a
merge: Scope A consent adapters (TikTok/Meta/GA/Bing/Clarity)
harlan-zw 392e173
merge: Scope A+ consent adapters (GTM/Matomo/Mixpanel/PostHog)
harlan-zw 6b15d18
merge: Scope B unified useScriptConsent composable
harlan-zw a36398c
fix: dedupe consent types after Scope B merge
harlan-zw bbb68d5
style: fix max-statements-per-line in consent-default test
harlan-zw 8ae9ff1
fix(consent): extract adapters to decoupled module so registry.ts staβ¦
harlan-zw 429a2fc
test(consent): remove unit tests needing nuxt runtime; hoist posthog β¦
harlan-zw 58303b8
fix(consent): address CodeRabbit review findings
harlan-zw 3b68b6c
fix(consent): tiktok hold defaultConsent + mixpanel stub opt-in/out
harlan-zw be0ad69
refactor(consent): vendor-native per-script consent API
harlan-zw c6161b1
fix(consent): resolve CI typecheck + lint errors
harlan-zw 8608c04
docs(tiktok): clarify consent method stubs + loader filename
harlan-zw 7bd5d5c
refactor(matomo): drop redundant as any on _paq push
harlan-zw File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,108 +1,232 @@ | ||
| --- | ||
| title: Consent Management | ||
| description: Learn how to get user consent before loading scripts. | ||
| description: Gate scripts behind user consent and drive vendor-native consent APIs through a typed per-script `consent` object. | ||
| --- | ||
|
|
||
| ::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). | ||
| :: | ||
|
|
||
| ## Background | ||
| ## Two complementary primitives | ||
|
|
||
| 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. | ||
| Nuxt Scripts ships two consent primitives that work together: | ||
|
|
||
| ## Usage | ||
| 1. **[`useScriptTriggerConsent()`{lang="ts"}](/docs/api/use-script-trigger-consent)**: a binary load gate. The script only starts loading after consent is granted. | ||
| 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. | ||
|
|
||
| The [`useScriptTriggerConsent()`{lang="ts"}](/docs/api/use-script-trigger-consent){lang="ts"} composable offers flexible interaction options suitable for various scenarios. | ||
| 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. | ||
|
|
||
| See the [API](/docs/api/use-script-trigger-consent) docs for full details on the available options. | ||
| ## Binary load gate | ||
|
|
||
| ### Accepting as a Function | ||
|
|
||
| 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. | ||
|
|
||
| For an example of how you might lay out your code to handle this, see the following: | ||
| The simplest usage matches the classic cookie-banner flow: load the script only after the user clicks accept. | ||
|
|
||
| ::code-group | ||
|
|
||
| ```ts [utils/cookie.ts] | ||
| export const agreedToCookiesScriptConsent = useScriptTriggerConsent() | ||
| export const scriptsConsent = useScriptTriggerConsent() | ||
| ``` | ||
|
|
||
| ```vue [app.vue] | ||
| <script setup lang="ts"> | ||
| import { agreedToCookiesScriptConsent } from '#imports' | ||
| import { scriptsConsent } from '#imports' | ||
|
|
||
| useScript('https://www.google-analytics.com/analytics.js', { | ||
| trigger: agreedToCookiesScriptConsent | ||
| trigger: scriptsConsent, | ||
| }) | ||
| </script> | ||
| ``` | ||
|
|
||
| ```vue [components/cookie-banner.vue] | ||
| <script setup lang="ts"> | ||
| import { agreedToCookiesScriptConsent } from '#imports' | ||
| import { scriptsConsent } from '#imports' | ||
| </script> | ||
|
|
||
| <template> | ||
| <button @click="agreedToCookiesScriptConsent.accept()"> | ||
| <button @click="scriptsConsent.accept()"> | ||
| Accept Cookies | ||
| </button> | ||
| </template> | ||
| ``` | ||
|
|
||
| :: | ||
|
|
||
| ### Accepting as a resolvable boolean | ||
| ### Reactive source | ||
|
|
||
| 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`. | ||
| Pass a `Ref<boolean>`{lang="html"} if an external store owns the state. | ||
|
|
||
| ```ts | ||
| const agreedToCookies = ref(false) | ||
| useScript('https://www.google-analytics.com/analytics.js', { | ||
| trigger: useScriptTriggerConsent({ | ||
| consent: agreedToCookies | ||
| }) | ||
| }) | ||
| const consent = useScriptTriggerConsent({ consent: agreedToCookies }) | ||
| ``` | ||
|
|
||
| ### Revoking Consent | ||
| ### Revoking | ||
|
|
||
| You can revoke consent after it has been granted using the `revoke` method. Use the reactive `consented` ref to track the current consent state. | ||
|
|
||
| ```vue [components/cookie-banner.vue] | ||
| <script setup lang="ts"> | ||
| import { agreedToCookiesScriptConsent } from '#imports' | ||
| </script> | ||
| 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. | ||
|
|
||
| ```vue | ||
| <template> | ||
| <div v-if="agreedToCookiesScriptConsent.consented.value"> | ||
| <p>Cookies accepted</p> | ||
| <button @click="agreedToCookiesScriptConsent.revoke()"> | ||
| <div v-if="scriptsConsent.consented.value"> | ||
| <button @click="scriptsConsent.revoke()"> | ||
| Revoke Consent | ||
| </button> | ||
| </div> | ||
| <button v-else @click="agreedToCookiesScriptConsent.accept()"> | ||
| <button v-else @click="scriptsConsent.accept()"> | ||
| Accept Cookies | ||
| </button> | ||
| </template> | ||
| ``` | ||
|
|
||
| ### Delaying script load after consent | ||
| ### Delaying the load after consent | ||
|
|
||
| ```ts | ||
| const consent = useScriptTriggerConsent({ | ||
| consent: agreedToCookies, | ||
| postConsentTrigger: () => new Promise<void>(resolve => | ||
| setTimeout(resolve, 3000), | ||
| ), | ||
| }) | ||
| ``` | ||
|
|
||
| There may be instances where you want to trigger the script load after a certain event, only if the user has consented. | ||
| ## Per-script consent API | ||
|
|
||
| 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. | ||
| 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. | ||
|
|
||
| ```ts | ||
| const agreedToCookies = ref(false) | ||
| useScript('https://www.google-analytics.com/analytics.js', { | ||
| trigger: useScriptTriggerConsent({ | ||
| consent: agreedToCookies, | ||
| // load 3 seconds after consent is granted | ||
| postConsentTrigger: () => new Promise<void>(resolve => | ||
| setTimeout(resolve, 3000), | ||
| ), | ||
| const { consent } = useScriptGoogleAnalytics({ | ||
| id: 'G-XXXXXXXX', | ||
| defaultConsent: { ad_storage: 'denied', analytics_storage: 'denied' }, | ||
| }) | ||
|
|
||
| function onAcceptAll() { | ||
| consent.update({ | ||
| ad_storage: 'granted', | ||
| ad_user_data: 'granted', | ||
| ad_personalization: 'granted', | ||
| analytics_storage: 'granted', | ||
| }) | ||
| } | ||
| ``` | ||
|
|
||
| ### Per-vendor surface | ||
|
|
||
| | Script | `defaultConsent` | Runtime `consent.*` | | ||
| |---|---|---| | ||
| | Google Analytics | `Partial<ConsentState>`{lang="html"} (GCMv2) | `consent.update(state)`{lang="ts"} | | ||
| | Google Tag Manager | `Partial<ConsentState>`{lang="html"} (GCMv2) | `consent.update(state)`{lang="ts"} | | ||
| | Bing UET | `{ ad_storage }` | `consent.update({ ad_storage })`{lang="ts"} | | ||
| | Meta Pixel | `'granted' \| 'denied'` | `consent.grant()`{lang="ts"} / `consent.revoke()`{lang="ts"} | | ||
| | TikTok Pixel | `'granted' \| 'denied' \| 'hold'` | `consent.grant()`{lang="ts"} / `consent.revoke()`{lang="ts"} / `consent.hold()`{lang="ts"} | | ||
| | Matomo | `'required' \| 'given' \| 'not-required'` | `consent.give()`{lang="ts"} / `consent.forget()`{lang="ts"} *(requires `defaultConsent: 'required'` or `'given'`)* | | ||
| | Mixpanel | `'opt-in' \| 'opt-out'` | `consent.optIn()`{lang="ts"} / `consent.optOut()`{lang="ts"} | | ||
| | PostHog | `'opt-in' \| 'opt-out'` | `consent.optIn()`{lang="ts"} / `consent.optOut()`{lang="ts"} | | ||
| | Clarity | `boolean \| Record<string, string>`{lang="html"} | `consent.set(value)`{lang="ts"} | | ||
|
|
||
| See each script's registry page for notes on lossy projections and vendor caveats. | ||
|
|
||
| ### Fanning out to multiple scripts | ||
|
|
||
| When one cookie banner drives several vendors, wire them explicitly in your accept handler. No magic, fully typed, no lossy remapping: | ||
|
|
||
| ```ts | ||
| const ga = useScriptGoogleAnalytics({ id: 'G-XXX', defaultConsent: { ad_storage: 'denied', analytics_storage: 'denied' } }) | ||
| const meta = useScriptMetaPixel({ id: '123', defaultConsent: 'denied' }) | ||
| const matomo = useScriptMatomoAnalytics({ cloudId: 'foo.matomo.cloud', defaultConsent: 'required' }) | ||
|
|
||
| function onAcceptAll() { | ||
| ga.consent.update({ | ||
| ad_storage: 'granted', | ||
| ad_user_data: 'granted', | ||
| ad_personalization: 'granted', | ||
| analytics_storage: 'granted', | ||
| }) | ||
| meta.consent.grant() | ||
| matomo.consent.give() | ||
| } | ||
|
|
||
| function onDeclineAll() { | ||
| meta.consent.revoke() | ||
| matomo.consent.forget() | ||
| } | ||
| ``` | ||
|
|
||
| ### Granular categories | ||
|
|
||
| If users can toggle categories individually (analytics, marketing, functional), the same pattern applies; each script gets only the categories it understands: | ||
|
|
||
| ```ts | ||
| function savePreferences(choices: { analytics: boolean, marketing: boolean }) { | ||
| ga.consent.update({ | ||
| analytics_storage: choices.analytics ? 'granted' : 'denied', | ||
| ad_storage: choices.marketing ? 'granted' : 'denied', | ||
| ad_user_data: choices.marketing ? 'granted' : 'denied', | ||
| ad_personalization: choices.marketing ? 'granted' : 'denied', | ||
| }) | ||
| if (choices.marketing) | ||
| meta.consent.grant() | ||
| else meta.consent.revoke() | ||
| if (choices.analytics) | ||
| matomo.consent.give() | ||
| else matomo.consent.forget() | ||
| } | ||
| ``` | ||
|
|
||
| ## Third-party CMP recipes | ||
|
|
||
| When a dedicated Consent Management Platform owns the UI, bridge its events into each script's `consent` API. | ||
|
|
||
| ### OneTrust | ||
|
|
||
| ```ts | ||
| const ga = useScriptGoogleAnalytics({ id: 'G-XXX', defaultConsent: { ad_storage: 'denied', analytics_storage: 'denied' } }) | ||
| const meta = useScriptMetaPixel({ id: '123', defaultConsent: 'denied' }) | ||
|
|
||
| onNuxtReady(() => { | ||
| function apply() { | ||
| const groups = (window as any).OnetrustActiveGroups as string | undefined | ||
| if (!groups) | ||
| return | ||
| const analytics = groups.includes('C0002') | ||
| const marketing = groups.includes('C0004') | ||
| ga.consent.update({ | ||
| analytics_storage: analytics ? 'granted' : 'denied', | ||
| ad_storage: marketing ? 'granted' : 'denied', | ||
| ad_user_data: marketing ? 'granted' : 'denied', | ||
| ad_personalization: marketing ? 'granted' : 'denied', | ||
| }) | ||
| if (marketing) | ||
| meta.consent.grant() | ||
| else meta.consent.revoke() | ||
| } | ||
|
|
||
| apply() | ||
| window.addEventListener('OneTrustGroupsUpdated', apply) | ||
| }) | ||
| ``` | ||
|
|
||
| ### Cookiebot | ||
|
|
||
| ```ts | ||
| const ga = useScriptGoogleAnalytics({ id: 'G-XXX', defaultConsent: { ad_storage: 'denied', analytics_storage: 'denied' } }) | ||
| const meta = useScriptMetaPixel({ id: '123', defaultConsent: 'denied' }) | ||
|
|
||
| onNuxtReady(() => { | ||
| function apply() { | ||
| const cb = (window as any).Cookiebot | ||
| if (!cb?.consent) | ||
| return | ||
| ga.consent.update({ | ||
| analytics_storage: cb.consent.statistics ? 'granted' : 'denied', | ||
| ad_storage: cb.consent.marketing ? 'granted' : 'denied', | ||
| ad_user_data: cb.consent.marketing ? 'granted' : 'denied', | ||
| ad_personalization: cb.consent.marketing ? 'granted' : 'denied', | ||
| }) | ||
| if (cb.consent.marketing) | ||
| meta.consent.grant() | ||
| else meta.consent.revoke() | ||
| } | ||
|
|
||
| apply() | ||
| window.addEventListener('CookiebotOnAccept', apply) | ||
| window.addEventListener('CookiebotOnDecline', apply) | ||
| }) | ||
| ``` | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.