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.

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.

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.

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.

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.

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>
  );
}

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.

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>
  );
}

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.

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.

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 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>

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 {}

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.

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()}

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.

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.

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.

  1. Open Chrome DevTools, select the Network tab, type cookie-consent in the filter box, and load your app. The loader cookie-consent.js, the script cookie-consent-code.js, and the ping to www.termsfeed.com appear, and the consent banner shows.
  2. Open the Console. No line contains violates the following Content Security Policy directive.
  3. 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, collect requests with gcs=G100 appear instead, because the untagged tag sends cookieless pings. The Application tab does not list a _ga cookie until you accept.
  4. Click the "I agree" button on the third route. collect requests appear without a reload, with gcs=G111 under Consent Mode when the targeting category is in use, and gcs=G101 without it.
  5. Navigate to 2 more routes. One collect request with en=page_view appears per route, and the consent banner stays hidden. Change the filter to cookie-consent, and no new request appears.
  6. Open the Application tab, select Cookies and your site. Tick "Show URL-decoded" and confirm that cookie_consent_level holds "tracking":true.
  7. 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.

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 useEffect starts 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 for DOMContentLoaded after it fired. No consent banner appears. Start the script with the document.readyState check.
  • A tagged script sits in a component or a page. The Vue compiler drops it, Angular strips it from templates, and useHead or <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 the loadWhenAccepted() function.
  • run() is called again on a route event. A call on astro:page-load, on turbo:load, or after a language switch neither restores the consent banner nor applies new options. Keep the single run() call in the host page. Visitors pick the language in the Preferences Center menu for the current page.
  • Code reads window during server rendering. Nuxt and Angular with @angular/ssr render pages on the server, where the global does not exist. Read Cookie Consent in onMounted, in a .client plugin, in an event listener, or after isPlatformBrowser.
  • 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 the astro:before-swap script.
  • A Turbo restoration visit shows 2 consent banners. Keep the line that removes the cached copy in the turbo:before-render script.
  • 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.