Skip to main content

Consent-first analytics on Docusaurus with GA4

· 9 min read
Pere Pages
Software Engineer
A cookie-consent banner sitting above a blog, with analytics switched off until the visitor opts in

I wanted to know which posts people actually read — without dropping a tracking cookie on someone the moment they land. This is how the blog runs Google Analytics 4 (GA4) on a static Docusaurus site while keeping it completely off until you opt in.

Most analytics setups are backwards: the tracker loads, sets its cookies, and starts sending data, and then a banner asks for permission it already took. This build inverts that. Nothing is stored and nothing is sent until the visitor clicks Accept — and if they never do, the site works exactly the same, just blind.

The shape of the solution

The whole design is one idea: analytics is denied by default, and a single opt-in flips it on. Everything else is plumbing to make that idea hold on a static site.

There are three ways you can handle analytics on a personal site, and only one of them is both useful and clean:

ApproachWhat you learnCompliant without asking first
No analytics at allnothingyes
Analytics on by defaulteverything, immediatelyno — it stores before consent
Consent-gated (this build)what opted-in readers allowyes

The consent-gated row is the only one that gives you real usage data and respects the reader's choice. Here's the lifecycle it implements:

Every later section is just one box in that diagram: the default denied state, the banner, the granted flip, and the reset path.

Why analytics has to be off by default

Under the European Union's General Data Protection Regulation (GDPR) and the ePrivacy Directive, you must get a visitor's consent before setting any non-essential cookie — and analytics cookies are non-essential. Consent has to be opt-in: freely given and collected before the cookie is set, not assumed from a pre-ticked box or inferred from continued browsing.

That single requirement is what rules out the "load the tracker, ask later" pattern. If the analytics script sets its cookie on page load, you've already broken the rule by the time the banner renders. So the real constraint isn't "show a banner" — it's don't let the tracker store anything until the answer is yes.

Wiring GA4 into Docusaurus

Docusaurus ships analytics through the @docusaurus/plugin-google-gtag plugin, which comes bundled in preset-classic. The obvious move is to configure it inside the preset:

// The tempting-but-wrong spot
preset: {
gtag: { trackingID: 'G-XXXXXXX', anonymizeIP: true },
}

The problem is ordering. Consent Mode (next section) needs a small script to run in the page <head> — the HyperText Markup Language (HTML) document head — before gtag issues its config command, and Docusaurus renders head tags in plugin order. Buried inside the preset, gtag's position relative to my own script is not something I control.

So I pulled gtag out of the preset and registered it explicitly in plugins, right after a tiny consent plugin:

plugins: [
consentModeDefaultPlugin, // sets the denied-by-default script first
[
'@docusaurus/plugin-google-gtag', // gtag config runs after it
{ trackingID: 'G-XXXXXXX', anonymizeIP: true },
],
// ...
]

anonymizeIP: true tells gtag to truncate the visitor's Internet Protocol (IP) address before storage, so a full IP is never retained.

The piece that makes "off by default" real is Google Consent Mode. Instead of blocking the gtag script, Consent Mode lets it load but withholds storage until you say otherwise. You declare a default consent state up front, and gtag queues everything until that state is updated.

The consent plugin injects one inline script into the <head>:

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',
wait_for_update: 500,
});
// Replay a returning visitor's stored opt-in:
try {
if (localStorage.getItem('cookie-consent') === 'granted') {
gtag('consent', 'update', { analytics_storage: 'granted' });
}
} catch (e) {}

The four signals Consent Mode understands all start at denied:

Consent signalDefault hereGoverns
analytics_storagedeniedanalytics cookies and data collection
ad_storagedeniedadvertising cookies
ad_user_datadeniedsending user data to Google for ads
ad_personalizationdeniedpersonalized advertising

wait_for_update: 500 gives the page half a second for a stored choice to be replayed before gtag decides what to send, so a returning visitor who already accepted isn't briefly measured as denied. Because this script shares the same window.dataLayer gtag uses and runs first, gtag comes up already knowing storage is off.

Here's the load order end to end:

Notice that gtag never has to be blocked or lazy-loaded. It's present the whole time; Consent Mode just keeps it muted.

The banner is a small React component mounted once for the whole site. Docusaurus builds a single-page application (SPA), so I mount it from the theme's Root wrapper — the one component that persists across client-side navigation — rather than per page:

// src/theme/Root.js
export default function Root({ children }) {
return (
<>
{children}
<ConsentBanner />
</>
);
}

On Accept, the banner records the decision and tells Consent Mode to grant analytics storage:

function choose(value) {
localStorage.setItem('cookie-consent', value); // 'granted' | 'denied'
window.gtag('consent', 'update', {
analytics_storage: value === 'granted' ? 'granted' : 'denied',
});
setVisible(false);
}

There's a nice irony here worth calling out: the consent choice itself is not stored in a cookie. It lives in the browser's localStorage under the key cookie-consent. Cookies get sent to a server on every request; this decision never needs to leave the browser, so localStorage is both simpler and lighter. The only cookies on the site are the ones Google Analytics sets after you accept.

MechanismHoldsWritten whenSent to a server?
localStorage['cookie-consent'] (ours)the visitor's yes/no decisionon Accept or Declinenever — stays in the browser
_ga / _ga_* cookies (Google's)analytics visitor and session IDsonly after Acceptyes, to Google

One user-interface (UI) subtlety: the banner starts hidden and only appears after an effect checks localStorage. Docusaurus pre-renders pages with server-side rendering (SSR), and if the component rendered "visible" on the server, a returning visitor who already chose would see the banner flash for a frame before the client hid it. Starting hidden keeps the server markup and the first client render identical, so there's no hydration flash.

Page views are automatic, but I also wanted a few interaction signals. These live in a client module — a script Docusaurus runs globally — that attaches delegated listeners on document, so they survive client-side route changes without re-binding to each page's Document Object Model (DOM):

function track(name, params) {
if (typeof window.gtag === 'function') {
window.gtag('event', name, params);
}
}

Four events cover the interactions I care about:

EventFires whenKey params
outbound_clicka link to another domain is clickedlink_url, link_text
resource_clickan outbound click happens on a /resources pagelink_url, link_text
copy_codethe copy button on a code block is clickedpage
searchthe site-search box settles (debounced)search_term

The subtle win is that this code doesn't need to know about consent at all. It calls gtag('event', ...) unconditionally, and Consent Mode holds those events while analytics_storage is denied — nothing is sent until the visitor accepts. No if (consented) checks scattered through the event code; the gate lives in one place.

Giving control back: the privacy page

Consent you can't withdraw isn't really consent. A /privacy page (written in MDX — Markdown with embedded components) explains what's collected and embeds a small control that resets everything:

function reset() {
localStorage.removeItem('cookie-consent');
window.gtag('consent', 'update', { analytics_storage: 'denied' });
window.location.reload(); // banner reappears, choice is fresh
}

It clears the stored decision, flips Consent Mode back to denied, and reloads so the banner comes back. A "Legal" link in the site footer points at the page, so it's reachable from anywhere.

Gotchas worth remembering

A handful of things that were easy to get wrong:

  • Plugin order is head-tag order. The consent-default script only works because its plugin is registered before gtag. Reorder them and gtag configures before storage is denied.
  • A consent choice is not a cookie. Storing the decision in localStorage sidesteps needing consent to store the consent — and never travels to a server.
  • Start the banner hidden. Rendering it visible on the server causes a flash for returning visitors; gate visibility behind a client-side localStorage check.
  • Let Consent Mode do the gating. Custom events fire unconditionally and stay queued until Accept, so there's no consent logic to duplicate.
  • Anonymize the IP. anonymizeIP: true truncates the address before it's stored, which is a cheap, sensible default.

The result is analytics I can actually feel fine about: a reader who declines gets a site that behaves identically, and a reader who accepts is measured only for what they agreed to.

References