← All Guides

Tracking Single Page Apps with GTM: Virtual Page Views, History Change and Clean Events for React, Next.js, Vue and Angular

Why single page applications break default page view tracking, the two ways to fix it, how to send virtual page views with correct titles and paths, how to avoid double counting the first page, and how to structure events in a component-based app.

Atiqul Islam
Written by Atiqul Islam

Tracking & analytics specialist. 278+ GTM, GA4 and server-side tracking projects for clients in the US, UK and Australia.

Rather not do this yourself? I set this up for clients every week. Free 15-minute audit, no obligation.

Book a Free Audit

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

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:

Step 6: Things That Break in SPAs

Step 7: Test

  1. GTM Preview. Load the app: expect exactly one page_view for the landing route.
  2. Navigate through five routes: expect one virtual_page_view and one GA4 page_view per route, with the correct title and path in the Variables tab.
  3. Use the browser back button: expect one more.
  4. Trigger a business event: expect one fire with the right data.
  5. GA4 DebugView: page_view events with distinct page_location values.
  6. 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.

Frequently Asked Questions

Why does GA4 show one page view for my whole React app?

Because the browser loads one document and the app changes the URL with the History API. GTM fires its page view once, on the real load. Every route after that is invisible unless you tell GTM about it.

History Change trigger or my own dataLayer push?

Your own push from the router. It gives you the right page title, which the History Change trigger cannot, and fires exactly once per navigation. Use History Change only when you cannot touch the app code.

How do I avoid double counting the first page?

Either let the Google tag send its automatic page view on load and send virtual page views only on route changes, or turn off the automatic page view and send every page including the first from the router. Pick one.

Does enhanced measurement handle this?

GA4's enhanced measurement has a page changes based on browser history events option. It works for simple apps but sends the old title and cannot be controlled. For anything serious, send your own.

What about Next.js with server rendering?

The first page is a real document load, later navigations are client-side. The same rule applies: automatic page view for the first, router-driven virtual page views after. In the App Router, use a client component that subscribes to pathname changes.

Want This Done Right the First Time?

Book a free 15-minute audit call. I'll review your setup, show you what is broken, and tell you exactly what it takes to fix it.

Chat Now