Keep TermsFeed Free Cookie Consent working when your app changes routes without a page load
Load Cookie Consent once in the host page of your single-page application, and drive page views and route code from the consent state. A single-page application (SPA) changes routes without loading a new page, so a route change never restarts Cookie Consent, which reads tagged scripts only when it starts.
Cookie Consent is a browser script that a <script> tag loads, not a component. It adds the consent banner and the Preferences Center to <body>, outside the element your framework mounts into, so React, Vue, Angular, or Svelte never patches them. In the Next.js App Router, where React renders <html> and <body> itself, Cookie Consent adds the consent banner after hydration.
The setup takes 6 steps and about 15 minutes. You need access to the host HTML page or the root layout of your app, and a test build of the app. Every example starts from the code the TermsFeed generator writes at https://www.termsfeed.com/cookie-consent/, which you create with Install a Cookie Banner on Any Website.
Step 1: Put the loader, the configuration, and the tagged scripts in the host page
Paste the Cookie Consent block and every tagged script into the HTML page that loads your app, outside the element your framework mounts into. In the Next.js App Router, that place is the root layout. The browser parses that page before your framework starts, so the tagged scripts exist when run() reads them. Each framework keeps its host page in its own file.
| Framework | Where the code goes |
|---|---|
| React with Vite | index.html at the project root |
| Create React App | public/index.html |
| Next.js App Router | app/layout.tsx |
| Next.js Pages Router | pages/_document.tsx and pages/_app.tsx |
| Vue 3 with Vite | index.html at the project root |
| Nuxt 3 and 4 | app.head in nuxt.config.ts |
| Angular | src/index.html |
| SvelteKit | src/app.html |
| Astro | The <head> of the layout every page uses |
| Turbo Drive in Rails | The <head> of app/views/layouts/application.html.erb |
This index.html of a Vite app holds Google Analytics 4 tagged with tracking in the <head> and the block the generator wrote, placed before </body>. Replace the measurement ID and the options with your own.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Example App</title>
<script type="text/plain" data-cookie-consent="tracking" async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
<script type="text/plain" data-cookie-consent="tracking">
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-XXXXXXXXXX');
</script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
<!-- Cookie Consent by TermsFeed https://www.TermsFeed.com -->
<script type="text/javascript" src="https://www.termsfeed.com/public/cookie-consent/4.2.0/cookie-consent.js" charset="UTF-8"></script>
<script type="text/javascript" charset="UTF-8">
document.addEventListener('DOMContentLoaded', function () {
cookieconsent.run({"notice_banner_type":"headline","consent_type":"express","palette":"dark","language":"en","page_load_consent_levels":["strictly-necessary"],"website_name":"Example App"});
});
</script>
<noscript>Free cookie consent management tool by <a href="https://www.termsfeed.com/">TermsFeed Generator</a></noscript>
<!-- End Cookie Consent by TermsFeed https://www.TermsFeed.com -->
<!-- Below is the link that users can use to open Preferences Center to change their preferences. Do not modify the ID parameter. Place it where appropriate, style it as needed. -->
<a href="#" id="open_preferences_center">Update cookies preferences</a>
</body>
</html>
Write every tagged script as a plain <script> element, never with a component that recreates script elements, such as next/script.
Under a Content Security Policy, write the same nonce on the loader tag, the inline run() script, and each tagged script. The loader passes the nonce from its own tag to the script and the ping it loads, and the consent banner styles take the nonce from that script. A released inline script runs only with its own nonce or data-nonce, because Cookie Consent never lends it the page nonce. Without a nonce, a released external script needs its host in the policy, or 'strict-dynamic'. Build the policy with Content Security Policy.
Step 2: Load the Cookie Consent loader and call run() once per page load
Load the loader once, from the host page, and call cookieconsent.run() once, never from a component that mounts again on each route. Running the loader again does nothing when the page already has Cookie Consent or the queueing cookieconsent object an earlier loader set. The first copy keeps window.cookieconsent and cookieconsent.openPreferencesCenter().
A second run() call keeps the first instance and returns the first object, or undefined while the first one is still starting. The options of the second call are ignored, including a new language. React Strict Mode, hot module replacement, and route components that call run() again do not add a second consent banner. Keep every option in the first call.
The generator wraps run() in a DOMContentLoaded listener, which suits a host page the browser parses. A run() script that starts after the DOMContentLoaded event, such as a Next.js afterInteractive script, adds a listener that never fires, and no consent banner appears. Start such a script at once when the page is already parsed, with this check.
function startCookieConsent() {
window.cookieconsent.run({
notice_banner_type: 'headline',
consent_type: 'express',
palette: 'dark',
language: 'en',
page_load_consent_levels: ['strictly-necessary'],
website_name: 'Example Site'
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', startCookieConsent);
} else {
startCookieConsent();
}
A run() that starts before <body> exists waits for the DOMContentLoaded event on its own.
Step 3: Send page views on route changes from the consent state
Send a page view from your router only when the visitor accepted tracking, by reading cookieconsent.cookieConsentObject.userConsent. The tagged Google Analytics 4 script runs once, when the visitor accepts or on page load, and sends the first page view itself.
Google Analytics 4 sends a page view on each browser history change by itself. The Enhanced measurement option "Page changes based on browser history events" controls this, according to the Google Analytics help. Keep either that option or your router hook, so each route counts once. Turn the option off in the settings of your web data stream, under Enhanced measurement, to send page views from your router alone.
This function sends a Google Analytics 4 page view for each route after the first, once the visitor accepted tracking. It skips the first route, because the route change hook of each router runs on the first page too. Call it from the route change hook of your router; the comments in the code name that hook for React Router, Vue Router, Angular, and SvelteKit.
var firstRouteSeen = false;
function sendPageViewAfterConsent() {
if (!firstRouteSeen) {
firstRouteSeen = true;
return;
}
var cc = window.cookieconsent && window.cookieconsent.cookieConsentObject;
if (!cc || cc.userConsent.acceptedLevels.tracking !== true || typeof gtag !== 'function') {
return;
}
gtag('event', 'page_view', {
page_location: location.href,
page_title: document.title
});
}
// React Router: useEffect(sendPageViewAfterConsent, [location.pathname]);
// Vue Router: router.afterEach(function () { sendPageViewAfterConsent(); });
// Angular: router.events.pipe(filter(e => e instanceof NavigationEnd)).subscribe(sendPageViewAfterConsent);
// SvelteKit: afterNavigate(sendPageViewAfterConsent);
Tools that record only the page they loaded on, such as HubSpot, need a call per route after the visitor accepted tracking. Push setPath and trackPageView to _hsq, the 2 calls that the API of the HubSpot tracking code gives single-page apps. Skip the first page, which the HubSpot tracking code counts itself.
Step 4: Load code that a route adds later from the consent state
Load a script that a route adds, such as a map, a chat widget, or a video player, from a loader function that checks consent.
This function loads a script for a released category, now or as soon as Cookie Consent releases it. It calls cookieConsentAccepted(), which reads the saved answer from the cookie when the component mounts before run(). Before an answer, acceptedLevels holds the categories released on page load, so in implied mode or with page_load_consent_levels the function loads their scripts at once, as Cookie Consent runs their tagged scripts.
A switch the visitor changes in the Preferences Center changes acceptedLevels before any save. A component that mounts while the Preferences Center is open therefore loads the script of a switch turned on but not saved. Copy both functions, and call this one from the component that needs the script. Find cookieConsentAccepted() in Read the consent state in the browser with cookieConsentObject.
function loadWhenAccepted(category, src) {
function inject() {
if (document.querySelector('script[src="' + src + '"]')) {
return;
}
var script = document.createElement('script');
script.src = src;
script.async = true;
document.head.appendChild(script);
}
var cc = window.cookieconsent && window.cookieconsent.cookieConsentObject;
var releasedOnPageLoad = cc && cc.userConsent.userAccepted !== true && cc.userConsent.acceptedLevels[category] === true;
if (cookieConsentAccepted(category) || releasedOnPageLoad) {
inject();
return;
}
// 'page_load' reaches a listener added before run() with the page-load release, 'choice' each answer
document.addEventListener('cc_userConsentSaved', function listener(event) {
if (event.details[category] === true) {
document.removeEventListener('cc_userConsentSaved', listener);
inject();
}
});
}
// Example: a chat widget on the support route
loadWhenAccepted('functionality', 'https://widget.example.com/chat.js');
Use only categories listed in your notice_banner_purposes_levels, because the cookie fallback of cookieConsentAccepted() reads older cookies, which keep true for a category you have since removed. Check the callback and event arguments in Free Cookie Consent Callbacks and DOM Events.
From the scripts_specific_loaded callback, with a switch on level and callbacks_force: true, grant the Microsoft Clarity, Meta, TikTok, and HubSpot signals. Resolve the consent trigger of a framework script loader, such as Nuxt Scripts, from the same callback. Without callbacks_force, the callback runs only for categories that have tagged scripts. With it, the callback runs for each accepted category on every page load and right after the visitor accepts it.
Send Google Consent Mode signals from user_consent_saved with user_consent_saved_on_page_load: true instead. That callback runs before any tagged script, on every answer and on every page load.
Step 5: Open the Preferences Center from your app
Give your "Cookie settings" link the id open_preferences_center, or a class you pass in open_preferences_center_selector, and the link works on every route. Cookie Consent checks the selector on every click, so links your framework renders after run() open the Preferences Center too. Cookie Consent cancels the default action of the click but lets the click through, so, once Cookie Consent runs, href="#" changes nothing. A router link that skips clicks with event.defaultPrevented set leaves the route as it is.
A button that calls cookieconsent.openPreferencesCenter() works once Cookie Consent has started. Calling it earlier throws TypeError, so call window.cookieconsent?.cookieConsentObject?.openPreferencesCenter(), or keep the selector. The selector suits links in menus and footers, and the method suits buttons in your own components. Set up the link with Add a Cookie Settings Link.
Step 6: Keep the consent banner when your framework replaces the body
Frameworks that render inside one element of <body>, such as React, Vue, Angular, and SvelteKit, leave the consent banner alone. Turbo Drive and the Astro router swap the whole <body> on navigation, which removes the consent banner and the Preferences Center with it. The Preferences Center comes back on its next opening, but the consent banner does not, and run() does not start a second instance.
Move the consent banner into the incoming body in the event each router fires before the swap: astro:before-swap in Astro and turbo:before-render in Turbo. The moved element keeps its listeners, so its buttons keep working on the new page. Copy the script for this event from the Astro layout or the Turbo layout, and place it after the Cookie Consent block. The script looks for the element with the id termsfeed-com---nb, or its wrapper .termsfeed-com---nb-interstitial-overlay in the interstitial layout, and moves it.
A site that sets notice_banner_append to beforeend uses append instead of prepend in those scripts. Keep the Cookie Consent block in the <head> of the layout in both frameworks, where each router leaves it in place.
Declare the cookieconsent global for TypeScript
Declare window.cookieconsent in a .d.ts file, so TypeScript accepts calls to the global without any. The types cover the members a site reads: run(), openPreferencesCenter(), and cookieConsentObject with the current choices. Both openPreferencesCenter and cookieConsentObject are optional, because they exist only once Cookie Consent has started.
// types/cookieconsent.d.ts
type CookieConsentCategory = 'strictly-necessary' | 'functionality' | 'tracking' | 'targeting';
interface CookieConsentObject {
userConsent: {
acceptedLevels: Record<CookieConsentCategory, boolean>;
userAccepted: boolean;
};
userConsentToken?: string;
openPreferencesCenter(): void;
isLevelInPurposes(level: CookieConsentCategory): boolean;
}
interface CookieConsentGlobal {
run(options: Record<string, unknown>): CookieConsentObject | undefined;
openPreferencesCenter?: () => void;
cookieConsentObject?: CookieConsentObject;
}
declare global {
interface Window {
cookieconsent?: CookieConsentGlobal;
_hsq?: unknown[][];
}
}
export {};
The declaration is the same in React, Next.js, Vue, and Nuxt. The Angular service declares the members it reads inside its own file instead.
React: Paste the Cookie Consent code into index.html
Paste the loader, the configuration, and the tagged scripts into index.html, with <div id="root"></div> as the mount element and <script type="module" src="/src/main.jsx"></script> as the entry. Vite rewrites the module script and leaves the other tags as written. A Create React App project keeps index.html in public/ and has no module script line, because Create React App injects its bundle at build time.
Leave the consent banner to Cookie Consent instead of rebuilding it from React state. A component that needs the answer reads cookieConsentAccepted() when it mounts, then listens for the cc_userConsentSaved event on window for later answers.
A React button opens the Preferences Center with optional chaining, which keeps the click silent before Cookie Consent has started.
export function CookieSettingsButton() {
return (
<button
type="button"
onClick={() => window.cookieconsent?.cookieConsentObject?.openPreferencesCenter()}
>
Cookie settings
</button>
);
}
Next.js: Load Cookie Consent with next/script from the root layout
Load the Cookie Consent loader with next/script and strategy="beforeInteractive", and call run() from a second script with strategy="afterInteractive". The Next.js documentation names cookie consent managers as a use for beforeInteractive, which runs once per document load and never again on a client-side navigation. The Next.js documentation says an afterInteractive script loads after some or all of the hydration.
Keep the configuration in one module that both routers share. The run() script carries the document.readyState check.
// lib/cookie-consent.ts
export const cookieConsentRunScript = `
function startCookieConsent() {
window.cookieconsent.run({
notice_banner_type: 'headline',
consent_type: 'express',
palette: 'dark',
language: 'en',
page_load_consent_levels: ['strictly-necessary'],
website_name: 'Example Site'
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', startCookieConsent);
} else {
startCookieConsent();
}
`;
export const ga4ConfigScript = `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-XXXXXXXXXX');
`;
Write the tagged scripts as plain <script> elements in the <head>. The next/script component inserts afterInteractive scripts from the client, in no fixed order with the run() script. A tagged script that arrives after Cookie Consent has read the page never runs. The external tag does not carry async, because React moves a script with src and async into the head and de-duplicates it.
Load Cookie Consent in the Next.js App Router from app/layout.tsx
The root layout stays mounted across client-side navigations, so run() runs once per document.
// app/layout.tsx
import Script from 'next/script';
import type { ReactNode } from 'react';
import { cookieConsentRunScript, ga4ConfigScript } from '@/lib/cookie-consent';
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<head>
<script
type="text/plain"
data-cookie-consent="tracking"
src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"
/>
<script
type="text/plain"
data-cookie-consent="tracking"
dangerouslySetInnerHTML={{ __html: ga4ConfigScript }}
/>
</head>
<body>
{children}
<footer>
<a href="#" id="open_preferences_center">Update cookies preferences</a>
</footer>
<Script
src="https://www.termsfeed.com/public/cookie-consent/4.2.0/cookie-consent.js"
strategy="beforeInteractive"
/>
<Script id="cookie-consent-run" strategy="afterInteractive">
{cookieConsentRunScript}
</Script>
<noscript>
Free cookie consent management tool by <a href="https://www.termsfeed.com/">TermsFeed Generator</a>
</noscript>
</body>
</html>
);
}
Load Cookie Consent in the Next.js Pages Router from _document.tsx and _app.tsx
Put the loader and the tagged scripts in pages/_document.tsx, and call run() from pages/_app.tsx. Next.js renders _document only on the server, so the tagged scripts in its <Head> reach the HTML untouched. The Pages Router accepts a beforeInteractive script only inside _document.
// pages/_document.tsx
import { Html, Head, Main, NextScript } from 'next/document';
import Script from 'next/script';
import { ga4ConfigScript } from '@/lib/cookie-consent';
export default function Document() {
return (
<Html lang="en">
<Head>
<script
type="text/plain"
data-cookie-consent="tracking"
src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"
/>
<script
type="text/plain"
data-cookie-consent="tracking"
dangerouslySetInnerHTML={{ __html: ga4ConfigScript }}
/>
</Head>
<body>
<Main />
<NextScript />
<Script
src="https://www.termsfeed.com/public/cookie-consent/4.2.0/cookie-consent.js"
strategy="beforeInteractive"
/>
</body>
</Html>
);
}
The _app file wraps every page, and its afterInteractive script runs once per document load.
// pages/_app.tsx
import type { AppProps } from 'next/app';
import Script from 'next/script';
import { cookieConsentRunScript } from '@/lib/cookie-consent';
export default function App({ Component, pageProps }: AppProps) {
return (
<>
<Component {...pageProps} />
<Script id="cookie-consent-run" strategy="afterInteractive">
{cookieConsentRunScript}
</Script>
</>
);
}
Next.js route changes: HubSpot page views
This App Router component sends a HubSpot page view on each new path after consent. Render it once in the root layout.
// app/hubspot-route-tracker.tsx
'use client';
import { usePathname } from 'next/navigation';
import { useEffect, useRef } from 'react';
export function HubSpotRouteTracker() {
const pathname = usePathname();
const lastPath = useRef(pathname);
useEffect(() => {
if (pathname === lastPath.current) return;
lastPath.current = pathname;
const consent = window.cookieconsent?.cookieConsentObject;
if (!consent?.userConsent.acceptedLevels.tracking || !window._hsq) return;
window._hsq.push(['setPath', window.location.pathname + window.location.search]);
window._hsq.push(['trackPageView']);
}, [pathname]);
return null;
}
The Pages Router exposes the same moment as the routeChangeComplete event of useRouter().events.
Next.js with Google Consent Mode v2 and next/third-parties
Load the Google tag untagged with GoogleAnalytics from @next/third-parties/google, set every consent signal to denied before it, and send the signals from user_consent_saved. The component loads gtag.js after hydration and does not accept a type="text/plain" attribute, so Cookie Consent never holds it. Until the visitor accepts, the denied defaults let it send only cookieless pings. Cookie Consent does not send any Consent Mode signal of its own.
The Consent Mode defaults must be the first script in <head>, after only the charset meta, above the Google tag, any Google Tag Manager container, and the Cookie Consent block. Put the defaults in the root layout as a beforeInteractive inline script above the loader and the Google tag. The Next.js documentation states that beforeInteractive scripts always go into the <head> of the HTML document, wherever the layout places them, and run in the order they are placed.
// lib/consent-mode.ts
export const consentModeDefaults = `
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',
functionality_storage: 'denied',
personalization_storage: 'denied',
wait_for_update: 500
});
`;
export const cookieConsentRunScript = `
function startCookieConsent() {
window.cookieconsent.run({
notice_banner_type: 'headline',
consent_type: 'express',
palette: 'dark',
language: 'en',
page_load_consent_levels: ['strictly-necessary'],
website_name: 'Example Site',
user_consent_saved_on_page_load: true,
callbacks: {
user_consent_saved: function (details) {
gtag('consent', 'update', {
analytics_storage: details.tracking ? 'granted' : 'denied',
ad_storage: details.targeting ? 'granted' : 'denied',
ad_user_data: details.targeting ? 'granted' : 'denied',
ad_personalization: details.targeting ? 'granted' : 'denied',
functionality_storage: details.functionality ? 'granted' : 'denied',
personalization_storage: details.functionality ? 'granted' : 'denied'
});
}
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', startCookieConsent);
} else {
startCookieConsent();
}
`;
The layout loads the defaults and the loader before hydration. The run() script and the Google tag both load after hydration, in either order, and the update usually reaches the Google tag within its wait_for_update time.
// app/layout.tsx
import Script from 'next/script';
import { GoogleAnalytics } from '@next/third-parties/google';
import type { ReactNode } from 'react';
import { consentModeDefaults, cookieConsentRunScript } from '@/lib/consent-mode';
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
{children}
<Script id="consent-mode-default" strategy="beforeInteractive">
{consentModeDefaults}
</Script>
<Script
src="https://www.termsfeed.com/public/cookie-consent/4.2.0/cookie-consent.js"
strategy="beforeInteractive"
/>
<Script id="cookie-consent-run" strategy="afterInteractive">
{cookieConsentRunScript}
</Script>
</body>
<GoogleAnalytics gaId="G-XXXXXXXXXX" />
</html>
);
}
In this layout, user_consent_saved reads the saved categories in details and sends granted or denied for the signals of each one. A decline sends denied for every optional category, and a withdrawal sends denied as soon as the visitor saves it. Check the signal names against Map Categories to Vendor Consent Signals, and set up the full flow with Google Consent Mode v2.
Vue and Nuxt: Put the Cookie Consent code in index.html or nuxt.config.ts
Keep every tagged script out of .vue files, because the Vue template compiler ignores <script> tags inside component templates. A Vue 3 app with Vite takes the loader, the configuration, and the tagged scripts in index.html. Vue mounts inside <div id="app">, and the consent banner sits outside that element.
Nuxt 3 and 4: Declare the scripts in nuxt.config.ts
Declare the tagged scripts, the loader, and the run() call in the app.head option of nuxt.config.ts, so Nuxt writes them into the HTML of every page. Each entry of app.head.script becomes a <script> element: keys such as src and type become its attributes, and innerHTML becomes its content. The tagPosition: 'bodyClose' key places an entry before </body>, the spot the Vite index.html uses for the generator block.
// nuxt.config.ts
export default defineNuxtConfig({
app: {
head: {
script: [
{
type: 'text/plain',
'data-cookie-consent': 'tracking',
async: true,
src: 'https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX',
},
{
type: 'text/plain',
'data-cookie-consent': 'tracking',
innerHTML:
"window.dataLayer = window.dataLayer || []; function gtag(){dataLayer.push(arguments);} gtag('js', new Date()); gtag('config', 'G-XXXXXXXXXX');",
},
{
src: 'https://www.termsfeed.com/public/cookie-consent/4.2.0/cookie-consent.js',
tagPosition: 'bodyClose',
},
{
tagPosition: 'bodyClose',
innerHTML: `
function startCookieConsent() {
window.cookieconsent.run({
notice_banner_type: 'headline',
consent_type: 'express',
palette: 'dark',
language: 'en',
page_load_consent_levels: ['strictly-necessary'],
website_name: 'Example Site'
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', startCookieConsent);
} else {
startCookieConsent();
}
`,
},
],
noscript: [
{
tagPosition: 'bodyClose',
innerHTML:
'Free cookie consent management tool by <a href="https://www.termsfeed.com/">TermsFeed Generator</a>',
},
],
},
},
});
Leave charset out of the script entries, although the generator writes charset="UTF-8" on its tags. The Nuxt head manager keeps only one tag with a charset key, the page character set. With the key, the loader and the run() script never reach the HTML. termsfeed.com serves the script as UTF-8, so the attribute changes nothing.
Nuxt renders the app inside <div id="__nuxt">, and client-side navigation patches that element only. The consent banner keeps its state across routes.
Vue and Nuxt route changes: HubSpot page views
This Nuxt plugin sends a HubSpot page view after each client-side navigation that follows the first page. In a Vue app, put the same afterEach callback on the router you create in src/router/index.js.
// plugins/hubspot-routes.client.ts
export default defineNuxtPlugin(() => {
const router = useRouter();
router.afterEach((to, from) => {
if (from.matched.length === 0) return; // the first page: the HubSpot tracking code counted it
const consent = window.cookieconsent?.cookieConsentObject;
if (!consent?.userConsent.acceptedLevels.tracking || !window._hsq) return;
window._hsq.push(['setPath', window.location.pathname + window.location.search]);
window._hsq.push(['trackPageView']);
});
});
The .client.ts suffix keeps the plugin out of server rendering, where window does not exist.
Load Nuxt Scripts when the visitor accepts a category
Pass a consent trigger from useScriptTriggerConsent() to a Nuxt Scripts script, and grant it from scripts_specific_loaded. Nuxt Scripts loads a script with that trigger only once its consent value resolves to true. The consent option accepts a promise, which suits a callback that runs before or after Vue mounts.
Create the promise in the <head>, before the Cookie Consent block, with a script entry in app.head. Then resolve it from the callback, as in this configuration.
// nuxt.config.ts (a new promise entry and a changed run() entry)
export default defineNuxtConfig({
app: {
head: {
script: [
{
innerHTML:
'window.cookieConsentTracking = new Promise(function (resolve) { window.cookieConsentGrantTracking = resolve; });',
},
// ...the loader entry from "Nuxt 3 and 4: Declare the scripts in nuxt.config.ts"
{
tagPosition: 'bodyClose',
innerHTML: `
function startCookieConsent() {
window.cookieconsent.run({
notice_banner_type: 'headline',
consent_type: 'express',
palette: 'dark',
language: 'en',
page_load_consent_levels: ['strictly-necessary'],
website_name: 'Example Site',
callbacks_force: true,
callbacks: {
scripts_specific_loaded: function (level) {
switch (level) {
case 'tracking':
window.cookieConsentGrantTracking(true);
break;
}
}
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', startCookieConsent);
} else {
startCookieConsent();
}
`,
},
],
},
},
});
Pass the promise to the trigger in app.vue, which reads it only in the browser, because window does not exist during server rendering.
<!-- app.vue -->
<script setup lang="ts">
const trigger = useScriptTriggerConsent({
consent: import.meta.client ? (window as any).cookieConsentTracking : false,
});
useScriptGoogleAnalytics({
id: 'G-XXXXXXXXXX',
scriptOptions: { trigger },
});
</script>
<template>
<NuxtPage />
</template>
Leave Google Analytics out of the tagged app.head entries when Nuxt Scripts loads it, so the page carries one Google tag. For a visitor who declines, the callback never resolves the promise, so the script never loads. Nuxt Scripts does not unload a script after a withdrawal, and from the next page load, scripts_specific_loaded no longer runs for tracking, so the promise never resolves.
Read consent on the Nuxt server with useCookie
Read the cookie_consent_level cookie with useCookie to decide on the server what a page renders. Nuxt decodes cookie values with decodeURIComponent and parses them, so the composable returns the JSON object of the 4 categories. The same code runs on the server and in the browser.
// composables/useTrackingConsent.ts
export function useTrackingConsent() {
const levels = useCookie<Record<string, boolean> | null>('cookie_consent_level', { readonly: true });
const answered = useCookie<boolean | null>('cookie_consent_user_accepted', { readonly: true });
return computed(() => answered.value === true && levels.value?.tracking === true);
}
The cookie_consent_level cookie exists before the visitor answers and holds the categories released on page load, so the composable checks cookie_consent_user_accepted too. Decode the cookie in other languages with Read the Consent State.
Open the Preferences Center from a Vue component
A Vue button calls openPreferencesCenter() on cookieConsentObject with optional chaining.
<template>
<button type="button" @click="openPreferences">Cookie settings</button>
</template>
<script setup lang="ts">
function openPreferences() {
window.cookieconsent?.cookieConsentObject?.openPreferencesCenter();
}
</script>
Angular: Paste the Cookie Consent code into src/index.html and wrap the cookieconsent global in a service
Paste the loader, the configuration, and the tagged scripts into src/index.html, with <app-root></app-root> as the mount element and <base href="/"> in the <head>. The Angular CLI keeps the tags of this file as written and adds its own bundles. Angular strips <script> tags from component templates, so a tagged script never goes into a component.
Load the loader with its own tag in index.html, never through the scripts array of angular.json. That array copies a file into your bundle instead of loading the address TermsFeed serves.
Wrap the cookieconsent global in an Angular service
Create a service that reads cookieconsent.cookieConsentObject, opens the Preferences Center, and sends HubSpot page views after consent. Components then inject the service instead of touching window. The service checks the platform first, so server rendering with @angular/ssr never reaches the browser global.
// src/app/cookie-consent.service.ts
import { DestroyRef, Injectable, PLATFORM_ID, inject } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router } from '@angular/router';
import { filter } from 'rxjs';
type CookieConsentCategory = 'strictly-necessary' | 'functionality' | 'tracking' | 'targeting';
interface CookieConsentObject {
userConsent: {
acceptedLevels: Record<CookieConsentCategory, boolean>;
userAccepted: boolean;
};
openPreferencesCenter(): void;
}
declare global {
interface Window {
cookieconsent?: { cookieConsentObject?: CookieConsentObject };
_hsq?: unknown[][];
}
}
@Injectable({ providedIn: 'root' })
export class CookieConsentService {
private readonly router = inject(Router);
private readonly destroyRef = inject(DestroyRef);
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
hasConsent(category: CookieConsentCategory): boolean {
if (!this.isBrowser) return false;
return window.cookieconsent?.cookieConsentObject?.userConsent.acceptedLevels[category] === true;
}
openPreferences(): void {
if (!this.isBrowser) return;
window.cookieconsent?.cookieConsentObject?.openPreferencesCenter();
}
trackPageViews(): void {
if (!this.isBrowser) return;
let firstNavigation = true;
this.router.events
.pipe(
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
takeUntilDestroyed(this.destroyRef),
)
.subscribe(() => {
if (firstNavigation) {
firstNavigation = false;
return;
}
if (!this.hasConsent('tracking') || !window._hsq) return;
window._hsq.push(['setPath', window.location.pathname + window.location.search]);
window._hsq.push(['trackPageView']);
});
}
}
Start the page views once, from the root component, and render the footer with the link that opens the Preferences Center.
// src/app/app.component.ts
import { Component, inject } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { CookieConsentService } from './cookie-consent.service';
import { SiteFooterComponent } from './site-footer.component';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, SiteFooterComponent],
template: `<router-outlet /><app-site-footer />`,
})
export class AppComponent {
constructor() {
inject(CookieConsentService).trackPageViews();
}
}
The footer link carries the id open_preferences_center.
// src/app/site-footer.component.ts
import { Component } from '@angular/core';
@Component({
selector: 'app-site-footer',
standalone: true,
template: `
<footer>
<a href="#" id="open_preferences_center">Update cookies preferences</a>
</footer>
`,
})
export class SiteFooterComponent {}
Run Cookie Consent in Angular under a Content Security Policy with ngCspNonce
Put the same nonce on <app-root> as ngCspNonce, on the Cookie Consent loader tag, on the inline run() script, and on each tagged script. The Angular documentation sets ngCspNonce on the root element so Angular renders its own <style> elements under the policy. Angular does not add that nonce to the Cookie Consent tags, so your server writes it into each of them.
The server replaces randomNonceGoesHere with a fresh value on every response, in the HTML and in the Content-Security-Policy header.
<body>
<app-root ngCspNonce="randomNonceGoesHere"></app-root>
<script nonce="randomNonceGoesHere" type="text/plain" data-cookie-consent="tracking" async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
<script nonce="randomNonceGoesHere" type="text/plain" data-cookie-consent="tracking">
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-XXXXXXXXXX');
</script>
<!-- Cookie Consent by TermsFeed https://www.TermsFeed.com -->
<script nonce="randomNonceGoesHere" type="text/javascript" src="https://www.termsfeed.com/public/cookie-consent/4.2.0/cookie-consent.js" charset="UTF-8"></script>
<script nonce="randomNonceGoesHere" type="text/javascript" charset="UTF-8">
document.addEventListener('DOMContentLoaded', function () {
cookieconsent.run({"notice_banner_type":"headline","consent_type":"express","palette":"dark","language":"en","page_load_consent_levels":["strictly-necessary"],"website_name":"Example App"});
});
</script>
<!-- End Cookie Consent by TermsFeed https://www.TermsFeed.com -->
<!-- The "Update cookies preferences" link with the id open_preferences_center sits in SiteFooterComponent. -->
</body>
The header allows www.termsfeed.com and the nonce for scripts, and the nonce for styles. Add the hosts your tagged tools load further scripts from, such as www.googletagmanager.com.
Content-Security-Policy: script-src 'self' 'nonce-randomNonceGoesHere' https://www.termsfeed.com; style-src 'self' 'nonce-randomNonceGoesHere'
The CSP_NONCE injection token, the alternative to the ngCspNonce attribute, gives Angular the nonce at runtime for its own styles only. Build the complete policy with A nonce-based Content Security Policy for Cookie Consent.
SvelteKit: Paste the Cookie Consent code into src/app.html
Put the tagged scripts in the <head> of src/app.html and the generator block before </body>, after the element that holds %sveltekit.body%. SvelteKit serves this template as written around every page it renders. The SvelteKit documentation asks for %sveltekit.body% inside a <div>, and hydration touches only that <div>. A block pasted after that <div> stays out of reach of hydration, which replaces the tags pasted inside it.
This app.html carries the tagged Google Analytics 4 scripts and the generator block around the SvelteKit placeholders.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script type="text/plain" data-cookie-consent="tracking" async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
<script type="text/plain" data-cookie-consent="tracking">
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-XXXXXXXXXX');
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
<!-- Cookie Consent by TermsFeed https://www.TermsFeed.com -->
<script type="text/javascript" src="https://www.termsfeed.com/public/cookie-consent/4.2.0/cookie-consent.js" charset="UTF-8"></script>
<script type="text/javascript" charset="UTF-8">
document.addEventListener('DOMContentLoaded', function () {
cookieconsent.run({"notice_banner_type":"headline","consent_type":"express","palette":"dark","language":"en","page_load_consent_levels":["strictly-necessary"],"website_name":"Example Site"});
});
</script>
<noscript>Free cookie consent management tool by <a href="https://www.termsfeed.com/">TermsFeed Generator</a></noscript>
<!-- End Cookie Consent by TermsFeed https://www.TermsFeed.com -->
<!-- Below is the link that users can use to open Preferences Center to change their preferences. Do not modify the ID parameter. Place it where appropriate, style it as needed. -->
<a href="#" id="open_preferences_center">Update cookies preferences</a>
</body>
</html>
Add nonce="%sveltekit.nonce%" to every one of these tags when kit.csp runs in nonce mode. SvelteKit fills the placeholder for scripts you include by hand. Keep tagged scripts out of <svelte:head> in a page component, and load the code a route adds with the loadWhenAccepted() function.
This root layout sends HubSpot page views with afterNavigate, whose from is null on the first page.
<!-- src/routes/+layout.svelte -->
<script>
import { afterNavigate } from '$app/navigation';
let { children } = $props();
afterNavigate(({ from }) => {
if (!from) return; // the first page: the HubSpot tracking code counted it
const consent = window.cookieconsent?.cookieConsentObject;
if (!consent?.userConsent.acceptedLevels.tracking || !window._hsq) return;
window._hsq.push(['setPath', window.location.pathname + window.location.search]);
window._hsq.push(['trackPageView']);
});
</script>
{@render children()}
Astro: Keep the code in the layout head and move the consent banner on each swap
Place the tagged scripts and the Cookie Consent block in the <head> of the layout every page uses, each with is:inline. The is:inline directive makes Astro write a script exactly as you wrote it instead of bundling it. With the router, Astro leaves a head script in place when the new page carries the same script, so a navigation does not start a second Cookie Consent.
Without <ClientRouter />, every link is a full page load, and the block works anywhere in the layout. With the router, keep the block in the <head>. The Astro documentation names one case where inline scripts run again: a visitor who leaves for a page without the script and comes back. That second run does not call run(), because DOMContentLoaded has already fired. This layout carries the tagged Google tag, the Cookie Consent block, and a script that moves the consent banner before each swap.
---
// src/layouts/BaseLayout.astro
import { ClientRouter } from 'astro:transitions';
const { title } = Astro.props;
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}</title>
<ClientRouter />
<script is:inline type="text/plain" data-cookie-consent="tracking" async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
<script is:inline type="text/plain" data-cookie-consent="tracking">
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-XXXXXXXXXX');
</script>
<script is:inline src="https://www.termsfeed.com/public/cookie-consent/4.2.0/cookie-consent.js" charset="UTF-8"></script>
<script is:inline charset="UTF-8">
document.addEventListener('DOMContentLoaded', function () {
cookieconsent.run({"notice_banner_type":"headline","consent_type":"express","palette":"dark","language":"en","page_load_consent_levels":["strictly-necessary"],"website_name":"Example Site"});
});
</script>
<script is:inline>
document.addEventListener('astro:before-swap', function (event) {
var styles = document.head.querySelectorAll('style');
for (var i = 0; i < styles.length; i++) {
if (styles[i].textContent.indexOf('termsfeed-com---') !== -1) {
event.newDocument.head.append(styles[i]);
}
}
var banner = document.getElementById('termsfeed-com---nb');
if (!banner) return;
var node = banner.closest('.termsfeed-com---nb-interstitial-overlay') || banner;
event.newDocument.body.prepend(node);
});
</script>
</head>
<body>
<slot />
<footer>
<a href="#" id="open_preferences_center">Update cookies preferences</a>
</footer>
<noscript>Free cookie consent management tool by <a href="https://www.termsfeed.com/">TermsFeed Generator</a></noscript>
</body>
</html>
Astro 5 and later name the router <ClientRouter />. Astro 3 and 4 name it <ViewTransitions />, imported from the same module.
The last script of the layout runs on astro:before-swap, whose newDocument property holds the incoming page, according to the Astro documentation. Along with the consent banner, it moves the <style> element of Cookie Consent into the incoming <head>. The router keeps only the head stylesheets and scripts that the new page carries in its own head. Without the lines that move the <style> element, the consent banner and the Preferences Center show without their styles after the first navigation.
Send page views to a tool that needs a call per route from astro:page-load, which fires on the first page and after every navigation. Check cookieconsent.cookieConsentObject.userConsent.acceptedLevels.tracking first, and skip the first event for a tool that counts the first page itself.
Turbo Drive in Rails and Hotwire: Load the code in the head and move the consent banner on each render
Put the tagged scripts and the Cookie Consent block in the <head> of app/views/layouts/application.html.erb. The Turbo handbook states that Turbo Drive adds only the head scripts the current page does not already hold, and evaluates body scripts each time it renders a page. A block in the <head> therefore stays in place across Turbo visits, while a block before </body> loads the loader again on every Turbo visit.
The DOMContentLoaded listener around run() waits for the first page to finish parsing, so the block works in the head. Add nonce="<%= content_security_policy_nonce %>" to each of these tags when the app sends a Content Security Policy.
<!DOCTYPE html>
<html lang="en">
<head>
<title>Example App</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<script type="text/plain" data-cookie-consent="tracking" async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
<script type="text/plain" data-cookie-consent="tracking">
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-XXXXXXXXXX');
</script>
<!-- Cookie Consent by TermsFeed https://www.TermsFeed.com -->
<script type="text/javascript" src="https://www.termsfeed.com/public/cookie-consent/4.2.0/cookie-consent.js" charset="UTF-8"></script>
<script type="text/javascript" charset="UTF-8">
document.addEventListener('DOMContentLoaded', function () {
cookieconsent.run({"notice_banner_type":"headline","consent_type":"express","palette":"dark","language":"en","page_load_consent_levels":["strictly-necessary"],"website_name":"Example App"});
});
</script>
<!-- End Cookie Consent by TermsFeed https://www.TermsFeed.com -->
<script>
document.addEventListener('turbo:before-render', function (event) {
var banner = document.getElementById('termsfeed-com---nb');
if (!banner) return;
var node = banner.closest('.termsfeed-com---nb-interstitial-overlay') || banner;
var cachedCopy = event.detail.newBody.querySelector('#termsfeed-com---nb');
if (cachedCopy) {
(cachedCopy.closest('.termsfeed-com---nb-interstitial-overlay') || cachedCopy).remove();
}
event.detail.newBody.prepend(node);
});
</script>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
</head>
<body>
<%= yield %>
<footer>
<a href="#" id="open_preferences_center">Update cookies preferences</a>
</footer>
<noscript>Free cookie consent management tool by <a href="https://www.termsfeed.com/">TermsFeed Generator</a></noscript>
</body>
</html>
Keep the inline run() script identical on every page, because Turbo evaluates a head script whose content differs from the current one.
The script after the Cookie Consent block runs on turbo:before-render, and the Turbo reference documents event.detail.newBody as the <body> that replaces the current one. The data-turbo-permanent attribute does not keep the consent banner in the page. Turbo keeps a permanent element only when the new page holds an element with the same id, and the consent banner exists only in the browser.
Turbo keeps a copy of each page it leaves for restoration visits and previews, and that copy sometimes holds an inactive clone of the consent banner. The script removes any clone before it moves the live consent banner in.
A tool that needs a call per route gets its page views from turbo:load, which fires on the first page and after every Turbo visit. Read the tracking value of acceptedLevels before each call, and ignore the first turbo:load for a tool that counts the landing page on its own.
How to verify Cookie Consent in your single-page app
The checks assume express consent with page_load_consent_levels left out or at the generator value ["strictly-necessary"]. Close every private window, then test a production build in a new one, because a production build runs the code your visitors get. Add debug: true to the run() options, build with npm run build, and serve the output with npm run preview (Vite, Nuxt, SvelteKit, and Astro) or npm start (Next.js), and serve a Create React App build with npx serve -s build. A Rails app runs with bin/rails server -e production. For Angular, build with ng build and serve the output through your server, which sends the Content Security Policy header your visitors get.
Follow 2 links before you answer to confirm that the consent banner stays on screen across route changes, including the body swaps of Turbo Drive and the Astro router. Rebuild and serve the app again after each change.
- Open Chrome DevTools, select the Network tab, type
cookie-consentin the filter box, and load your app. The loadercookie-consent.js, the scriptcookie-consent-code.js, and the ping towww.termsfeed.comappear, and the consent banner shows. - Open the Console. No line contains
violates the following Content Security Policy directive. - Return to the Network tab, change the filter to
collect, and follow 2 links in your app. The consent banner stays on screen and appears once. With a tagged Google tag, no request appears. With the untagged Google tag of Next.js with Google Consent Mode v2 and next/third-parties,collectrequests withgcs=G100appear instead, because the untagged tag sends cookieless pings. The Application tab does not list a_gacookie until you accept. - Click the "I agree" button on the third route.
collectrequests appear without a reload, withgcs=G111under Consent Mode when thetargetingcategory is in use, andgcs=G101without it. - Navigate to 2 more routes. One
collectrequest withen=page_viewappears per route, and the consent banner stays hidden. Change the filter tocookie-consent, and no new request appears. - Open the Application tab, select Cookies and your site. Tick "Show URL-decoded" and confirm that
cookie_consent_levelholds"tracking":true. - Click your cookie settings link, such as "Update cookies preferences", on a route the app rendered after start. The Preferences Center opens on the "Your privacy" tab. Select the tab of the category you accepted, such as "Tracking cookies", and its switch reads "Active".
In the Console, no run() was already called on this page warning appears after a navigation. Remove debug: true before you go live. Run the checks every installation needs with Check That Your Cookie Banner Works.
Common mistakes with Cookie Consent in single-page apps
Search your components and layouts for these mistakes before you ship.
- The loader sits in a component instead of the host page. A loader added from a
useEffectstarts Cookie Consent only when a route that mounts it first renders, so no consent banner shows to a visitor who lands on another route. Load it once, from the host page or the layout head. - The
run()script waits forDOMContentLoadedafter it fired. No consent banner appears. Start the script with thedocument.readyStatecheck. - A tagged script sits in a component or a page. The Vue compiler drops it, Angular strips it from templates, and
useHeador<svelte:head>adds it on a client-side navigation, after Cookie Consent has read the page. Move it to the host page, and load route code with theloadWhenAccepted()function. run()is called again on a route event. A call onastro:page-load, onturbo:load, or after a language switch neither restores the consent banner nor applies new options. Keep the singlerun()call in the host page. Visitors pick the language in the Preferences Center menu for the current page.- Code reads
windowduring server rendering. Nuxt and Angular with@angular/ssrrender pages on the server, where the global does not exist. Read Cookie Consent inonMounted, in a.clientplugin, in an event listener, or afterisPlatformBrowser. - A tagged inline script lacks the nonce. Under a nonce-based policy, the browser blocks the released inline script, and the Console reports it. Write the nonce on every tagged script.
- The Astro consent banner shows without its styles after a navigation. Keep the lines that move the Cookie Consent
<style>element in theastro:before-swapscript. - A Turbo restoration visit shows 2 consent banners. Keep the line that removes the cached copy in the
turbo:before-renderscript. - The SvelteKit block sits inside the body placeholder. Hydration replaces the content of that
<div>. Paste the block after the<div>, before</body>.
A consent banner that appears unstyled points to a blocked or removed <style> element; on Astro, check the astro:before-swap script first. A consent banner that never appears, even on the first page, has other causes. Fix an unstyled consent banner with Ad Blockers and CSP Hide the Cookie Banner, and a missing one with Cookie Banner Not Appearing.