"Control Freak" approach for a vanilla JS SPA with a custom router - a modular setup that juniors can debug without fear.
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.
Native GTM Consent Mode is often a "black box" in SPA environments.
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);
};gtm_consent_update. Without this, GTM would not know that consent changed in real time inside a SPA.Standard "History Change" trigger fires before the custom router finishes updating the DOM.
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
});page_location and page_title are always 100% accurate. Without debounce, we send data while the script is still changing the DOM.GA4 Loader loads with parameters that prevent automatic firing.
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'
});spa_navigation trigger. No more automatic page_view events that GTM generates and we do not control.spa_navigation event.