For Vue.js and Angular developers, that shift makes consent architecture a compliance obligation, not a design choice.
Your analytics break the moment you deploy a consent banner, unless you wire consent state into your Vue.js or Angular architecture deliberately. Both frameworks have reactive state management patterns, dependency injection systems, and router hooks that turn what looks like a simple banner problem into an architecture decision. Get it wrong and you either break consent before the user has chosen, or you break your app after they do.
Your team ships the banner. QA passes. Then a regulator audit or a cookie scanner flags that GA4 fired before consent, because the gtag('consent', 'default') call wasn't in the right place, or the Pinia store initialized 300ms too late. This guide prevents that.
It covers the complete implementation for Vue 3 (Composition API + Pinia) and Angular 18+ (standalone API + APP_INITIALIZER), including Google Consent Mode v2 wiring, router guard patterns, and the SPA-specific pitfalls that generic consent guides skip entirely.
Key takeaways
- In SPAs, consent state must live in reactive global state (Pinia store for Vue, injectable service for Angular), not in localStorage reads scattered across components. Any component that controls whether a script fires must read from the same reactive source.
- Google Consent Mode v2's gtag('consent', 'default', {...}) call must execute before the GA4/GTM script loads, in an SPA, this means it must run before index.html renders the <script> tag, not inside any component lifecycle hook.
- Route changes in Vue Router and Angular Router do not require a new consent check. Consent is session-scoped; the only time to call gtag('consent', 'update') is on app initialization (for returning visitors) and when a user makes a new choice.
- Unlike Next.js App Router, pure client-side SPAs have no server rendering step to suppress the banner. The banner must be hidden or shown purely from client-side state, but this means there is no hydration mismatch problem either.
- GDPR's enforcement priority in 2026 has shifted to consent record-keeping and withdrawal mechanics. Regulators can now audit whether your consent events were actually propagated to downstream scripts, not just whether a banner appeared.
Why SPAs need explicit consent architecture
In a server-rendered app, you can check a cookie server-side and withhold the banner's HTML from the initial response. In a client-only SPA, everything renders from JavaScript in the browser: there is no server-side rendering step, and consent state must be read from localStorage or a cookie after the page loads.
This creates two concrete problems:
Problem 1: Banner flash on returning visitors. If your banner component reads localStorage synchronously in setup() (Vue) or ngOnInit() (Angular), the browser will show the banner for one frame before hiding it once the stored consent is read. Users who already gave consent see a flash. The fix is to initialize consent state globally, before any component mounts, so the banner component has correct state from its first render.
Problem 2: Scripts firing before consent state is resolved. Analytics libraries like GA4, Meta Pixel, and Hotjar are typically loaded via <script> tags in index.html. If gtag('consent', 'default', {...}) is not called before those script tags execute, the tags fire in an unknown consent state. Google Consent Mode v2 requires the default call to run synchronously before the GA4 snippet.
Both problems have the same architectural solution: establish global consent state before the framework initializes, not inside it.
Secure Privacy's Cookie & Consent Solution ships a JavaScript SDK that handles this initialization and the store/service pattern for both Vue and Angular. The implementations below show the architecture the SDK follows — useful for understanding what it does on your behalf, or if you're building without a CMP.
Vue 3 implementation: Pinia store + Composition API
Step 1: Set Google Consent Mode v2 defaults before the framework boots
In index.html, add an inline <script> block before any analytics script tags:
<head>
<!-- Must run before any Google script -->
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('consent', 'default', {
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
analytics_storage: 'denied',
wait_for_update: 500
});
</script>
<!-- GA4 snippet loads after defaults are set -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX"></script>
</head>This runs synchronously before Vue boots, before any component mounts, and before GA4 fires any requests. The wait_for_update: 500 gives your Pinia store 500ms to push the returning visitor's stored consent state before GA4 models the session as unconsented.
Step 2: Define the consent Pinia store
// stores/consent.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export type ConsentState = {
analytics: boolean
marketing: boolean
preferences: boolean
}
export const useConsentStore = defineStore('consent', () => {
const resolved = ref(false)
const analytics = ref(false)
const marketing = ref(false)
const preferences = ref(false)
const STORAGE_KEY = 'sp_consent_v1'
function load() {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) {
try {
const stored: ConsentState = JSON.parse(raw)
analytics.value = stored.analytics
marketing.value = stored.marketing
preferences.value = stored.preferences
applyConsentMode()
} catch {
// malformed storage — show banner
}
}
resolved.value = true
}
function grant(choices: ConsentState) {
analytics.value = choices.analytics
marketing.value = choices.marketing
preferences.value = choices.preferences
localStorage.setItem(STORAGE_KEY, JSON.stringify(choices))
applyConsentMode()
}
function applyConsentMode() {
if (typeof window.gtag !== 'function') return
window.gtag('consent', 'update', {
analytics_storage: analytics.value ? 'granted' : 'denied',
ad_storage: marketing.value ? 'granted' : 'denied',
ad_user_data: marketing.value ? 'granted' : 'denied',
ad_personalization: marketing.value ? 'granted' : 'denied',
})
}
const showBanner = computed(() => !resolved.value || localStorage.getItem(STORAGE_KEY) === null)
return { resolved, analytics, marketing, preferences, load, grant, showBanner }
})Step 3: Initialize the store before the app mounts
// main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { useConsentStore } from './stores/consent'
import App from './App.vue'
import router from './router'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.use(router)
// Load consent state before mounting so all components start with correct state
const consentStore = useConsentStore()
consentStore.load()
app.mount('#app')Calling consentStore.load() before app.mount('#app') ensures that when the root component and the consent banner component render for the first time, resolved is already true and showBanner reflects stored state, no flash.
If you're using Secure Privacy's SDK, this initialization call is replaced by the SP initialization. The SDK reads stored consent, fires gtag('consent', 'update') for returning visitors, and signals the banner when needed — the same sequence, without the store boilerplate.
Step 4: The banner component
<!-- components/ConsentBanner.vue -->
<script setup lang="ts">
import { useConsentStore } from '@/stores/consent'
const store = useConsentStore()
function acceptAll() {
store.grant({ analytics: true, marketing: true, preferences: true })
}
function rejectAll() {
store.grant({ analytics: false, marketing: false, preferences: false })
}
</script>
<template>
<div v-if="store.showBanner" class="consent-banner" role="dialog"
aria-label="Cookie consent">
<p>We use cookies for analytics and marketing. Choose your preferences.</p>
<button @click="rejectAll">Reject non-essential</button>
<button @click="acceptAll">Accept all</button>
</div>
</template>The v-if="store.showBanner" reads directly from the Pinia store, reactive, synchronous, and shared across the entire component tree. No prop drilling, no mounted() timing issues.
Step 5: Consent-gating third-party scripts
For scripts loaded after the initial bundle (Hotjar, Intercom, Meta Pixel), use dynamic import conditioned on consent:
// composables/useAnalytics.ts
import { watch } from 'vue'
import { useConsentStore } from '@/stores/consent'
export function useAnalytics() {
const consent = useConsentStore()
watch(() => consent.analytics, (granted) => {
if (granted) {
// Dynamically load Hotjar only after consent
import('./vendors/hotjar').then(m => m.initHotjar())
}
}, { immediate: true })
}Call useAnalytics() once in your root App.vue setup(). The immediate: true option fires on app load, if returning visitor already gave analytics consent, Hotjar loads immediately without waiting for the next state change.
Angular 18+ implementation: consent service + APP_INITIALIZER
Step 1: Consent Mode defaults in index.html
Same as Vue, the inline <script> before the GA4 snippet runs before Angular boots:
<head>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('consent', 'default', {
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
analytics_storage: 'denied',
wait_for_update: 500
});
</script>
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX"></script>
</head>Step 2: Consent service as a singleton
// services/consent.service.ts
import { Injectable, signal, computed } from '@angular/core'
export interface ConsentChoices {
analytics: boolean
marketing: boolean
preferences: boolean
}
@Injectable({ providedIn: 'root' })
export class ConsentService {
private _choices = signal<ConsentChoices | null>(null)
readonly showBanner = computed(() => this._choices() === null)
readonly analytics = computed(() => this._choices()?.analytics ?? false)
readonly marketing = computed(() => this._choices()?.marketing ?? false)
private readonly STORAGE_KEY = 'sp_consent_v1'
load(): void {
const raw = localStorage.getItem(this.STORAGE_KEY)
if (raw) {
try {
const stored: ConsentChoices = JSON.parse(raw)
this._choices.set(stored)
this.applyConsentMode()
} catch {
// malformed storage — leave signal null so banner shows
}
}
}
grant(choices: ConsentChoices): void {
this._choices.set(choices)
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(choices))
this.applyConsentMode()
}
private applyConsentMode(): void {
const choices = this._choices()
if (!choices || typeof (window as any).gtag !== 'function') return
;(window as any).gtag('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',
})
}
}Using Angular's signal() (available from Angular 17+) means that any template reading consentService.showBanner() or consentService.analytics() will automatically re-render when the signal changes, no Subject, no BehaviorSubject, no manual change detection.
Step 3: Initialize before the app renders using APP_INITIALIZER
// app.config.ts
import { ApplicationConfig, APP_INITIALIZER } from '@angular/core'
import { ConsentService } from './services/consent.service'
function initConsent(consentService: ConsentService) {
return () => consentService.load()
}
export const appConfig: ApplicationConfig = {
providers: [
{
provide: APP_INITIALIZER,
useFactory: initConsent,
deps: [ConsentService],
multi: true,
}
]
}APP_INITIALIZER functions run before Angular renders any component. By the time your root AppComponent renders, ConsentService.load() has already run and showBanner reflects whether stored consent exists.
Secure Privacy's Angular integration replaces ConsentService with the SP service. The APP_INITIALIZER factory above is the pattern it follows — you register the SP provider instead and remove the custom service code.
Step 4: The banner component
// components/consent-banner/consent-banner.component.ts
import { Component, inject } from '@angular/core'
import { ConsentService } from '../../services/consent.service'
@Component({
selector: 'app-consent-banner',
standalone: true,
template: `
@if (consent.showBanner()) {
<div class="consent-banner" role="dialog" aria-label="Cookie consent">
<p>We use cookies for analytics and marketing.</p>
<button (click)="reject()">Reject non-essential</button>
<button (click)="accept()">Accept all</button>
</div>
}
`
})
export class ConsentBannerComponent {
readonly consent = inject(ConsentService)
accept() {
this.consent.grant({ analytics: true, marketing: true, preferences: true })
}
reject() {
this.consent.grant({ analytics: false, marketing: false, preferences: false })
}
}The Angular 17+ @if control flow syntax replaces *ngIf and produces more efficient compiled output. The banner hides immediately when showBanner() signal becomes false, no change detection cycle needed.
Step 5: Router guard for consent-required routes
Some applications have routes that should not render at all without analytics consent (e.g., an in-app A/B test that requires event tracking to function). Use an Angular route guard:
// guards/analytics-consent.guard.ts
import { inject } from '@angular/core'
import { CanActivateFn, Router } from '@angular/router'
import { ConsentService } from '../services/consent.service'
export const analyticsConsentGuard: CanActivateFn = () => {
const consent = inject(ConsentService)
const router = inject(Router)
if (consent.analytics()) {
return true
}
// Redirect to a version of the route that doesn't require tracking
return router.createUrlTree(['/no-tracking'])
}Apply it selectively, most routes should not require consent to navigate. This guard is appropriate only where the route genuinely cannot function without tracking (rare), not as a consent enforcement gate for all routes.
Common mistakes in SPA consent implementations
Reading localStorage in multiple components independently. If three components each check localStorage.getItem('consent') directly, a consent withdrawal only updates the component that triggered it unless you manually synchronize the others. Centralize in Pinia store or Angular service, one source of truth.
Calling gtag('consent', 'update') on every route change. Consent state doesn't change when the user navigates. Calling update on every route change fires unnecessary dataLayer events and can interfere with session analytics. Call update only when the user makes a choice or when the app initializes with a stored choice.
Loading analytics scripts inside component onMounted / ngOnInit. If GA4 or Meta Pixel loads inside a component lifecycle hook, it will load after the framework has rendered, which means the consent default may already have been evaluated. Load via index.html (for always-present scripts) or via the consent store/service watcher (for consent-gated scripts).
Not handling the consent withdrawal case. GDPR Article 7(3) requires withdrawal to be as easy as giving consent, and the effect must be immediate. When a user withdraws analytics consent mid-session, you must call gtag('consent', 'update', { analytics_storage: 'denied' }), clear any analytics cookies already set, and stop any in-progress tracking calls. Implementing only the Consent Mode update without the cookie cleanup does not satisfy the "immediate effect" requirement — and clearing cookies set by third-party SDKs (GA4, Meta Pixel, Hotjar) without breaking their initialization is non-trivial. Secure Privacy's SDK handles withdrawal and cookie cleanup as part of its consent lifecycle management.
Missing the wait_for_update window. If the returning visitor's consent state is stored in localStorage, your store/service initialization needs to run and call gtag('consent', 'update') before the wait_for_update timeout expires (default: 500ms). If initialization is slow, GA4 may model the session as unconsented even for users who already gave consent. Keep initialization lean.
Testing your SPA consent implementation
Use Chrome DevTools → Application → Cookies to verify that non-essential cookies (analytics, advertising) are not set before consent is given. Specifically check:
- On first visit: _ga, _gid, _fbp, and any vendor tracking cookies must not exist before the user accepts
- After rejection: No tracking cookies should be set at all
- After acceptance: Cookies appear within one pageview or interaction event
- After withdrawal: Cookies previously set are cleared; no new ones are written
In Chrome DevTools → Network, filter by google-analytics or doubleclick and verify no outbound requests fire before consent is granted.
If you want a CMP that handles storage, TCF 2.3 compliance, and the consent API for you, Secure Privacy's Cookie & Consent Solution works with Vue and Angular via its JavaScript SDK. It exposes a consent-change event your Pinia store or Angular service subscribes to — the same pattern as the window.__tcfapi listener — and generates TCF 2.3 compliant strings without any application-level string handling.
The consent management platform you choose should expose a JavaScript API that your Pinia store or Angular service can subscribe to, rather than relying on polling localStorage. This is the cleanest integration pattern and is what TCF 2.3-certified CMPs provide through the window.__tcfapi interface.
Vue vs Angular: consent implementation pattern comparison
| Vue 3 + Pinia | Angular 18+ standalone | |
|---|---|---|
| Global consent state | Pinia store with ref() | Injectable service with signal() |
| Pre-render initialization | store.load() before app.mount() in main.ts | APP_INITIALIZER factory |
| Reactive banner condition | computed(() => showBanner) | computed(() => showBanner()) signal |
| Consent-gated script loading | watch() on store state, immediate: true | effect() on service signal |
| Route guard for consent | Vue Router beforeEach guard | CanActivateFn guard |
| SSR caveat (Nuxt) | Use useCookie() instead of localStorage | Inject PLATFORM_ID, guard with isPlatformBrowser() |
| TCF 2.3 integration | window.__tcfapi in Pinia action | window.__tcfapi in service method |
Frequently asked questions
Does a Vue.js or Angular SPA need a cookie banner if it only uses Pinia/NgRx for state, not cookies?
Yes if you use any non-essential third-party tracking. The GDPR and ePrivacy Directive apply to any client-side storage mechanism used for non-essential processing, localStorage, sessionStorage, IndexedDB, and fingerprinting are treated equivalently to cookies when used for analytics or advertising purposes. Using Pinia for application state internally does not trigger consent requirements; loading GA4 or Meta Pixel does.
Do we need to handle consent separately for each Vue Router / Angular Router route?
No. Consent is global and session-scoped. You do not need to re-check or re-display the banner on route changes. The one exception is consent-required route guards, but those are appropriate only for routes that genuinely cannot function without tracking, not as a blanket enforcement pattern.
Can we use Vuex instead of Pinia for consent state in Vue 3?
Yes. Pinia is the recommended state management library for Vue 3 (and is the successor to Vuex), but Vuex 4 works for Vue 3 with the same pattern. The key principle is the same: consent state lives in a globally shared reactive store, initialized before the app mounts.
How do we handle Nuxt.js, which uses Vue with SSR?
Nuxt adds server-side rendering and hydration, making it closer to the Next.js scenario covered in our Next.js consent guide. The critical difference: in Nuxt, localStorage is not available during SSR, and the consent state must be sourced from a cookie (readable server-side via useCookie) rather than localStorage. Use useNuxtApp().ssrContext to detect server vs. client context and gate localStorage reads accordingly.
We use Angular Universal (SSR). Does this guide apply?
Angular Universal introduces the same server/client split as Nuxt. The APP_INITIALIZER approach still works but localStorage reads must be guarded with isPlatformBrowser(platformId). The consent service should inject PLATFORM_ID and skip localStorage access during server-side rendering, defaulting to "no consent stored", which will cause the banner to render server-side and be suppressed client-side once the service reads actual stored consent after hydration.
Our consent banner uses a third-party CMP SDK. Does all of this still apply?
Yes, but the Pinia store or Angular service becomes a thin wrapper around the CMP's JavaScript API rather than managing storage directly. Subscribe to the CMP's consent-change event (via window.__tcfapi('addEventListener', 2, callback) for TCF-certified CMPs, or the CMP's proprietary API), and call gtag('consent', 'update') from within that callback. The store/service still provides the reactive bridge between the CMP's global event and your framework's reactivity system. If you use Secure Privacy, the SDK exposes a consent-change callback directly — your store or service subscribes to it, and localStorage management and TCF string handling are removed from your application entirely.




