The Problem
You install GTM on a React, Next.js, Vue, or Angular site. You publish the Google Tag and a GA4 page_view event tag. You open GA4 Realtime - one page_view fires on the initial load. Then the user navigates to another page. Nothing. No page_view. No session data for that second page.
This is the most common tracking gap on modern JavaScript-heavy websites, and it catches a lot of marketers off guard.
Why It Happens
Traditional websites reload the full page on every navigation. The browser sends a new HTTP request, the GTM snippet re-executes, all tags fire again.
Single-page applications (SPAs) work differently. The JavaScript framework intercepts navigation events and updates the DOM without a full page reload. The browser URL changes, the content changes, but the page never truly reloads. GTM’s container does not re-execute. Your All Pages trigger does not fire.
From GTM’s perspective, the user is still on the first page they loaded.
The Fix: History Change Trigger
GTM has a built-in trigger type specifically for this: History Change.
When a JavaScript framework updates the URL using the HTML5 History API (pushState or replaceState), GTM detects this change and fires a synthetic event called gtm.historyChange (or gtm.historyChange-v2 in newer containers).
How to Set It Up
Step 1: Enable History Change in your GTM container
GTM → Admin → Container Settings → enable “Enable history change events” if you see that option. In most containers this is already active.
Step 2: Create a History Change trigger
- GTM → Triggers → New
- Trigger Type: History Change
- Fire on: All History Changes
- Name: “History Change - All”
- Save
Step 3: Add this trigger to your GA4 page_view tag
Open your GA4 event tag that sends page_view. Currently it likely only has an All Pages trigger. Add the History Change trigger as a second trigger.
Now the tag fires on:
- Initial page load (All Pages)
- Every subsequent SPA navigation (History Change)
Why This Often Still Doesn’t Work
The History Change trigger solves most cases - but it has failure modes.
Failure Mode 1: The Framework Uses Hash-Based Routing
Older React Router or Vue Router setups use hash-based URLs (example.com/#/about instead of example.com/about). Hash changes do not always trigger the History API, which means the History Change trigger does not fire.
Fix: Create a separate Window Loaded or Custom Event trigger paired with a data layer push that fires on hash-based navigation. Or configure the framework to use HTML5 history mode instead of hash mode.
Failure Mode 2: page_location and page_title Are Stale
Even when the History Change trigger fires correctly, the page_location and page_title variables may still contain the previous page’s values - because GTM reads these at the moment the tag fires, and the framework may update the DOM asynchronously.
Check this: In GTM Preview mode, navigate to a second page in your SPA. In the Variables tab of the History Change event, look at {{Page URL}} and {{Page Title}}. Are they the new page’s values or the old ones?
If they are stale, your page_view events are all being attributed to the first page.
Fix: Add a small delay to the tag using Tag Sequencing, or - better - have the development team push the correct page data to the data layer at the moment of navigation:
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: 'page_view',
page_title: document.title,
page_location: window.location.href
});
Use this custom data layer event as your trigger instead of the History Change trigger.
Failure Mode 3: The Framework Fires Navigation Events Before the DOM Updates
Some frameworks (especially Next.js with the App Router) fire route change events before the new page content renders. GTM picks up the event and reads the old document.title and URL.
Fix: Listen to the correct lifecycle hook on the framework side. In Next.js App Router, use useEffect after the component mounts to push to the data layer - not the router event itself.
// Next.js App Router example - in a layout component
'use client';
import { useEffect } from 'react';
import { usePathname } from 'next/navigation';
export function TrackPageView() {
const pathname = usePathname();
useEffect(() => {
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: 'page_view',
page_location: window.location.href,
page_title: document.title,
});
}, [pathname]);
return null;
}
This is more reliable than any GTM-only solution for Next.js.
The Data Layer Push Method (Most Reliable for SPAs)
If you have developer access, the most reliable approach is to bypass the History Change trigger entirely and have the framework push a custom event to the data layer on every route change.
// Call this in your router's onChange / afterEach callback
function trackPageView() {
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: 'virtual_page_view',
page_title: document.title,
page_path: window.location.pathname,
page_location: window.location.href
});
}
In GTM:
- Create a Custom Event trigger - Event Name:
virtual_page_view - Attach it to your GA4 page_view tag instead of (or alongside) All Pages
This gives you clean, accurate page data for every navigation event - controlled entirely by the framework, not inferred by GTM.
Verifying Your Fix
In GTM Preview mode:
- Load the SPA
- Navigate to 2 - 3 different pages using the app’s navigation (not browser back/forward)
- In Tag Assistant, confirm your GA4 page_view tag fires on each navigation
- In the Variables tab of each firing, confirm
{{Page URL}}and{{Page Title}}match the current page - not the first page
In GA4 DebugView:
- Enable debug mode
- Navigate through several pages
- Confirm
page_viewevents appear with correctpage_locationvalues for each page visited
If you see the same page_location on every page_view event, the variable staleness problem is present.
What About Enhanced Measurement Auto page_view?
GA4’s Enhanced Measurement has an option called “Page changes based on browser history events” - this sounds like it solves the problem automatically.
It does - but only if your GA4 is deployed via the Google Tag (gtag.js), and only when the tag is loaded directly on the page. When GA4 is deployed via GTM, this Enhanced Measurement setting may not work reliably on SPAs because GTM controls the tag lifecycle.
Do not rely on this setting for SPAs deployed via GTM. Use the explicit trigger or data layer push approach described above.
Framework-Specific Notes
Next.js (Pages Router): Use router.events - specifically routeChangeComplete - to trigger data layer pushes.
Next.js (App Router): Use useEffect with usePathname as shown above.
React Router v6: Use the useLocation hook in a top-level component and push to the data layer in a useEffect that depends on location.
Vue Router: Use the afterEach navigation guard at the router level.
Angular: Use the NavigationEnd event from the Router service.
In every case, the principle is the same: push to the data layer after the navigation is complete and the DOM has updated.
Final Thoughts
GTM’s History Change trigger is a good starting point, but it is not a complete solution for most SPA setups. The reliable fix is a data layer push from the framework itself - triggered after each navigation, with the correct page data attached.
If you have developer access, spend 30 minutes implementing the data layer push approach. It will save you hours of debugging stale page data and missing events.
In the next article of this series, we will cover:
GTM blocked by Content Security Policy - the silent killer no one tells you about.
Related Posts
Data Layer Values Lost Between Pages - How to Persist Variables Across Navigation Without a Developer
9 min read
Cross-Domain Tracking Breaking at Iframes and Third-Party Checkouts - and How to Fix It
10 min read
Duplicate Conversions in GTM - Why Your Purchase Event Fires Twice and How to Diagnose It
9 min read
Need Help With Your Google Ads?
I help e-commerce brands scale profitably with data-driven PPC strategies.
Get In Touch