Cookie consent platforms were designed for multi-page websites. A visitor arrives, the CMP loads, the banner appears, the user clicks, and tracking scripts fire (or don't). On the next page, the whole process repeats — but consent is stored, so scripts fire immediately. Simple.
Single-page apps break every step of that model. There are no page reloads. Navigation is virtual — a URL change handled by JavaScript, not a server request. Scripts loaded in one route persist across all routes. And the timing assumptions baked into every CMP's lifecycle — load before first paint, block before first script, re-check on navigation — collapse when "navigation" is a history.pushState() call.
The result is predictable: SPAs are where cookie consent fails silently. The banner loads once but scripts slip through on route changes. Page views fire before consent status is checked. GTM containers execute in a hydration gap before the React tree is interactive. And Consent Mode v2's default/update sequence — already fragile on static sites — becomes a timing puzzle that most developers get wrong.
This guide covers the SPA-specific consent problems that standard CMP documentation ignores, with concrete integration patterns for React, Vue, Angular, and their SSR variants.
Why SPAs Break Cookie Consent
Traditional CMPs rely on three assumptions that don't hold in single-page apps:
- Page loads are real HTTP requests. CMPs inject blocking scripts in the
<head>that execute before the page renders. In an SPA, the initial page load is the only real HTTP request. Every subsequent "page" is a client-side render — the<head>doesn't re-execute, blocking scripts don't re-evaluate, and any consent-gating logic tied toDOMContentLoadedorloadevents fires exactly once. - Script tags are per-page. On a multi-page site, analytics scripts in the
<head>reload with each navigation, giving the CMP a natural interception point. In an SPA, scripts loaded via<script>tags persist for the entire session. A script that loads on route/is still running when the user navigates to/checkout— and there's no mechanism for the CMP to "unload" it. - Page views map to page loads. CMPs often hook into the browser's navigation events to re-check consent on each page. SPAs emit virtual page views — usually a
dataLayer.pushor agtag('event', 'page_view', ...)call triggered by the router. If the CMP doesn't know about these virtual events, it can't gate them.
These aren't edge cases. They're the default behavior of React, Vue, and Angular apps. If your CMP wasn't built for SPAs, it's almost certainly leaking unconsented tracking on route changes.
The Race Condition Problem
The hardest SPA consent bug is a race condition between the CMP initialization and the app's first render. Here's what happens:
- Browser requests the SPA shell (usually a minimal HTML file with a
<div id="root">) - JavaScript bundles download and execute
- React/Vue/Angular bootstraps, the component tree renders
- The router resolves the initial route and fires a page-view event
- The CMP script loads (async or deferred)
- The CMP checks for stored consent
- The banner appears (if no stored consent)
Steps 4 and 5 are the problem. The page-view event fires before the CMP has initialized. If that page-view triggers a GA4 event or a Facebook Pixel PageView, tracking data has left the browser before the user had any chance to consent.
On a traditional multi-page site, the CMP loads synchronously in the <head>, blocking everything downstream. In an SPA, the app shell loads first, the framework bootstraps, the router fires — all before the CMP is ready. The CMP can't block what's already happened.
The fix has two parts:
- Consent Mode default must fire synchronously before any tag. Place the
gtag('consent', 'default', ...)call in an inline<script>in the HTML shell — before your app bundle, before GTM, before anything. This ensures Google tags start in a denied state regardless of when the CMP loads. - Non-Google scripts must be gated at the import level. Don't load third-party SDKs as
<script>tags in the HTML shell. Import them dynamically in your app code, behind a consent check. More on this in the script-gating section below.
Handling Consent on Route Changes
Once initial consent is handled, the next problem is virtual navigation. When a user navigates from /blog to /pricing in a React app, no page load occurs — the router swaps components, and you (or your analytics setup) push a virtual page-view event to the dataLayer.
The rule is straightforward: don't fire page-view events unless consent is granted. But in practice, most SPA analytics setups fire page views unconditionally on every route change and rely on the CMP to somehow intercept them. That doesn't work — by the time the CMP could intercept a dataLayer.push, the event is already in Google's pipeline.
The correct pattern is to check consent status before firing the virtual page view:
// React Router v6+ with consent check
import { useLocation } from 'react-router-dom';
import { useConsent } from './hooks/useConsent';
function AnalyticsRouteTracker() {
const location = useLocation();
const { analyticsGranted } = useConsent();
useEffect(() => {
if (!analyticsGranted) return;
gtag('event', 'page_view', {
page_path: location.pathname + location.search,
page_title: document.title,
});
}, [location, analyticsGranted]);
return null;
}
// Mount inside your Router
<BrowserRouter>
<AnalyticsRouteTracker />
<Routes>...</Routes>
</BrowserRouter>Key details:
- The
useConsenthook (your own or your CMP's) must return a reactive value that updates when consent changes. If a user accepts the banner mid-session, subsequent navigations should start firing page views. - The effect depends on both
locationandanalyticsGranted. If the user grants consent without navigating, the current page view should fire retroactively — or not, depending on your privacy stance. IncludeanalyticsGrantedin the dependency array if you want retroactive firing; exclude it if you only want future navigations tracked. - Don't wrap this in a
setTimeout. Consent is either granted or it isn't — adding delay adds complexity without solving the fundamental gating question.
React Integration Patterns
React's component model and hooks API make it the most natural framework for consent integration. Here are the patterns that work.
Consent Context Provider
Centralize consent state in a context provider that wraps your app:
// ConsentProvider.tsx
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
type ConsentState = {
necessary: boolean; // always true
analytics: boolean;
marketing: boolean;
preferences: boolean;
};
type ConsentContextType = {
consent: ConsentState;
consentLoaded: boolean;
updateConsent: (consent: Partial<ConsentState>) => void;
};
const ConsentContext = createContext<ConsentContextType | null>(null);
export function ConsentProvider({ children }: { children: React.ReactNode }) {
const [consent, setConsent] = useState<ConsentState>({
necessary: true,
analytics: false,
marketing: false,
preferences: false,
});
const [consentLoaded, setConsentLoaded] = useState(false);
useEffect(() => {
// Read stored consent from your CMP or localStorage
const stored = window.__cookiebeam?.getConsent();
if (stored) {
setConsent(prev => ({ ...prev, ...stored }));
}
setConsentLoaded(true);
// Listen for consent changes (banner interaction)
const handler = (e: CustomEvent) => {
setConsent(prev => ({ ...prev, ...e.detail }));
};
window.addEventListener('cookiebeam:consent', handler as EventListener);
return () => window.removeEventListener('cookiebeam:consent', handler as EventListener);
}, []);
const updateConsent = useCallback((update: Partial<ConsentState>) => {
setConsent(prev => ({ ...prev, ...update }));
}, []);
return (
<ConsentContext.Provider value={{ consent, consentLoaded, updateConsent }}>
{children}
</ConsentContext.Provider>
);
}
export function useConsent() {
const ctx = useContext(ConsentContext);
if (!ctx) throw new Error('useConsent must be used within ConsentProvider');
return ctx;
}Consent-Gated Components
Use the consent hook to conditionally render tracking-dependent components:
function ChatWidget() {
const { consent, consentLoaded } = useConsent();
if (!consentLoaded || !consent.preferences) return null;
return <ThirdPartyChatEmbed />;
}The consentLoaded flag prevents a flash where the widget renders before consent state is known. Without it, components that depend on consent default to rendering (since the initial state has everything false), then unmount — which may have already triggered third-party script initialization.
Vue and Nuxt Patterns
Vue's reactivity system handles consent state naturally. Use a composable for consent access and a plugin for app-wide initialization.
Consent Composable
// composables/useConsent.ts
import { ref, readonly, onMounted, onUnmounted } from 'vue';
const consent = ref({
necessary: true,
analytics: false,
marketing: false,
preferences: false,
});
const consentLoaded = ref(false);
export function useConsent() {
onMounted(() => {
const stored = window.__cookiebeam?.getConsent();
if (stored) {
consent.value = { ...consent.value, ...stored };
}
consentLoaded.value = true;
const handler = (e: CustomEvent) => {
consent.value = { ...consent.value, ...e.detail };
};
window.addEventListener('cookiebeam:consent', handler);
onUnmounted(() => window.removeEventListener('cookiebeam:consent', handler));
});
return {
consent: readonly(consent),
consentLoaded: readonly(consentLoaded),
};
}Vue Router Guard
Gate page-view tracking at the router level using navigation guards:
// router/index.ts
import { useConsent } from '@/composables/useConsent';
router.afterEach((to) => {
const { consent } = useConsent();
if (!consent.value.analytics) return;
gtag('event', 'page_view', {
page_path: to.fullPath,
page_title: to.meta.title || document.title,
});
});Nuxt-Specific Considerations
Nuxt 3 adds a server-side rendering layer. The consent composable must be client-only — consent doesn't exist on the server. Use Nuxt's <ClientOnly> wrapper for consent-dependent UI, and guard the composable with import.meta.client checks:
// plugins/consent.client.ts
export default defineNuxtPlugin(() => {
const stored = window.__cookiebeam?.getConsent();
// Initialize consent state for client-side use
});File-naming the plugin with .client.ts ensures Nuxt only runs it in the browser, avoiding hydration mismatches.
Angular Patterns
Angular's dependency injection and service pattern map cleanly to consent management. Create a consent service that components inject:
// consent.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
export interface ConsentState {
necessary: boolean;
analytics: boolean;
marketing: boolean;
preferences: boolean;
}
@Injectable({ providedIn: 'root' })
export class ConsentService {
private consentSubject = new BehaviorSubject<ConsentState>({
necessary: true,
analytics: false,
marketing: false,
preferences: false,
});
consent$ = this.consentSubject.asObservable();
loaded = false;
constructor() {
const stored = (window as any).__cookiebeam?.getConsent();
if (stored) {
this.consentSubject.next({ ...this.consentSubject.value, ...stored });
}
this.loaded = true;
window.addEventListener('cookiebeam:consent', ((e: CustomEvent) => {
this.consentSubject.next({ ...this.consentSubject.value, ...e.detail });
}) as EventListener);
}
}Router Event Tracking
Subscribe to Angular's router events and gate page views on consent:
// app.component.ts
import { Router, NavigationEnd } from '@angular/router';
import { filter } from 'rxjs/operators';
import { ConsentService } from './consent.service';
constructor(
private router: Router,
private consentService: ConsentService
) {
this.router.events.pipe(
filter(event => event instanceof NavigationEnd)
).subscribe((event: NavigationEnd) => {
const consent = this.consentService.consentSubject.value;
if (!consent.analytics) return;
gtag('event', 'page_view', {
page_path: event.urlAfterRedirects,
});
});
}Angular's zone-based change detection means consent updates propagate automatically — no manual subscription management needed for template bindings.
SSR and Hydration Timing: Next.js and Nuxt
Server-side rendering introduces a consent timing problem that doesn't exist in client-only SPAs. The server renders the full HTML, sends it to the browser, and then the framework "hydrates" — attaching event listeners and making the page interactive. During the gap between HTML delivery and hydration completion, scripts in the <head> can execute.
This hydration gap is where consent violations happen in SSR apps:
- Next.js renders the page on the server, including any analytics
<Script>components - The HTML arrives at the browser with the rendered content
- Scripts in
<head>(GTM, GA4, pixels) begin executing - React hydration starts and the consent provider initializes
- The consent check runs — but scripts have already fired
The fix for Next.js is to use the Script component with the strategy prop, combined with consent gating:
// app/layout.tsx (Next.js App Router)
import Script from 'next/script';
export default function RootLayout({ children }) {
return (
<html>
<head>
{/* Consent default — inline, runs immediately */}
<script
dangerouslySetInnerHTML={{
__html: `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('consent', 'default', {
'ad_storage': 'denied',
'analytics_storage': 'denied',
'ad_user_data': 'denied',
'ad_personalization': 'denied',
'wait_for_update': 500
});
`,
}}
/>
</head>
<body>
{children}
{/* GTM loads after consent default is set */}
<Script
id="gtm"
strategy="afterInteractive"
dangerouslySetInnerHTML={{
__html: `(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','GTM-XXXX');`,
}}
/>
</body>
</html>
);
}Key points:
- The consent default is an inline
<script>in<head>— it runs before hydration, before GTM, before anything. Don't use Next.js'sScriptcomponent for this; it may defer execution. - GTM uses
strategy="afterInteractive", which loads after hydration. This ensures the consent default is always set before GTM initializes. - Don't use
strategy="beforeInteractive"for tracking scripts — it executes during the hydration gap, before your React consent logic is active.
For Nuxt 3, the equivalent is using useHead for the inline consent default and the <Script> component from unhead with deferred loading for GTM.
Script Blocking in SPAs: Dynamic Import Gating
The type="text/plain" trick that CMPs use on multi-page sites — changing a script tag's type so the browser doesn't execute it, then swapping it back on consent — doesn't work cleanly in SPAs. Script tags aren't being reloaded on navigation, and dynamically-loaded modules can't be "un-imported."
The SPA-native approach is dynamic import gating: only import() the tracking module when consent is granted.
// lib/analytics.ts
let analyticsLoaded = false;
export async function initAnalytics() {
if (analyticsLoaded) return;
const consent = window.__cookiebeam?.getConsent();
if (!consent?.analytics) return;
// Dynamic import — the module is only fetched
// and executed when this function runs
const { initGA4 } = await import('./ga4-setup');
const { initHotjar } = await import('./hotjar-setup');
initGA4();
initHotjar();
analyticsLoaded = true;
}
export async function initMarketing() {
const consent = window.__cookiebeam?.getConsent();
if (!consent?.marketing) return;
const { initFBPixel } = await import('./fb-pixel-setup');
const { initTikTokPixel } = await import('./tiktok-setup');
initFBPixel();
initTikTokPixel();
}Call these from your consent change handler:
window.addEventListener('cookiebeam:consent', (e) => {
const consent = e.detail;
if (consent.analytics) initAnalytics();
if (consent.marketing) initMarketing();
});Advantages over type="text/plain" swapping:
- No DOM manipulation — you're not scanning the DOM for script tags and mutating attributes
- Bundler-friendly — dynamic imports work with Webpack, Vite, esbuild, and every modern bundler's code-splitting
- Tree-shakeable — if consent is never granted, the tracking modules are never downloaded (assuming proper code splitting)
- Idempotent — the
analyticsLoadedguard prevents double-initialization on repeated consent events
GTM Container Conditional Loading
Google Tag Manager deserves special attention because it's both a script loader and a consent signal consumer. In an SPA, you have two choices for how GTM interacts with consent:
Option 1: Load GTM always, let Consent Mode gate tags
Load the GTM container on every page, but set Consent Mode defaults before it initializes. Tags inside GTM that support Consent Mode (all Google tags) will respect the consent state. Tags that don't (custom HTML, third-party templates) need their own consent triggers.
This is the simpler approach and works well if most of your tracking is Google-based. The container loads once, persists across route changes, and consent updates propagate to all tags automatically via the dataLayer.
Option 2: Don't load GTM until consent
Defer the entire GTM container until the user grants consent. This is the strictest approach — no tags fire, no pings are sent, no container JavaScript executes until explicit consent.
// Load GTM only after consent
function loadGTM(containerId: string) {
if (document.querySelector(`script[src*="${containerId}"]`)) return;
const script = document.createElement('script');
script.async = true;
script.src = `https://www.googletagmanager.com/gtm.js?id=${containerId}`;
document.head.appendChild(script);
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' });
}
// In your consent handler
window.addEventListener('cookiebeam:consent', (e) => {
if (e.detail.analytics || e.detail.marketing) {
loadGTM('GTM-XXXX');
}
});The trade-off: you lose Advanced Consent Mode's behavioral modeling (which requires the container to load in a restricted state before consent). If data recovery via modeling matters to you, use Option 1. If absolute blocking is your requirement, use Option 2.
Consent Mode v2 in SPAs: Getting the Sequence Right
Consent Mode v2 requires two calls — consent('default', ...) and consent('update', ...) — to fire in the right order. In SPAs, the sequencing is more fragile because of how frameworks manage the page lifecycle. (For a deep dive on the four required parameters and common setup mistakes, see our Consent Mode v2 failures guide.)
The SPA-Specific Sequence
- Inline script in HTML shell:
gtag('consent', 'default', { all four params denied, wait_for_update: 500 }) - GTM or gtag.js loads: reads the default consent state
- App hydrates / mounts: React/Vue/Angular boots up
- CMP initializes: checks for stored consent
- If stored consent exists:
gtag('consent', 'update', { ... })fires immediately - User navigates (virtual): page-view event fires only if
analytics_storageisgranted - User grants consent mid-session:
consent('update', ...)fires, tags re-evaluate
The critical thing to get right: step 6. On a multi-page site, each navigation triggers a full page load, which re-runs the consent default and update cycle naturally. In an SPA, the default never re-runs — it was set once in the HTML shell. The update state persists. So when the user navigates, tags already know the consent state. You don't need to re-fire consent('update', ...) on route changes.
What you do need to handle is consent changes mid-session. If the user opens the banner settings and revokes analytics consent, you need to:
// Revoke consent update
gtag('consent', 'update', {
'ad_storage': 'denied',
'analytics_storage': 'denied',
'ad_user_data': 'denied',
'ad_personalization': 'denied'
});
// Stop firing page views on subsequent navigations
// (your consent hook should handle this reactively)And here's what most implementations miss: revoking consent in Consent Mode doesn't unload scripts. GA4 will stop sending new events, but the gtag.js library is still in memory. Third-party scripts you imported dynamically are still running. In an SPA, once a script is loaded, it stays loaded until the user refreshes the page. Consent Mode handles the Google side gracefully (tags go silent), but non-Google scripts need you to explicitly disable them — or reload the page on consent withdrawal.
How CookieBeam Handles SPA Navigation
CookieBeam's script-gating engine was built for SPAs from the start. Instead of relying on page-load interception, CookieBeam operates at the script execution level, which makes it navigation-model agnostic.
Automatic consent default injection: The CookieBeam loader script injects the consent('default', ...) call synchronously, before your app framework boots. No hydration gap, no race condition with GTM. The loader is a single inline script — it doesn't wait for an external file to download.
Route-change awareness: CookieBeam hooks into history.pushState and history.replaceState to detect SPA navigation. When a virtual navigation occurs, CookieBeam re-evaluates which scripts should be active based on the current consent state. Scripts gated behind marketing consent don't suddenly execute because the user navigated to a new route.
Consent Mode v2 integration: CookieBeam fires consent('default', ...) before any Google tag loads and pushes consent('update', ...) when the user interacts with the banner. Both calls include all four required parameters (ad_storage, analytics_storage, ad_user_data, ad_personalization). The update fires once, not on every route change — because in an SPA, the consent state is persistent.
Dynamic script gating: Third-party scripts managed through CookieBeam's dashboard are blocked until consent, regardless of how they're loaded — <script> tags, dynamic imports, or injected by GTM. When consent is granted, scripts activate. When consent is withdrawn, scripts are deactivated. For scripts that can't be cleanly deactivated (most pixels), CookieBeam surfaces a "page reload required" notice to the user. Learn more about the mechanics in our script blocking guide.
Framework-agnostic events: CookieBeam emits a cookiebeam:consent custom event on the window object whenever consent changes. This works with React, Vue, Angular, Svelte, or plain JavaScript — no framework-specific SDK needed. The event's detail property contains the full consent state, so your app can react to changes without polling.
Implementation Checklist
Use this list before shipping consent in your SPA:
- Consent default fires before your app framework — Check by adding a
console.logto the inline consent script and another to your app's entry point. The consent log must appear first. - No tracking scripts in the HTML shell's
<head>— Move all analytics and marketing scripts to dynamic imports gated on consent. The only scripts in<head>should be the consent default and your app bundle. - Page-view events check consent before firing — Add a breakpoint or log to your route-change analytics handler. Navigate between routes with consent denied — no events should appear in the Network tab going to analytics endpoints.
- Consent changes propagate reactively — Grant consent mid-session and verify that the next navigation fires a page-view event. Revoke consent and verify that navigations stop firing events.
- SSR hydration doesn't leak scripts — If using Next.js/Nuxt, check the Network tab during the hydration gap (between HTML delivery and React/Vue mount). No tracking requests should fire before the framework's consent check runs.
- GTM containers load after consent defaults — In GTM Preview, the consent default must appear in the timeline before "Container Loaded."
- Consent withdrawal is handled — Revoke consent in the banner settings. Google tags should go silent (verify via Network tab). Third-party scripts should stop or a reload prompt should appear.
- All four Consent Mode v2 parameters are present — In both default and update calls. Missing
ad_user_dataorad_personalizationmeans you're running v1 semantics. See the Consent Mode v2 troubleshooting guide for verification steps.
Further Reading
- How to Block Scripts Until Cookie Consent — script blocking mechanics for all site types
- Why 67% of Consent Mode v2 Setups Fail Compliance Checks — the four required parameters, race conditions, and testing methods
- How CMPs Block Scripts — comparing script blocking approaches across CMPs
- How Consent Mode v2 Affects GA4 Reporting — behavioral modeling and modeled data implications
- Cookie Banner Performance & Core Web Vitals — ensuring your consent UI doesn't hurt SEO
- Google's Consent Mode documentation — official reference for the consent API
- Web Vitals for SPAs (web.dev) — Google's guidance on measuring core web vitals in single-page apps