Google Tag Manager in the Hugo Ecosystem

Table of Contents
Introduction#
I recently worked on adding analytics to a Hugo project. Usually, adding a tracking script takes just five minutes. But doing it the right way is a different story. If we want it to be fast and respect user privacy, it becomes a more interesting challenge.
Below, I have shared my notes and a step-by-step guide for Google Tag Manager and Google Analytics 4. I focused on keeping the code clean. I also made sure the Cookie Consent really works, rather than just looking like it does.
Why do we separate GTM and GA4?#
First, let’s cover a bit of architecture, because I often see confusion here.
Google Tag Manager (GTM) is our “container.” It manages what loads on the page and when. The main benefit is that once you add it to the code, you can control everything else from the UI. You don’t need to call a developer for every change.
Google Analytics 4 (GA4) is just one of the tools, or “tags,” that we put inside this container.
My recommendation is simple. The website should only load the lightweight GTM code. Then, GTM decides if it should run the heavier GA4 script. This gives us full control over when we start tracking the user. Hint: we should only do it after they say yes :D
Strona Hugo
└─ GTM (ładowany zawsze jako zarządca)
└─ GA4 (ładowany warunkowo, po zgodzie)
Step 1: Foundations (Account Setup)#
Before I touch the code, I always make sure the foundations are ready. In this case, it means generating two key IDs that will be our reference points for the whole process.
Google Analytics 4 (GA4): First, I created a new property at analytics.google.com. This is our destination, all the data we collect will end up here. The most important thing from this step is the Measurement ID, which looks like G-XXXXXXXX.
Google Tag Manager (GTM): Next, at tagmanager.google.com, I set up a “Web” container. This is our “control panel” or “container” for scripts. From here, I got the GTM ID (format GTM-XXXXXXXX). This is the only ID that will go directly into our website code.
To save time later, I did one important thing right away in the GTM panel: I prepared a tag to connect GTM with GA4. The setup is simple. I chose “Google Analytics: GA4 Configuration” as the tag type and entered the Measurement ID from the first step.
For the trigger, I set it to All Pages for now. This is a deliberate, temporary simplification. It allows us to verify if the connection works at all before we add complex logic for GDPR consent.
Step 2: Integration with Hugo#
This is where the actual coding begins. Instead of hardcoding scripts directly into the theme files, I used partials. This makes the code much easier to maintain later on.
File Structure#
In the project’s layouts directory, I ensured we have the following structure:
layouts/
├── partials/
│ ├── head-extended.html <-- Tu wpinamy skrypt GTM
│ └── cookie-consent.html <-- Nasz baner RODO
└── _default/
└── baseof.html <-- Główny layout
Configuration and Script Implementation#
Before we paste any code into the templates, we need to keep the project clean. Instead of hardcoding the GTM ID directly into the HTML files, I defined it as a parameter in the hugo.toml configuration file.
This is key for two reasons. First, we separate the configuration from the code. Second, it allows us to load scripts conditionally. If I remove the ID from the configuration (for example, in a local environment), the script simply won’t render.
In the [params] section, I added this variable:
[params]
googleTagManagerID = "GTM-XXXXXXXX"
Now I can safely use this parameter in my templates. If you are using a theme that supports extended_head.html, that is the perfect spot for it. Notice that I wrapped the entire code block in an if statement, this acts as our “safety gate.”
{{ if $.Site.Params.googleTagManagerID }}
<!-- Google Tag Manager -->
<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','{{ $.Site.Params.googleTagManagerID }}');</script>
<!-- End Google Tag Manager -->
{{ end }}
W ramach dobrych praktyk zadbałem również o użytkowników, którzy mają wyłączony JavaScript. Stworzyłem osobny plik gtm-noscript.html w katalogu partials i zaimplementowałem go w głównym szablonie baseof.html, zaraz po otwarciu znacznika <body>.
Tutaj również sprawdzam, czy ID jest zdefiniowane. Dzięki temu zachowujemy porządek w strukturze plików – główny layout pozostaje czytelny, a kod odpowiedzialny za obsługę <noscript> jest odseparowany i łatwy w utrzymaniu .
As a best practice, I also looked out for users who have JavaScript disabled. I created a separate file named gtm-noscript.html in the partials directory. Then, I included it in the main baseof.html template, immediately after the opening <body> tag.
Here I also check if the ID is defined. This keeps our file structure organized. The main layout stays readable, while the code responsible for <noscript> remains isolated and easy to maintain.
{{ if $.Site.Params.googleTagManagerID }}
<!-- Google Tag Manager (noscript) -->
`<noscript>`<iframe src="https://www.googletagmanager.com/ns.html?id={{ $.Site.Params.googleTagManagerID }}"
height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
<!-- End Google Tag Manager (noscript) -->
{{ end }}
Krok 3: Cookie Consent#
Przejdźmy do najciekawszej części, czyli mechanizmu zgód. Mogliśmy oczywiście wpiąć gotowy, ciężki skrypt zewnętrzny, ale zależało mi na rozwiązaniu lekkim (“lightweight”). Postawiłem na czysty JavaScript, bez zbędnych bibliotek typu jQuery.
Podczas implementacji podjąłem dwie kluczowe decyzje architektoniczne:
Inline CSS dla wydajności. Zazwyczaj unikamy stylów w linii, ale tutaj zrobiłem wyjątek. Baner musi wyrenderować się natychmiast, a wrzucenie stylów bezpośrednio w div redukuje tzw. Critical Rendering Path. Nie zmuszamy przeglądarki do pobierania zewnętrznego arkusza CSS tylko po to, by pokazać prosty pasek .
Centralizacja konfiguracji. Teksty trzymamy w hugo.toml, a nie “na sztywno” w kodzie HTML. Dzięki temu obsługa wielojęzyczności (i18n) staje się trywialna – wystarczy dodać wpis w pliku konfiguracyjnym.
Oto jak wygląda przykładowa konfiguracja w hugo.toml:
Step 3: Cookie Consent#
Let’s move on to the most interesting part: the consent mechanism. We could have easily plugged in a heavy, ready-made external script. But I wanted something lightweight. I chose pure JavaScript, avoiding unnecessary libraries like jQuery.
During implementation, I made two key architectural decisions:
TODO: Critical rendering path link and chane translations to i18n files instead of hugo.toml
Inline CSS for performance. Usually, we avoid inline styles, but I made an exception here. The banner needs to render immediately. Putting styles directly into the div reduces the Critical Rendering Path. We don’t want to force the browser to fetch an external CSS file just to display a simple bar.
Centralized configuration. We keep the text in dedicated files—so-called translation tables rather than hardcoding it into the HTML. This makes handling multilingual support (i18n) trivial. You simply need to add an entry to the appropriate configuration file.
Here is what the example configuration looks like in i18n/en.toml:
[cookieConsentMessage]
other = "We use cookies and Google Tag Manager to analyze and improve your experience."
[cookieConsentAccept]
other = "Accept"
[cookieConsentDecline]
other = "Decline"
How It Works (JavaScript Logic)#
The heart of this entire solution is the layouts/partials/cookie-consent.html file. I wrote a simple script there, where the most critical role is played by the notifyGTMConsent function.
Why is this function so important? Because just saving a cookie in the browser is not enough. We need to actively notify Google Tag Manager about this fact.
<div id="cookie-consent-banner" style="{your_styles}">
<div style="{your_styles}">
<div style="flex: 1;">
<p style="{your_styles}">
{{ i18n "cookieConsentMessage" }}
</p>
</div>
<div style="{your_styles}">
<button id="cookie-decline" style="{your_styles}">
{{ i18n "cookieConsentDecline" }}
</button>
<button id="cookie-accept" style="{your_styles}">
{{ i18n "cookieConsentAccept" }}
</button>
</div>
</div>
</div>
<script>
(function() {
'use strict';
const COOKIE_NAME = 'cookie-consent';
const ANALYTICS_COOKIE = 'analytics-consent';
const banner = document.getElementById('cookie-consent-banner');
function getCookie(name) {
const value = '; ' + document.cookie;
const parts = value.split('; ' + name + '=');
return parts.length === 2 ? parts.pop().split(';').shift() : null;
}
function setCookie(name, value, days = 365) {
const expires = new Date(Date.now() + days * 24 * 60 * 60 * 1000);
document.cookie = name + '=' + value + ';expires=' + expires.toUTCString() + ';path=/;SameSite=Lax';
}
function notifyGTMConsent() {
if (window.dataLayer) {
window.dataLayer.push({
event: 'consent_update',
analytics_consent: getCookie(ANALYTICS_COOKIE) === 'true'
});
}
}
function handleConsent(accepted) {
setCookie(COOKIE_NAME, accepted ? 'accepted' : 'declined');
setCookie(ANALYTICS_COOKIE, accepted ? 'true' : 'false');
notifyGTMConsent();
if (banner) banner.style.display = 'none';
}
// Setup event listeners FIRST (avoid race condition)
if (banner) {
const acceptBtn = document.getElementById('cookie-accept');
const declineBtn = document.getElementById('cookie-decline');
if (acceptBtn) acceptBtn.addEventListener('click', function() {
handleConsent(true);
});
if (declineBtn) declineBtn.addEventListener('click', function() {
handleConsent(false);
});
}
// THEN check consent and show banner if needed
if (!getCookie(COOKIE_NAME)) {
if (banner) banner.style.display = 'block';
} else {
notifyGTMConsent();
}
})();
</script>
I took care of two important details in the code to improve the solution’s stability and security.
First, I eliminated a potential race condition through a specific order of operations. I attach event listeners to the buttons first, and only then do I check the cookie state to decide whether to show the banner. This ensures that even if the script executes instantly, we won’t miss any user interaction.
Second, when setting the cookie itself, I enforced the SameSite=Lax flag. This is the current standard for data privacy and protects us from unwanted cross-site cookie transmission.
W kodzie zadbałem o dwa istotne detale, które zwiększają stabilność i bezpieczeństwo rozwiązania. Przede wszystkim wyeliminowałem potencjalny wyścig (race condition) poprzez specyficzną kolejność operacji: najpierw podpinam nasłuchiwanie zdarzeń na przyciskach, a dopiero w następnym kroku sprawdzam stan ciasteczka i decyduję o wyświetleniu banera. Dzięki temu mam pewność, że nawet przy błyskawicznym wykonaniu skryptu nie zgubimy interakcji użytkownika. Dodatkowo, przy samym ustawianiu ciasteczka wymusiłem flagę SameSite=Lax, co jest obecnie standardem w dbaniu o prywatność danych i zabezpiecza nas przed niechcianym przesyłaniem ciastek między witrynami.
Step 4: Business Logic in GTM (Triggers)#
Once we have the code ready on the site, we need to instruct Google Tag Manager on how to interpret the signals coming from our website. This is the moment where we tie everything together and tell GTM: “Hey, only load analytics when the user gives us the green light.”
To achieve this, I configured two elements in the GTM panel.
First, I defined a new Data Layer Variable named analytics_consent. Its job is to listen for and capture the value that our JavaScript sends to the data layer.
With the variable in place, I could create the actual Trigger, which I named Cookie Consent - Analytics Approved. This is where the decision-making “magic” happens. I set the trigger type to Custom Event and configured it to react to the consent_update event.
But here is the key part: it only fires if our analytics_consent variable equals true. This ensures that tags will fire only after explicit user consent.
Final GA4 Tag Configuration#
Finally, I went back to the GA4 tag configuration to connect the dots. I assigned it two parallel triggers. This might look redundant at first glance, but it is crucial for everything to work correctly.
The first trigger is the standard Initialization - All Pages, which loads the container configuration itself. The second is our new Cookie Consent - Analytics Approved, which is responsible for actually firing the tracking.
This setup gives us full coverage for all business scenarios:
- New User: They land on the page, see the banner, and click “Accept.” At that moment, our script sends the consent_update event, which immediately fires GA4. We don’t lose the session.
- Returning User: They visit the page, and the script automatically detects the existing consent cookie. A consent_update fires in the background, and GA4 starts instantly without bothering the user with the banner again.
- User says “No”: The script sends a false signal. Our trigger sees this, fails the condition, and blocks data transmission. We have full GDPR compliance without any messy hacks in the code.
Verification and Final Thoughts#
Finally, before we wrap this up, I always run a quick “sanity check.” We need to be certain that our consent logic actually works, rather than just appearing to work. I usually rely on three verification methods.
Browser Console (DevTools). This is the first line of defense. After loading the page, I open the console and type dataLayer. This lets me check if the array exists at all and if our events are being pushed to it. If I see a history of events there, I know the communication between the site and GTM is working correctly.
GTM Preview Mode. This is hands down the best debugging tool. In preview mode, I see in black and white which tags have launched (status “Fired”) and which are politely waiting for user consent (status “Not Fired”). This is where I finally confirm if our GDPR blocking is watertight.
DebugView in GA4. A quick note for the impatient: standard reports in Google Analytics can have a delay of up to 24 hours. That is why, for testing, I rely exclusively on the “DebugView,” which shows traffic in real-time. If I see data there, it means everything is in order.
Implementing analytics this way gives us two huge architectural benefits. First, we maintain hygiene in our Hugo repository, the code remains clean, free from dozens of tracking scripts pasted “on the fly.” Second, we gain flexibility. When the marketing team asks to add a Pinterest or LinkedIn tag, we can handle it in the GTM panel in minutes, without involving developers or triggering a new site deployment.
Good luck with the implementation!