Skip to content

Google Analytics, Google Tag Manager and DataLayer

This document provides a comprehensive guide to implementing analytics tracking in Terra projects using Google Analytics (GA), Google Tag Manager (GTM), and the DataLayer. You’ll learn how Terra integrates these tools into Single Page Applications (SPAs) and a step-by-step implementation for tracking pageviews across different navigation


Google Analytics (GA) is a web analytics service that tracks and reports website traffic, user behavior, and conversions. It provides insights into how visitors interact with your website, including metrics like page views, session duration, bounce rates, and user demographics. GA helps businesses understand their audience and make data-driven decisions to improve their digital presence.

Google Tag Manager (GTM) is a tag management system that allows you to deploy and manage marketing tags (snippets of code or tracking pixels) on your website without modifying the code directly. Instead of hardcoding analytics scripts, conversion tracking pixels, or remarketing tags into your site, GTM provides a user-friendly interface where you can add, update, and manage these tags through a web-based dashboard. This makes it easier for marketing teams to manage tracking codes independently.

The DataLayer is a JavaScript object that acts as a temporary data storage mechanism between your website and GTM. It’s essentially a structured way to pass information from your website to GTM in a standardized format. The dataLayer holds variables and events that GTM can read and use to fire tags based on specific conditions. Think of it as a messenger that communicates what’s happening on your website to your tracking tools.


At Terra, we build Single Page Applications (SPAs) that don’t perform traditional full-page reloads when navigating between pages. This presents a challenge for traditional analytics tracking, which relies on page load events. To solve this, we implement a virtual pageview system using the dataLayer to manually notify GTM every time a user navigates to a new view.

Our implementation ensures that:

  • GTM receives accurate pageview data even during client-side navigation
  • Analytics tags fire correctly without requiring full page reloads
  • We maintain consistent tracking across all user interactions
  • We prevent duplicate tag execution that could pollute our analytics data

To integrate our SPA behavior with GTM’s necessities, we use virtual pageviews that we send manually to the dataLayer for our Tag Manager to interpret. To do this, we need to add our page views at three key points:

When the page first loads, we need to wait until our GTM is active and send our first pageview to the dataLayer. We are going to take advantage of our boostify onload method for that and use it in our Project.js file:

class Project {
constructor() {
...
}
async init() {
try {
...
} catch (err) {
console.log(err);
} finally {
const tl = this.gsap.timeline({
defaults: {
duration: 0.1,
ease: "power1.inOut",
},
onStart: () => {
if (this.DOM.boostifyScripts.length > 0) {
this.boostify.onload({
maxTime: 1200,
callback: async () => {
...
if (!window.dataLayer) window.dataLayer = [];
window.dataLayer.push({
event: "VirtualPageview",
virtualPageURL: window.location.href, // full URL
virtualPageTitle: document.title, // Page title
virtualPagePath: window.location.pathname, // Path w/o hostname
});
},
});
}
},
});
...
}
}
}
export default Project;

This ensures that, once our project has loaded and our Boostify scripts are finished loading too, we send a first page view to the dataLayer with information about this first page the user is visiting.

The important bit here is that we need GTM to have loaded for it to function, but since we have it in Project after loading all the framework and boostify scripts, and with the extra 1200 ms we give it after finishing loading the scripts, GTM will have loaded.

When we move from page to page, we need to send another virtual page view to our GTM, and we’ll do this using our SWUP’s page:view hook.

class Core {
constructor(payload) {
...
}
async init() {
...
}
events() {
...
this.swup.hooks.on("page:view", async (data) => {
if (!window.dataLayer) window.dataLayer = [];
window.dataLayer.push({
event: "VirtualPageview",
virtualPageURL: window.location.href, // full URL
virtualPageTitle: document.title, // Page title
virtualPagePath: window.location.pathname, // Path w/o hostname
virtualPageReferrer: window.lastURL ? window.lastURL : window.location.protocol + "//" + window.location.host + data?.from?.url + data?.from?.search, // Referrer, if there is one
});
window.lastURL = null;
...
});
}
contentReplaced() {
...
}
willReplaceContent() {
...
}
}
export default Core;

So here we need to also add the referrer, and we’ll make sure it comes with parameters too.

When we search, we append a URL parameter with the search term. Since this creates a new URL, we need to also send the page view when this happens, so we’ll need to head to our search functionality, be it through third party services like Algolia or using our native CMS’s endpoints, and when we’re finished adding the parameters to the URL, push a new pageview again into the dataLayer.

We’ll use the same code as we’ve used in the previous instance, taking care to send the correct referrer. So it would look something like this to set the page search in the address bar:

updateUrl(){
const url = new URL(window.location);
if(this.searchQuery) {
url.searchParams.set('q', this.searchQuery);
} else {
url.searchParams.delete('q');
}
window.history.replaceState({}, '', url);
setPageViewOnSearch()
}

And something like this to send the new pageview:

export const setPageViewOnSearch = ({referrer}) => {
if(!window.dataLayer) window.dataLayer = [];
window.dataLayer.push({
event: "VirtualPageview",
virtualPageURL: window.location.href, // full URL
virtualPageTitle: document.title, // Page title
virtualPagePath: window.location.pathname, // Path w/o hostname
virtualPageReferrer: window.lastURL ? window.lastURL : window.location.protocol + "//" + window.location.host + data?.from?.url + data?.from?.search, // Referrer, if there is one
});
window.lastURL = window.location.href;
}

We set the lastURL as a referrer since there is no ‘real’ referrer because we have not changed pages.


For this, we have two actions we perform to ensure no analytics are being polluted with double / triple / … tags:

We have a custom SUWP plugin that is in charge of tagging every new script that is injected with the data-swup-ignore-script tag, so it does not get re-executed on page transition and does not append more new scripts to the page.

import Plugin from "@swup/plugin";
export default class SwupIgnoreInjectedScriptsPlugin extends Plugin {
name = "SwupIgnoreInjectedScriptsPlugin";
mount() {
const swupIgnorePatterns = [
"googletagmanager.com",
"clarity",
"cookiebot",
"clickcease",
"facebook",
];
const tagScript = (script) => {
if (!script || script.tagName !== "SCRIPT") return;
const src = script.getAttribute("src") || "";
const code = script.textContent || "";
const matches = swupIgnorePatterns.some(
(p) => src.includes(p) || code.includes(p)
);
if (matches && !script.hasAttribute("data-swup-ignore-script")) {
script.setAttribute("data-swup-ignore-script", "");
}
};
// 1️⃣ Tag existing scripts immediately
document.querySelectorAll("script").forEach(tagScript);
// 2️⃣ Watch for new scripts dynamically added to DOM
const observer = new MutationObserver((mutations) => {
for (const m of mutations) {
for (const node of m.addedNodes) {
if (node.tagName === "SCRIPT") tagScript(node);
else if (node.querySelectorAll)
node.querySelectorAll("script").forEach(tagScript);
}
}
});
observer.observe(document.documentElement, {
childList: true,
subtree: true,
});
}
}

We have an extra piece of code to add to our Core that will delete all duplicated scripts / tags from our HTML:

this.swup.hooks.on("page:view", async (data) => {
...
const seen = new Map(); // store keyword -> first script element
document.querySelectorAll("script").forEach((s) => {
const src = s.getAttribute("src") || "";
const code = s.textContent || "";
const keywords = [
// ... any origins or keywords to find the repeated scripts
];
// Find which keyword this script matches (if any)
const match = keywords.find((p) => src.includes(p) || code.includes(p));
if (match) {
// If we've already seen one for this keyword -> remove duplicates
if (seen.has(match)) {
s.remove();
} else {
// Keep the first one only
seen.set(match, s);
}
}
});
// Remove repeated inline Facebook bootstrap code
const inlineFBQs = Array.from(document.querySelectorAll("script")).filter((el) =>
el.textContent.includes("connect.facebook.net/en_US/fbevents.js")
);
if (inlineFBQs.length > 1) {
inlineFBQs.slice(1).forEach((el) => el.remove());
}
})

  • Before pushing any events, always check if window.dataLayer exists and initialize it if it doesn’t. This prevents errors and ensures the dataLayer is ready to receive events.
  • Ensure GTM has loaded before sending the first pageview. Use appropriate timing mechanisms (like our boostify.onload with a safe timeout) to give GTM enough time to initialize.
  • For SPAs, track pageviews at all key navigation points: Initial page load, Page transitions and Search updates
  • Always include comprehensive data in your pageview events
  • Implement safeguards to prevent duplicate scripts from being injected on page transitions
  • Maintain consistent event names across your implementation. Our standard is "VirtualPageview" for SPA navigation events.

Knowledge Check

Test your understanding of this section

Loading questions...