Self-hosted cookie consent + GA4 Consent Mode v2 on Hugo

June 2026 · 5 minute read
The following assume basic knowledge of GDPR, Hugo installed and an existing Hugo project. This is the modern replacement for two older posts here that used OneTrust + Google Tag Manager.

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.js with 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.

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

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"

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:

Contexthugo.EnvironmentGA loads?
hugo server (local)developmentno
staging build (HUGO_ENV=staging)stagingno
production buildproductionyes

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!

Above opinions and any mistakes are my own. I am not affiliated in any way with companies, or organizations mentioned above. The code samples provided are licensed under the Apache 2.0 License and rest content of this page is licensed under the Creative Commons Attribution 3.0 License, except if noted otherwise.