Vue and Nuxt apps hit two cookie-consent problems that a static site never sees: third-party scripts that developers wire into the app bundle and fire on mount, and server-side rendering that makes consent state ambiguous during hydration. Get either wrong and you either track before consent (a compliance failure) or throw hydration mismatch warnings (a bug). This guide covers both.
The core rule: don't load trackers on mount
The whole point of a consent banner is that non-essential scripts wait for a choice. In a Vue app that means you must not import or initialize analytics at module load or in a top-level onMounted. Instead, keep every tracker behind a consent check and load it dynamically only after the visitor opts in. The same principle applies to any single-page app, where route changes never reload the page.
SSR and hydration in Nuxt
On the server there's no navigator and no stored consent, so any consent-dependent rendering must run client-side only. Wrap it in <ClientOnly> or an onMounted guard so the server and client render the same initial markup and hydration stays clean:
<template>
<ClientOnly>
<CookieBanner v-if="!hasDecided" @accept="grant" @reject="deny" />
</ClientOnly>
</template>
<script setup>
const hasDecided = ref(false)
onMounted(() => {
// Reading persisted consent is a client-only concern
hasDecided.value = Boolean(localStorage.getItem('consent'))
})
</script>Persist the decision in a first-party cookie (via useCookie) if you need the server to read it on the next request, for example to decide server-side which tags to inject.
Deferring scripts with Nuxt Scripts
Nuxt Scripts is the official way to load third-party scripts in Nuxt, and it has a consent trigger built for exactly this. Load a script with a manual trigger and only call load() once the visitor accepts the relevant category (see the Nuxt Scripts consent guide):
// composables/useAnalytics.ts
export function useAnalytics() {
const { load } = useScript(
'https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX',
{ trigger: 'manual' } // do NOT auto-load
)
return { load }
}
// Call load() from your banner's accept handler, e.g.
const { load } = useAnalytics()
function grant() {
useCookie('consent').value = 'granted'
load()
}Because the script never loads until load() is called, no analytics cookie is set before consent, the behavior a regulator expects.
nuxt-gtag with Consent Mode v2
If you use the nuxt-gtag module for GA4 or Google Ads, set initMode: 'manual' so the tag doesn't fire on load, push Consent Mode v2 defaults of denied, then update and initialize when the visitor accepts. Consent Mode v2 has been required for EEA and UK traffic since March 2024:
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['nuxt-gtag'],
gtag: { id: 'G-XXXXXXX', initMode: 'manual' },
})
// in your accept handler
const { gtag, initialize } = useGtag()
gtag('consent', 'update', {
ad_storage: 'granted',
analytics_storage: 'granted',
ad_user_data: 'granted',
ad_personalization: 'granted',
})
initialize() // now the GA4 tag loadsFor the full container wiring, see our GTM Consent Mode guide. For plain Vue (Vite, no Nuxt), vue-gtag follows the same shape: configure it with bootstrap: false (or the current manual-init option) and call its init function only after consent.
Reacting to consent changes
Consent isn't one-and-done. Visitors withdraw it, and in a SPA that must take effect without a full reload. Keep consent in a reactive store (Pinia or a composable) and have every gated feature watch it, so revoking analytics stops future events immediately. Dedicated Nuxt modules such as nuxt-cookie-control expose a useCookieControl() composable that gives you this reactivity plus a ready-made UI.
Store consent where the server can read it
Where you persist the decision matters in an SSR app. localStorage is client-only, so the server can't see it and you risk a flash of the wrong state on first paint. A first-party cookie is readable on both sides, which lets Nuxt decide server-side whether to inject a tag at all. Nuxt's useCookie gives you an isomorphic handle:
const consent = useCookie('consent', {
maxAge: 60 * 60 * 24 * 180, // 6 months
sameSite: 'lax',
})
// consent.value is available during SSR and on the client
if (consent.value === 'granted') {
// safe to inject analytics server-side
}The consent cookie itself is strictly necessary (it stores a compliance decision, not tracking data) so it doesn't require consent to set.
A reactive consent store
Centralize consent so every gated feature reacts to one source of truth. A small Pinia store (or a shared composable) is enough:
export const useConsent = defineStore('consent', () => {
const analytics = ref(false)
const marketing = ref(false)
function set(categories) {
analytics.value = !!categories.analytics
marketing.value = !!categories.marketing
}
return { analytics, marketing, set }
})Components watch analytics and stop emitting events the instant it flips to false, so a withdrawal takes effect immediately, essential in a SPA where the page never fully reloads between routes.
Using CookieBeam in a Vue or Nuxt app
If you'd rather not build the banner, scanning, and audit log yourself, load the CookieBeam script in nuxt.config.ts via app.head.script (or Nuxt Scripts) and subscribe to its consent events to gate your app's own logic:
// nuxt.config.ts
export default defineNuxtConfig({
app: {
head: {
script: [
{ src: 'https://cdn.cookiebeam.com/banner/YOUR_BANNER_ID/default/loader.js', async: true, tagPriority: 'high' },
],
},
},
})Then, in a client-only plugin, listen for CookieBeam.on('consent', ...) and flip your reactive consent store. The same runtime handles GDPR opt-in and US opt-out regions, so you get one banner that adapts by geography. For the React and Next.js equivalent, see the Next.js App Router guide.