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:
| Approach | What you learn | Compliant without asking first |
|---|---|---|
| No analytics at all | ||
| Analytics on by default | ||
| Consent-gated (this build) |
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.
Consent Mode: deny first, ask later
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 signal | Default here | Governs |
|---|---|---|
analytics_storage | denied | analytics cookies and data collection |
ad_storage | denied | advertising cookies |
ad_user_data | denied | sending user data to Google for ads |
ad_personalization | denied | personalized 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 consent banner (and why it uses localStorage, not a cookie)
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.
| Mechanism | Holds | Written when | Sent to a server? |
|---|---|---|---|
localStorage['cookie-consent'] (ours) | the visitor's yes/no decision | on Accept or Decline | |
_ga / _ga_* cookies (Google's) | analytics visitor and session IDs | only after Accept |
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.
Custom events that respect consent
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:
| Event | Fires when | Key params |
|---|---|---|
outbound_click | a link to another domain is clicked | link_url, link_text |
resource_click | an outbound click happens on a /resources page | link_url, link_text |
copy_code | the copy button on a code block is clicked | page |
search | the 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
localStoragesidesteps 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
localStoragecheck. - 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: truetruncates 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.
