Years ago I wrote about adding a cookie-consent banner and Google Tag Manager to a Hugo site. Both relied on third-party, account-based services (OneTrust, GTM) and the now-dead Osano cookie-consent library. This post describes the setup that replaced them here — free, self-hosted, and opt-in by default, suitable for laws about the European audience:
- vanilla-cookieconsent v3 (MIT, zero-dependency) — vendored into the site, no CDN call before consent.
- Google Analytics 4 via
gtag.jswith Consent Mode v2 — analytics stay denied until the visitor opts in. - Self-hosted fonts — no Google Fonts request, so no IP transfer to a third party.
Why deny-by-default beats “block until consent”
The clean way to make analytics consent-correct is not to withhold the analytics script until the user clicks accept, but to load it with consent defaulted to denied. Google Consent Mode lets gtag.js run in a cookieless state — it sets no _ga cookies and sends no identifying data — until a gtag('consent','update', …) call grants analytics_storage. Your consent banner makes that call. There is no window where a tag can fire ahead of consent, which was the classic failure of the GTM-loads-first approach.
Step 1 — Vendor the library and fonts
Keep everything first-party. Download the consent library into static/ and the font files too:
mkdir -p static/vendor/cookieconsent static/fonts
# vanilla-cookieconsent v3 (pin a version)
curl -fsSL https://cdn.jsdelivr.net/npm/vanilla-cookieconsent@3.1.0/dist/cookieconsent.umd.js \
-o static/vendor/cookieconsent/cookieconsent.umd.js
curl -fsSL https://cdn.jsdelivr.net/npm/vanilla-cookieconsent@3.1.0/dist/cookieconsent.css \
-o static/vendor/cookieconsent/cookieconsent.css
# Example: Open Sans (woff2) via @fontsource
for w in 300 400 600; do
curl -fsSL "https://cdn.jsdelivr.net/npm/@fontsource/open-sans@5.0.28/files/open-sans-latin-${w}-normal.woff2" \
-o "static/fonts/open-sans-latin-${w}-normal.woff2"
done
Then replace any Google Fonts @import in your SCSS with local @font-face rules:
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/open-sans-latin-400-normal.woff2') format('woff2');
}
/* repeat for 300 and 600 */
Step 2 — GA4 with Consent Mode v2
Create a partial layouts/partials/analytics.html. It sets all consent signals to denied before loading gtag.js, and is gated so it loads only on production builds (never hugo server or a staging build):
{{ if eq hugo.Environment "production" }}
{{ with .Site.Params.analytics.ga4 }}
<script>
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: 'granted',
security_storage: 'granted',
wait_for_update: 500
});
gtag('js', new Date());
gtag('config', '{{ . }}');
</script>
<script async src="https://www.googletagmanager.com/gtag/js?id={{ . }}"></script>
{{ end }}
{{ end }}
Add the ID to your site config (empty disables GA entirely):
[params.analytics]
ga4 = "G-XXXXXXXXXX"
Step 3 — The consent banner
Create layouts/partials/consent.html. It loads the vendored library, defines two categories (necessary + analytics), and — crucially — calls gtag('consent','update', …) whenever consent changes:
<script defer src="/vendor/cookieconsent/cookieconsent.umd.js"></script>
<script>
window.addEventListener('load', function () {
if (typeof CookieConsent === 'undefined') return;
function syncGtagConsent() {
if (typeof gtag !== 'function') return;
gtag('consent', 'update', {
analytics_storage: CookieConsent.acceptedCategory('analytics') ? 'granted' : 'denied'
});
}
CookieConsent.run({
guiOptions: { consentModal: { layout: 'box wide', position: 'bottom right' } },
categories: {
necessary: { enabled: true, readOnly: true },
analytics: { autoClear: { cookies: [ { name: /^_ga/ }, { name: '_gid' } ] } }
},
onFirstConsent: syncGtagConsent,
onConsent: syncGtagConsent,
onChange: syncGtagConsent,
language: {
default: 'en',
translations: {
en: {
consentModal: {
title: 'We use cookies',
description: 'Analytics stay off until you accept. See the <a href="/refs/privacy">Privacy page</a>.',
acceptAllBtn: 'Accept',
acceptNecessaryBtn: 'Reject',
showPreferencesBtn: 'Manage preferences'
},
preferencesModal: {
title: 'Cookie preferences',
acceptAllBtn: 'Accept all',
acceptNecessaryBtn: 'Reject all',
savePreferencesBtn: 'Save preferences',
sections: [
{ title: 'Strictly necessary', linkedCategory: 'necessary' },
{ title: 'Analytics', description: 'Google Analytics (GA4) — anonymous visit counts.', linkedCategory: 'analytics' }
]
}
}
}
}
});
});
</script>
The autoClear rule deletes the _ga* cookies automatically if the visitor later withdraws consent.
Step 4 — Wire it into the templates
In the <head>, include the analytics partial and the consent stylesheet:
{{ partial "analytics.html" . }}
<link rel="stylesheet" href="/vendor/cookieconsent/cookieconsent.css">
Before </body>, include the consent partial, and give the footer a link that reopens the preferences modal — vanilla-cookieconsent exposes a data attribute for exactly this:
<a href="#" data-cc="show-preferencesModal">Cookie Settings</a>
{{ partial "consent.html" . }}
Step 5 — Keep analytics out of dev and staging
The eq hugo.Environment "production" guard in Step 2 is what makes this safe across environments:
| Context | hugo.Environment | GA loads? |
|---|---|---|
hugo server (local) | development | no |
staging build (HUGO_ENV=staging) | staging | no |
| production build | production | yes |
The consent banner still renders everywhere, so you can style and test it locally, but Google Analytics only ever loads on the real production site — staging traffic never pollutes your stats.
Result
The page makes no third-party request before consent (the banner and fonts are first-party; gtag.js loads in denied/cookieless mode). Accepting analytics flips Consent Mode to granted and GA starts setting _ga*; rejecting — or withdrawing later via Cookie Settings — keeps them off and clears them. It is free, self-hosted, version-controlled, and has no CMP account to manage.
Enjoy coding!