Engineering4 min read
Handling cookie consent in single-page apps
How to handle cookie consent in React, Vue and other SPAs: shared consent state, route-change tracking, SSR hydration, gated embeds and instant withdrawal.
In a single-page app (SPA), the page never fully reloads, so cookie consent has to live in application state rather than in a script that runs once. Store the visitor’s choice, share it with every component, activate or block scripts when it changes, send route-change page views only with consent, and make withdrawal take effect immediately rather than on the next page load.
Why is cookie consent harder in a single-page app?
Most consent patterns assume full page loads, while an SPA loads once and then swaps views with JavaScript. That breaks four assumptions:
- Scripts load once. If analytics was blocked at load and the visitor accepts later, no page load will pick it up. You have to activate it in place.
- Page views are virtual. Route changes do not reload the page, so your router must report page views, and it must check consent first.
- Choices change mid-session. A visitor can accept, browse, then withdraw from a footer link while scripts are already running in memory.
- Server rendering adds hydration. With frameworks such as Next.js or Nuxt, the server’s HTML must match the first client render, but the server may not know the visitor’s choice.
How should I model consent state in an SPA?
Treat consent as a small store that every part of the app reads from, with a subscribe method so components and scripts react to changes. Default every non-essential category to denied, so code that runs before the stored choice is loaded can never leak data.
// consent-store.ts
export type Category = 'necessary' | 'preferences' | 'analytics' | 'marketing';
export type Consent = Record<Category, boolean>;
export const DENIED: Consent = {
necessary: true, preferences: false, analytics: false, marketing: false,
};
let state: Consent = DENIED;
const listeners = new Set<() => void>();
export const consentStore = {
get: (): Consent => state,
set(next: Partial<Consent>) {
state = { ...state, ...next, necessary: true };
listeners.forEach((notify) => notify());
},
subscribe(listener: () => void) {
listeners.add(listener);
return () => { listeners.delete(listener); };
},
};
In a real app, set would also persist the choice to a first-party cookie, record it in your consent log and update Google Consent Mode with gtag('consent', 'update', …). In React, useSyncExternalStore connects the store to components, and the same hook can gate embeds that set cookies:
// consent.tsx
import { useSyncExternalStore } from 'react';
import { consentStore, DENIED } from './consent-store';
export function useConsent() {
return useSyncExternalStore(consentStore.subscribe, consentStore.get, () => DENIED);
}
export function VideoEmbed({ videoId }: { videoId: string }) {
const { marketing } = useConsent();
if (!marketing) {
return (
<div className="embed-placeholder">
<p>This video is hosted by YouTube, which may set marketing cookies.</p>
<button type="button" onClick={() => consentStore.set({ marketing: true })}>
Allow marketing cookies and play
</button>
</div>
);
}
return (
<iframe
src={`https://www.youtube-nocookie.com/embed/${videoId}`}
title="Product video"
allowFullScreen
/>
);
}
The third argument to useSyncExternalStore is the snapshot used during server rendering and hydration. Returning DENIED there means the server and the first client render agree, and React re-renders with the real state right after hydration. In Vue, a composable wrapping a reactive ref plays the same role.
For scripts that are not components, subscribe to the store directly and load them with dynamic import() once their category is granted. Our guide to blocking scripts until consent covers the activation patterns.
How do I track route changes only with consent?
Send a virtual page view from the router after each navigation, and check consent at that moment. If the visitor grants analytics consent mid-session, recording the page they are on is reasonable, because that is where the opt-in starts.
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { useConsent } from './consent';
import { trackPageView } from './analytics';
export function PageViewTracker() {
const { pathname } = useLocation();
const { analytics } = useConsent();
useEffect(() => {
if (analytics) trackPageView(pathname);
}, [pathname, analytics]);
return null;
}
Watch for double counting. Some analytics tools can send page views automatically on browser history changes; Google Analytics 4 offers this as an enhanced measurement option. Use either the automatic option or your own tracker, not both.
How do I avoid hydration mismatches and banner flicker?
Decide where the visitor’s choice is known on the first render. There are two sound options:
- Read the consent cookie on the server. Cookies are sent with every request, so server-rendered pages can show or skip the banner correctly from the first byte. This only works if the HTML is not cached and shared between visitors.
- Render the banner client-only. For statically generated or CDN-cached pages, render nothing on the server and show the banner after the app mounts. Position it as a fixed overlay so a late appearance does not cause layout shift; see cookie banners and Core Web Vitals.
Either way, the cookie or storage entry that remembers the choice is generally treated as necessary for honoring that choice; see what counts as a strictly necessary cookie.
How do I handle consent withdrawal mid-session?
Make withdrawal immediate and as easy as giving consent, which GDPR Article 7(3) requires. In practice that means a persistent “Cookie settings” link, usually in the footer, that reopens the preferences without a page load. When a visitor withdraws a category:
- Update the store and persist the new choice.
- If you use Google tags, send
gtag('consent', 'update', …)with the relevant signals set to'denied'. - Stop calling tracking functions; store-gated code does this automatically.
- Delete first-party cookies the withdrawn category set, where you know their names and domains. Your scripts cannot delete cookies set on other domains.
- Reload the page if any withdrawn script is already running. Libraries keep timers, listeners and in-memory IDs, and a reload is the only reliable way to clear them.
A reload after withdrawal is a fair trade-off: it is rare, and the alternative is running code you no longer have permission to run.
Bottom line
In an SPA, consent is state: default to denied, share one store, gate scripts and embeds on it, send page views only with consent and make withdrawal take effect at once. Tools like Cookiesh can take on much of this plumbing, with a React SDK that provides CookieshProvider and a useConsent() hook, plus a consent:updated event for everything else.
This article is general information, not legal advice. For decisions about your specific situation, consult a qualified privacy professional.
Frequently asked questions
Do I need to show the cookie banner again after every route change?
No. Show it until the visitor makes a choice, store that choice, and keep a persistent link to the settings. Ask again only when your purposes or vendors change materially or the stored choice expires.
Can I store the consent choice in localStorage instead of a cookie?
Both work in the browser, but a cookie is also sent to your server with each request, so server-rendered pages can show the right state on first render. localStorage is only readable by client-side code after the app loads.
Do framework script components like next/script handle consent for me?
No. Helpers such as the Script component in Next.js control when a script loads relative to hydration, not whether the visitor agreed to it. Render them conditionally on your consent state so they are not added to the page before an opt-in.
Sources
Cookiesh Editorial Team
Privacy engineers and compliance specialists writing practical guidance on consent, cookies and data protection for product teams.