MeasureCamp Split 2026 - Talk Writeup

GTM for SPA
+ Consent

"Control Freak" approach for a vanilla JS SPA with a custom router - a modular setup that juniors can debug without fear.

Marko · Funky EnterprisesMeasureCamp Split · 2026GTM · GA4 · Consent Mode · SPA

> The Problem

Standard GTM setups break on Single Page Applications with custom routers. The History Change trigger fires before the DOM finishes updating. Native GTM Consent Mode is a black box in SPA environments. The result: duplicate events, missing page_views, and consent signals that arrive too late.

Instead of one huge unreadable script, this case study presents a modular system - 6 tags, 3 triggers, each with exactly one job.

> 1. Architecture: 6 Tags, 3 Triggers

01
Consent Bridge
// intercept consent updates (priority 999)
02
SPA Navigation Detector
// MutationObserver + History API
03
GA4 Loader
// load gtag, send_page_view: false
04
GA4 page_view
// fire ONLY on spa_navigation
05
Meta Pixel
// consent-aware
06
Microsoft Clarity
// consent-aware
Trigger 1
Consent Initialization - for Bridge and Detector
Trigger 2
gtm_consent_update - for Loader, Pixel, Clarity
Trigger 3
spa_navigation - for GA4 page_view
Argument: Each component has exactly one job. Juniors can debug the system without fear of breaking something, because it is clear which tag does what.

> 2. Consent Bridge (Tag 52)

Priority 999. Why so high?

Native GTM Consent Mode is often a "black box" in SPA environments.

Custom HTML Tag // Priority 999
var origPush = window.dataLayer.push.bind(window.dataLayer);
window.dataLayer.push = function() {
  for (var i = 0; i < arguments.length; i++) {
    var item = arguments[i];
    if (item && typeof item === 'object' &&
        item[0] === 'consent' && item[1] === 'update') {
      var consentData = item[2] || {};
      origPush({
        'event': 'gtm_consent_update',
        'consent_analytics': consentData.analytics_storage || 'denied',
        'consent_ads': consentData.ad_storage || 'denied'
      });
    }
  }
  return origPush.apply(this, arguments);
};
Argument: The Bridge intercepts the consent update and converts it into a custom event gtm_consent_update. Without this, GTM would not know that consent changed in real time inside a SPA.
Why: This is the only way to guarantee that GA4 loads the same second the user clicks "Accept", instead of waiting for a page change that never happens in a SPA.

> 3. SPA Navigation Detector (Tag 56)

MutationObserver + 150ms debounce

Standard "History Change" trigger fires before the custom router finishes updating the DOM.

Custom HTML Tag // fires on All Pages
var lastPath = location.pathname;
function checkAndPush() {
  var currentPath = location.pathname;
  if (currentPath !== lastPath) {
    lastPath = currentPath;
    window.dataLayer.push({
      event: 'spa_navigation',
      page_location: location.href,
      page_path: currentPath,
      page_title: document.title,
      spa_timestamp: new Date().toISOString()
    });
  }
}
// MutationObserver with 150ms debounce
var observer = new MutationObserver(function() {
  clearTimeout(window._ferticoSpaTimeout);
  window._ferticoSpaTimeout = setTimeout(checkAndPush, 150);
});
observer.observe(document.documentElement, {
  childList: true, subtree: true
});
Argument: We use a MutationObserver that watches DOM changes, combined with a 150ms debounce. This is more reliable than pure History API.
Why: The 150ms debounce gives the custom router and Bootstrap 5 UI enough time to finish rendering, so page_location and page_title are always 100% accurate. Without debounce, we send data while the script is still changing the DOM.

> 4. Eliminating Duplicate Events (Tag 69 and 70)

send_page_view: false + exclusive trigger

GA4 Loader loads with parameters that prevent automatic firing.

GA4 Loader (Tag 70) // Priority 100
if (window._ferticoGA4Loaded) return;
window._ferticoGA4Loaded = true;
var s = document.createElement('script');
s.async = true;
s.src = 'https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX';
document.head.appendChild(s);
window.gtag('js', new Date());
window.gtag('config', 'G-XXXXXXXXXX', {
  'send_page_view': false,  // Key: do not send automatically
  'transport_type': 'beacon'
});
Argument: GA4 page_view (Tag 69) is sent exclusively through our spa_navigation trigger. No more automatic page_view events that GTM generates and we do not control.
Why: This completely eliminates duplicate events and the chaos that happens when GTM and the custom router try to control browser history at the same time. One source of truth: our spa_navigation event.

> Execution Flow

[1]
Consent Initialization trigger
// Consent Bridge (priority 999) mounts
// SPA Navigation Detector starts listening to DOM
[2]
User clicks "Accept" on cookie banner
// Banner sends: gtag('consent', 'update', {...})
[3]
Consent Bridge (priority 999) intercepts and sends
// event: 'gtm_consent_update'
[4]
gtm_consent_update trigger fires
// GA4 Loader (priority 100) loads gtag.js
// Meta Pixel loads
// Microsoft Clarity loads
[5]
User navigates through SPA (custom router changes URL)
// script.js changes DOM, MutationObserver detects
// After 150ms debounce: spa_navigation event
[6]
spa_navigation trigger fires
// GA4 page_view with accurate page_title and page_path

> Sanity Checklist Before Submit to Production

[ ]Preview mode on 3 different routes
[ ]Tag Assistant: 0 red tags
[ ]Consent: test "Reject All" scenario
[ ]SPA Back button test
[ ]Duplicate events: must be 0
[ ]Container size under 100KB
[ ]No hardcoded IDs in tag names
[ ]Version name: YYYY-MM-DD + short description

> Key Takeaways

>SPA does not fire page_view automatically. You must take control.
>Consent bridge is a temporary hack, but it works until GTM gets better SPA support.
>send_page_view: false + custom trigger = one source of truth.
>MutationObserver + debounce is more reliable than pure History API in a custom router.
>6 clean tags with names are better than 30 copy-paste HTML tags.

Need a clean GTM setup
for your SPA?

We design modular, maintainable analytics architectures - and we hold vendors accountable for clean implementation.