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
What is Google Analytics?
Section titled “What is Google Analytics?”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.
What is Google Tag Manager?
Section titled “What is Google Tag Manager?”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.
What is the DataLayer?
Section titled “What is the DataLayer?”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.
How Terra Uses These Tools
Section titled “How Terra Uses These 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
Implementation Guide
Section titled “Implementation Guide”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:
First load
Section titled “First load”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.
On page transition
Section titled “On page transition”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.
Search
Section titled “Search”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.
Custom Tags
Section titled “Custom Tags”For this, we have two actions we perform to ensure no analytics are being polluted with double / triple / … tags:
01. SWUP plugin
Section titled “01. SWUP plugin”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, }); }}02. Core
Section titled “02. Core”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()); } })Best Practices
Section titled “Best Practices”- Before pushing any events, always check if
window.dataLayerexists 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.onloadwith 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...