Adding Your Own Consent Management Platform (CMP)

The boilerplate gates delayed-phase, consent-dependent scripts (analytics, martech, chat widgets, etc.) behind scripts/consent-check.js. Out of the box this ships with a dummy implementation that declines consent by default. This guide walks through how the consent gate works and how to replace the dummy with a real consent management platform (CMP), with worked examples for OneTrust and Cookiebot.

During the delayed phase, loadDelayed() in scripts.js imports consent-check.js instead of loading third-party scripts directly:

function loadDelayed() {
  import('./consent-check.js');
  // load anything that can be postponed to the latest here
}

consent-check.js is responsible for three things: determining whether the visitor has consented, dispatching a consent.update event so any other code on the page can react to the consent state, and importing consented.js — where your analytics, martech, and other consent-dependent code actually lives — once (and only once) consent has been granted. The default implementation looks like this:

let consentedLoaded = false;

function hasConsent() {
  const consent = new URLSearchParams(window.location.search).get('consent');
  if (consent !== null) {
    return ['accept', 'true', '1', 'yes'].includes(consent.toLowerCase());
  }
  // default: decline
  return false;
}

function loadConsented() {
  if (consentedLoaded) return;
  consentedLoaded = true;
  import('./consented.js');
}

function onConsentUpdate() {
  const consented = hasConsent();
  window.dispatchEvent(new CustomEvent('consent.update', { detail: { consented } }));
  if (consented) {
    loadConsented();
  }
}

onConsentUpdate();

The ?consent=accept / ?consent=decline query parameter override is only there so you can exercise the consented path locally before a real CMP is wired up.

Wiring Up a Real CMP

To connect a real CMP, replace the contents of consent-check.js so that it: loads the CMP's script (typically by injecting a <script> tag from within consent-check.js, rather than adding it to head.html, to keep it out of the critical rendering path), listens for the CMP's own consent-change callback or event instead of hasConsent(), and calls loadConsented() and dispatches consent.update whenever the visitor grants consent for the categories your project relies on (analytics, targeting, etc.).

Note: because consent-check.js only runs in the delayed phase, the consent banner itself will typically render a few seconds after the page has loaded rather than immediately. This is intentional — it keeps the CMP script out of the critical rendering path and off the main thread during LCP. If your legal or compliance requirements mandate that the banner render immediately, load the CMP's banner script earlier (for example from loadEager() in scripts.js), while still keeping the actual consent-gated tracking code inside consented.js.

OneTrust

OneTrust exposes a global OptanonWrapper() callback that it invokes when the banner initializes and again whenever the visitor updates their preferences, along with a window.OnetrustActiveGroups string listing the active consent category IDs (for example C0001 Strictly Necessary, C0002 Performance, C0003 Functional, C0004 Targeting/Advertising).

// scripts/consent-check.js
const ONETRUST_SRC = 'https://cdn.cookielaw.org/scripttemplates/otSDKStub.js';
const ONETRUST_DATA_DOMAIN = 'your-onetrust-domain-id';

let consentedLoaded = false;

function loadConsented() {
  if (consentedLoaded) return;
  consentedLoaded = true;
  import('./consented.js');
}

// OneTrust calls this whenever the banner initializes or preferences change
window.OptanonWrapper = () => {
  const activeGroups = window.OnetrustActiveGroups || '';
  // adjust to the categories your project actually needs
  const consented = activeGroups.includes('C0004');
  window.dispatchEvent(new CustomEvent('consent.update', { detail: { consented } }));
  if (consented) {
    loadConsented();
  }
};

function loadOneTrust() {
  const script = document.createElement('script');
  script.src = ONETRUST_SRC;
  script.type = 'text/javascript';
  script.charset = 'UTF-8';
  script.setAttribute('data-domain-script', ONETRUST_DATA_DOMAIN);
  document.head.append(script);
}

loadOneTrust();

Cookiebot

Cookiebot dispatches window events (CookiebotOnConsentReady, CookiebotOnAccept, CookiebotOnDecline) and exposes the current state on window.Cookiebot.consent (necessary, preferences, statistics, marketing).

// scripts/consent-check.js
const COOKIEBOT_SRC = 'https://consent.cookiebot.com/uc.js';
const COOKIEBOT_CBID = 'your-cookiebot-id';

let consentedLoaded = false;

function loadConsented() {
  if (consentedLoaded) return;
  consentedLoaded = true;
  import('./consented.js');
}

function onCookiebotConsent() {
  const { consent } = window.Cookiebot;
  // adjust to the categories your project actually needs
  const consented = consent.statistics || consent.marketing;
  window.dispatchEvent(new CustomEvent('consent.update', { detail: { consented } }));
  if (consented) {
    loadConsented();
  }
}

window.addEventListener('CookiebotOnConsentReady', onCookiebotConsent);
window.addEventListener('CookiebotOnAccept', onCookiebotConsent);
window.addEventListener('CookiebotOnDecline', onCookiebotConsent);

function loadCookiebot() {
  const script = document.createElement('script');
  script.id = 'Cookiebot';
  script.src = COOKIEBOT_SRC;
  script.type = 'text/javascript';
  script.setAttribute('data-cbid', COOKIEBOT_CBID);
  document.head.append(script);
}

loadCookiebot();

Note: Cookiebot's automatic script-blocking mode (data-blockingmode="auto") works by intercepting other <script type="text/plain" data-cookieconsent="..."></script> tags before they execute, which generally requires the Cookiebot script to be the first script in head.html. That approach conflicts with the delayed-loading pattern described here. If you rely on automatic blocking, load Cookiebot in head.html as OneTrust or Cookiebot recommend, and keep only your own project-controlled analytics/martech code (the part you can gate manually) inside consented.js.

Testing

Once a real CMP is wired up, the ?consent= query-parameter override in the dummy implementation goes away, since consent is now driven by the CMP. It's still useful to keep an equivalent override during development — for example, short-circuiting onCookiebotConsent() or OptanonWrapper() when a debug query parameter is present — so you can exercise the consented path without having to accept or decline the real banner on every test.

Regardless of which CMP you use, verify in your browser's Network tab that: consented.js is not requested until consent is granted, the consent.update event fires with the correct detail.consented value on both grant and decline, and no analytics/martech network calls happen before consent is granted.