Single page applications load one HTML document and swap content as the visitor navigates. Google Tag Manager sees one page load and one page view, then nothing. This guide explains the fix, provides the router hooks for the common frameworks, and shows how to keep events clean in a component-based codebase.
Two Ways to Track Route Changes
Router push, recommended. The app's router calls a function on every navigation that pushes a virtual_page_view event with path, location and title to the dataLayer. GTM fires a GA4 page_view from it.
History Change trigger, fallback. GTM watches the History API and fires on pushState, replaceState and popstate. No app changes, but the page title is usually stale and some apps fire multiple history events per navigation.
Step 1: Decide Who Sends the First Page View
Option A: the Google tag sends its automatic page view on load, and the router sends virtual page views only after the first route. Option B: set send_page_view to false on the Google tag and send every page, including the first, from the router. Option B is cleaner when the router runs on initial load anyway.
Step 2: The Tracking Helper
One module, imported where needed:
// tracking.js
export function pushEvent(event, data = {}) {
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({ event, ...data });
}
export function trackPageView(path, title) {
pushEvent('virtual_page_view', {
page_path: path,
page_location: window.location.origin + path,
page_title: title || document.title,
});
}
Step 3: Router Hooks
React Router:
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { trackPageView } from './tracking';
export function RouteTracker() {
const location = useLocation();
useEffect(() => {
// Wait a tick so the new page's title is set
const id = setTimeout(() => trackPageView(location.pathname + location.search), 0);
return () => clearTimeout(id);
}, [location]);
return null;
}
Next.js App Router:
'use client';
import { useEffect } from 'react';
import { usePathname, useSearchParams } from 'next/navigation';
import { trackPageView } from './tracking';
export default function RouteTracker() {
const pathname = usePathname();
const search = useSearchParams();
useEffect(() => {
const qs = search?.toString();
trackPageView(pathname + (qs ? '?' + qs : ''));
}, [pathname, search]);
return null;
}
Vue Router:
router.afterEach((to) => {
nextTick(() => trackPageView(to.fullPath, document.title));
});
Angular:
this.router.events.pipe(filter(e => e instanceof NavigationEnd))
.subscribe((e: NavigationEnd) => trackPageView(e.urlAfterRedirects, document.title));
Step 4: GTM Configuration
- Data Layer Variables:
page_path,page_location,page_title. - Custom Event trigger:
virtual_page_view. - Google tag: set
send_page_viewto false if you chose Option B. - GA4 Event tag named
page_viewwith parameterspage_locationandpage_titlemapped, firing on the custom event trigger. The GA4 tag treats it as a page view because the event name ispage_view.
For Google Ads remarketing tags and Meta PageView, fire them on the same trigger so every route counts.
Step 5: Events in Components
Business events come from the component that owns the action, through the same helper:
pushEvent('form_submit_success', { form_name: 'Demo request', user_email: email });
pushEvent('add_to_cart', { ecommerce: { currency: 'USD', value: 59.95, items: [item] } });
Rules for component-based apps:
- Push from the success handler, after the API responds, never from the click handler.
- Push ecommerce events with
{ ecommerce: null }first. - Keep event names and keys in one constants file so they do not drift between components.
- Guard against double mounting in development: React strict mode runs effects twice; only push on real user actions or use a ref to dedupe page views.
Step 6: Things That Break in SPAs
- Stale titles. Push after the title updates, with a timeout or the framework's post-render hook.
- Redirect routes. A route that immediately redirects fires two page views. Skip the intermediate one by checking for a redirect flag.
- Modals and tabs as routes. Decide deliberately whether a modal is a page. Usually it is an event, not a page view.
- Hash routing. The History Change trigger handles hashes; your router hook should include the hash in the path if it matters.
- Click triggers on re-rendered elements. GTM's click triggers still work, because they listen on the document, but element selectors must survive re-renders. Prefer data attributes.
- Scroll depth and visibility. Reset per virtual page is not automatic. Fire scroll tags on content routes only, with a condition on
page_path.
Step 7: Test
- GTM Preview. Load the app: expect exactly one
page_viewfor the landing route. - Navigate through five routes: expect one
virtual_page_viewand one GA4 page_view per route, with the correct title and path in the Variables tab. - Use the browser back button: expect one more.
- Trigger a business event: expect one fire with the right data.
- GA4 DebugView: page_view events with distinct page_location values.
- In GA4 Realtime the next day, pages should show the route paths, not just the root.
Outcome
Every route counts as a page with the right title, business events arrive once from the component that owns them, and the rest of your tracking, from conversions to remarketing, works on an SPA exactly as it does on a traditional site.
