Single-Page App Tracking
Single-page apps do not reload the page on navigation. The first load is the only real page load — everything after is a JavaScript route change. This breaks GA4’s automatic page view tracking, which depends on the page_location value set when the page first loaded.
This recipe covers virtual pageview implementation, dataLayer cleanup between routes, and framework-specific patterns for React Router, Next.js App Router, and Vue Router.
The two approaches
Section titled “The two approaches”Approach A — GTM History Change trigger: GTM watches for HTML5 History API changes (pushState, replaceState, popstate). Simple to set up, but unreliable: it fires when the URL changes, which is often before the new page component has finished rendering. document.title will be stale.
Approach B — Custom page_view events from the router (recommended): Push a page_view event to the dataLayer from inside your router’s navigation hooks, after the component has mounted and the title is set. More reliable, more data, correctly timed.
Approach B — Framework implementation
Section titled “Approach B — Framework implementation”import { useEffect } from 'react';import { useLocation } from 'react-router-dom';
export function usePageTracking() { const location = useLocation();
useEffect(() => { // Wait a tick to ensure document.title has been updated by the page component const timer = setTimeout(() => { window.dataLayer = window.dataLayer || [];
// Clear stale page-scoped data from the previous route window.dataLayer.push({ page_path: undefined, page_title: undefined, page_category: undefined });
window.dataLayer.push({ event: 'page_view', page_path: location.pathname + location.search, page_title: document.title, page_location: window.location.href }); }, 0);
return () => clearTimeout(timer); }, [location]);}
// App.js — use the hook inside BrowserRouter contextfunction App() { usePageTracking(); return <Routes>...</Routes>;}// app/layout.js — analytics wrapper'use client';import { useEffect } from 'react';import { usePathname, useSearchParams } from 'next/navigation';
export function AnalyticsProvider({ children }) { const pathname = usePathname(); const searchParams = useSearchParams();
useEffect(() => { const url = pathname + (searchParams.toString() ? '?' + searchParams.toString() : '');
window.dataLayer = window.dataLayer || []; window.dataLayer.push({ event: 'page_view', page_path: url, page_title: document.title, page_location: window.location.href }); }, [pathname, searchParams]);
return <>{children}</>;}import { createRouter, createWebHistory } from 'vue-router';
const router = createRouter({ history: createWebHistory(), routes: [...]});
router.afterEach((to, from) => { // afterEach fires after navigation is committed // Use nextTick to ensure the component title update has run import('vue').then(({ nextTick }) => { nextTick(() => { window.dataLayer = window.dataLayer || []; window.dataLayer.push({ event: 'page_view', page_path: to.fullPath, page_title: document.title, page_name: to.name?.toString() }); }); });});
export default router;GTM Configuration
Section titled “GTM Configuration”-
Create a Custom Event Trigger
- Trigger type: Custom Event
- Event name:
page_view - This triggers on every SPA navigation (not the initial page load, which uses
gtm.js)
-
Create Data Layer Variables
DLV - page_path→page_pathDLV - page_title→page_titleDLV - page_location→page_location
-
Create a GA4 Configuration (Page View) tag
- Tag type: Google Analytics: GA4 Event
- Event name:
page_view - Parameters:
page_title→{{DLV - page_title}}page_location→{{DLV - page_location}}
- Trigger: the Custom Event trigger above
-
Prevent the initial page load from doubling up
The initial page load triggers
gtm.jsand your GA4 Configuration tag fires apage_viewautomatically. Your custompage_viewevent should NOT fire on the initial load.Add a condition to your Custom Event trigger: fire on Some Custom Events where
Eventequalspage_viewANDPage Pathdoes NOT equal{{DLV - initial_page_path}}— or simply let the framework push thepage_viewevent only on navigation, not on initial mount. -
Test in Preview Mode
Navigate between routes in your SPA. Each navigation should show a
page_viewevent in the Summary pane with the correctpage_pathandpage_titlefor the new route.
DataLayer cleanup between routes
Section titled “DataLayer cleanup between routes”Page-scoped data pushed on one route will persist in GTM’s data model until it is overwritten. A product ID from a product page will still be accessible on the category page unless you explicitly clear it.
Push a cleanup object before every navigation event:
router.afterEach((to, from) => { nextTick(() => { window.dataLayer = window.dataLayer || [];
// Clear page-scoped variables window.dataLayer.push({ // ecommerce data (ALWAYS clear this) ecommerce: null, // Custom page-level variables page_category: undefined, product_id: undefined, product_name: undefined, article_author: undefined });
// Then push the new page data window.dataLayer.push({ event: 'page_view', page_path: to.fullPath, page_title: document.title }); });});Test it
Section titled “Test it”- Open GTM Preview and navigate to your SPA
- Click through several routes
- Each route change should show a
page_viewevent in the Summary pane - Verify
page_pathandpage_titlereflect the new route, not the previous one - Verify stale variables from the previous route are cleared (check the Variables tab)
- In GA4 DebugView, verify
page_viewevents show correct paths
Common gotchas
Section titled “Common gotchas”document.title is the previous page’s title. If you push the page_view event synchronously in afterEach, the component that sets document.title may not have run yet. Use setTimeout(0) or nextTick() to defer the push until the render cycle completes.
Double page views on initial load. The GA4 Configuration tag sends an initial page_view on gtm.js. If your framework also pushes a page_view on component mount for the initial route, you get two page views for the landing page. Guard against this: if (to === from) return; or only push in afterEach when from.name !== null.
History Change trigger vs. Custom Event. If you switch from History Change to Custom Events, disable the History Change trigger. Running both will create duplicate page views.