GA4 Single Page Application Tracking: How to Fix Missing Pageviews in React, Angular, and Vue

12 mins
Explore Now

Key takeaways

  • Single page applications (SPAs) update content without a document reload. So GTM’s Container Load trigger fires only once on the first route the user lands on.
  • GA4’s Enhanced Measurement setting “Page changes based on browser history events” catches most route changes automatically, but frequently records the previous page title.
  • GTM’s built-in History Change trigger fires on pushState, replaceState, popstate, and hash changes. Which means it also fires on modals, filters, and tab switches you don’t want counted.
  • The most reliable method is a custom dataLayer push from your router. (React Router, Angular Router, Vue Router)Fired after the new title and content render.
  • Double counting is the most common SPA tracking bug. If you send manual page_view events, disable automatic history tracking or unset send_page_view.
  • Route changes also break scroll depth, element visibility, and click triggers, not just page views.
  • Broken SPA tracking corrupts A/B test data before it reaches your testing tool’s reports, which makes every downstream decision suspect.

Why does GA4 pageview tracking fail on single page application?

A single page application loads one HTML document. Then rewrites content in place using JavaScript. The browser never issues a new document request, so GTM’s Container Load trigger and GA4’s default page_view event fire exactly once. Every subsequent “page” the user sees goes unrecorded.

A single-page application is a website that loads a single HTML document. Updates the visible content dynamically via JavaScript, rather than requesting a new document from the server for each view.

The mechanism behind this is the browser History API. Frameworks like React, Angular, Vue, Svelte, and Next.js call history.pushState() to change the address bar without triggering navigation. The URL updates. The DOM updates. The load and DOMContentLoaded events do not fire again.

What does that mean in practice:

SignalTraditional multi-page siteSingle page application
DOMContentLoaded on route changeFiresDoes not fire
GTM Container Load triggerFires per pageFires once per session start
GA4 automatic page_viewFires per pageFires once (plus history events, if enabled)
document.referrerUpdates per pageStays at the original entry referrer
Scroll depth triggerResets per pageFires once, then never again
Session duration accuracyReliableInflated or deflated depending on setup

The symptom most teams notice first: sessions with one page view and a suspiciously long engagement time. Users are clearly navigating, but GA4 reports a single landing page and nothing else.


Does GA4 track SPA route changes automatically?

Partly. GA4’s Enhanced Measurement includes a setting called Page changes based on browser history events. Which is enabled by default. It listens for History API changes and fires an additional page_view. It works for URL-based routing, but it commonly captures a stale page_title and does not fire when only the hash changes in some configurations.

Where the automatic setting breaks down in Single Page Application

  • Stale page titles. GA4 reads document.title at the moment the history event fires. Most frameworks update the title after the route transition completes, so the event carries the previous view’s title. Your reports show correct paths mapped to wrong titles.
  • Over-firing on non-navigation state changes. Any component that pushes state for a filter, modal, accordion, or tab records a pageview.
  • No control over parameters. You cannot add custom dimensions, clean query strings, or set an accurate page_referrer before the hit is sent.
  • Hash-only routing gaps. Older Angular and Vue hash-mode routers (/#/checkout) behave inconsistently.

Where to find the setting: 

GA4 Admin โ†’ Data Streams โ†’ your web stream โ†’ Enhanced measurement โ†’ gear icon โ†’ “Page changes based on browser history events.”

If you only need directional data and your app updates titles synchronously, leaving this on is acceptable. If page titles feed your reporting, or you’re using pageviews as A/B test goals, move to manual tracking.


How do you track SPA pageviews with the GTM History Change trigger?

Create a History Change trigger in GTM and attach it to a GA4 Event tag named page_view, passing page_location and page_title manually. The trigger fires on pushState, replaceState, popstate, and hashchange, so add exception conditions to filter out state changes that aren’t real navigations.

Step-by-step setup

  1. Create the trigger. Triggers โ†’ New โ†’ Trigger Configuration โ†’ History Change. Set it to fire on Some History Changes so you can add conditions.
  2. Add exclusion conditions. Common ones:
    • History Source does not equal hashchange (excludes anchor jumps)
    • New History Fragment does not contain modal
    • Page Path does not match RegEx ^/(checkout/step-[0-9]+)$ if those are handled separately
  3. Create a GA4 Event tag. Event name: page_view. Add event parameters:
    • page_location โ†’ {{Page URL}}
    • page_path โ†’ {{Page Path}}
    • page_title โ†’ {{Page Title}}
    • page_referrer โ†’ a custom variable holding the previous URL
  4. Prevent double counting. In your Google Tag (GA4 Configuration), either uncheck automatic history tracking in Enhanced Measurement, or set the field send_page_view to false and fire your own page_view on Container Load and History Change.
  5. Validate in GA4 DebugView, not just GTM Preview. GTM Preview confirms the tag fired; DebugView confirms GA4 received the correct parameters.

The title timing problem

{{Page Title}} resolves at trigger time. If your framework sets the title after the route commits, you capture the old title. Two workarounds:

Option A โ€” delay the tag. Add a Custom HTML tag on History Change that waits, then pushes a clean event:

<script>

ย ย (function () {

ย ย ย ย var attempts = 0;

ย ย ย ย var initialTitle = document.title;

ย ย ย ย var check = setInterval(function () {

ย ย ย ย ย ย attempts++;

ย ย ย ย ย ย if (document.title !== initialTitle || attempts > 20) {

ย ย ย ย ย ย ย ย clearInterval(check);

ย ย ย ย ย ย ย ย window.dataLayer.push({

ย ย ย ย ย ย ย ย ย ย event: 'spa_page_view_ready',

ย ย ย ย ย ย ย ย ย ย page_title: document.title,

ย ย ย ย ย ย ย ย ย ย page_location: window.location.href,

ย ย ย ย ย ย ย ย ย ย page_path: window.location.pathname + window.location.search

ย ย ย ย ย ย ย ย });

ย ย ย ย ย ย }

ย ย ย ย }, 100);

ย ย })();

</script>

This is a workaround, not a fix. It fails when two consecutive routes share the same title, and the polling adds up to two seconds of latency.

Option B: Push from the app. Preferred. Covered in the next section.

Deduplication caveat in SPA Tracking

A common recipe is a Custom JavaScript variable that stores the last tracked path in a closure and returns false on repeats. Be aware that GTM may evaluate a variable more than once per event, so a variable that mutates global state can produce inconsistent results. Handle deduplication in your application code or with trigger conditions instead.


The most reliable way to track Single Page Application page views?

Push a custom event to the dataLayer from your router’s navigation-complete hook, after the title and content have rendered. Fire a GTM Custom Event trigger on it. This gives you exact control over timing, parameters, and which state changes count as pageviews, the three things History Change triggers can’t guarantee.

A virtual pageview is a manually fired analytics event that represents a content view in a single page application, sent in place of the browser-initiated pageview that never occurs.

React Router (v6+)

<script>

ย ย (function () {

ย ย ย ย var attempts = 0;

ย ย ย ย var initialTitle = document.title;

ย ย ย ย var check = setInterval(function () {

ย ย ย ย ย ย attempts++;

ย ย ย ย ย ย if (document.title !== initialTitle || attempts > 20) {

ย ย ย ย ย ย ย ย clearInterval(check);

ย ย ย ย ย ย ย ย window.dataLayer.push({

ย ย ย ย ย ย ย ย ย ย event: 'spa_page_view_ready',

ย ย ย ย ย ย ย ย ย ย page_title: document.title,

ย ย ย ย ย ย ย ย ย ย page_location: window.location.href,

ย ย ย ย ย ย ย ย ย ย page_path: window.location.pathname + window.location.search

ย ย ย ย ย ย ย ย });

ย ย ย ย ย ย }

ย ย ย ย }, 100);

ย ย })();

</script>

Set the title inside the same effect so the value you push is guaranteed to match what the user sees.

Angular Router

import { Router, NavigationEnd } from '@angular/router';

import { Title } from '@angular/platform-browser';

import { filter } from 'rxjs/operators';

constructor(private router: Router, private titleService: Title) {

ย ย let previousUrl = document.referrer;

ย ย this.router.events

ย ย ย ย .pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd))

ย ย ย ย .subscribe((event) => {

ย ย ย ย ย ย (window as any).dataLayer = (window as any).dataLayer || [];

ย ย ย ย ย ย (window as any).dataLayer.push({

ย ย ย ย ย ย ย ย event: 'virtual_page_view',

ย ย ย ย ย ย ย ย page_location: window.location.href,

ย ย ย ย ย ย ย ย page_path: event.urlAfterRedirects,

ย ย ย ย ย ย ย ย page_title: this.titleService.getTitle(),

ย ย ย ย ย ย ย ย page_referrer: previousUrl

ย ย ย ย ย ย });

ย ย ย ย ย ย previousUrl = window.location.href;

ย ย ย ย });

}

NavigationEnd fires after redirects and guards resolve, which is the correct moment โ€” NavigationStart would capture routes the user never reaches.

Vue Router

let previousUrl = document.referrer;

router.afterEach((to, from) => {

ย ย document.title = to.meta.title || document.title;

ย ย window.dataLayer = window.dataLayer || [];

ย ย window.dataLayer.push({

ย ย ย ย event: 'virtual_page_view',

ย ย ย ย page_location: window.location.href,

ย ย ย ย page_path: to.fullPath,

ย ย ย ย page_title: document.title,

ย ย ย ย page_referrer: previousUrl

ย ย });

ย ย previousUrl = window.location.href;

});

Next.js App Router

The App Router uses pushState under the hood. Track with usePathname and useSearchParams in a client component, wrapped in a Suspense boundary to avoid deopting the route to client rendering.

GTM configuration for the custom event

  1. Variables โ†’ create Data Layer Variables for page_location, page_path, page_title, page_referrer.
  2. Trigger โ†’ Custom Event, event name virtual_page_view.
  3. Tag โ†’ GA4 Event, event name page_view, parameters mapped to the four variables above.
  4. Google Tag โ†’ set send_page_view to false, and fire your own page_view on Container Load too so the entry page is counted once.
  5. Enhanced Measurement โ†’ turn off “Page changes based on browser history events.”

How do the three Single Page Application tracking methods compare?

GA4 Enhanced MeasurementGTM History Change triggerCustom dataLayer push
Engineering effortNoneLow (GTM only)Medium (app code change)
Control over timingNoneLimitedFull
Accurate page_titleUnreliableUnreliable without pollingReliable
Accurate page_referrerNoPartialYes
Filters non-navigation state changesNoPartially, via conditionsYes
Supports custom dimensionsNoYesYes
Survives framework upgradesYesYesNeeds review
Recommended forDirectional reporting onlySites where dev resources are blockedProduction analytics and experimentation

Decision rule: In a Single Page Application, if page views feed conversion goals, funnels, or A/B test metrics, use the custom dataLayer push. If you need something live this afternoon and no developer is available, use the History Change trigger with exclusions.


Do you know why it breaks in GTM on a single page application?

Pageviews not tracking in SPA are the visible failure. Several other GTM mechanisms depend on document load and silently stop working after the first route.

FeatureFailure modeFix
Scroll Depth triggerFire thresholds once, never resetsFire a custom scroll_reset event on route change; use your own scroll listener
Element Visibility triggerSet to “Once per page,” it stops after route oneChange to “Every time element appears”
Click triggers on new DOM nodesWork via delegation, but selectors break on re-renderUse stable data-* attributes, not generated class names
Form Submit triggerSPA forms often prevent default submissionTrack the framework’s submit handler with a dataLayer push
Custom HTML tagsRun once; DOM they target may not exist yetAdd a MutationObserver or fire on the virtual pageview event
document.referrerFrozen at entryPass page_referrer manually

The same re-render problem affects client-side A/B testing tools. A variation applied on initial load disappears when the router swaps the component out. Reliable SPA experiments need a MutationObserver or a route-change hook that reapplies the change โ€” and the analytics events described above so the results are attributed to the right view.


Here’s how you validate that Single Page Application tracking is working

SPA Tracking in GA4 Test in three layers:

GTM Preview confirms the tag fired

GA4 DebugView confirms the parameters arrived

Realtime confirms the events are processed.

Navigate at least four routes, plus a browser back button press, and check for duplicate page_view Events with identical timestamps.

Validation checklist:

  • One page_view per route change โ€” no duplicates, no gaps
  • page_title matches the title the user actually sees on that route
  • page_path excludes noise parameters like session IDs and tracking tokens
  • page_referrer shows the previous internal URL, not the original external referrer
  • Browser back and forward buttons each produce exactly one page_view
  • Modal opens, filter changes, and tab switches produce zero page_view events
  • Hash-only anchor links produce zero page_view events
  • Entry page counted once, not twice (the classic Container Load + History Change overlap)
  • Scroll depth events fire on routes two, three, and four
  • Landing page report in GA4 shows more than one distinct page per multi-route session

Run the same checks on a slow connection. Race conditions between route transitions and title updates surface under throttling that never appear on a fast local build.

To maintain ongoing data quality, analytics engineers execute a three-stage SPA GA4 Tracking validation workflow:

  1. Google Tag Manager Preview Mode is utilized to verify that native history change events are suppressed and custom virtual_page_view events fire in correct chronological order.
  2. Browser Developer Tools Network inspection monitors outbound /g/collect requests, verifying that event names (en=page_view), location URLs (dl), document titles (dt), and custom dimensions populate without missing values.
  3. GA4 DebugView provides real-time server-side validation within the Google Analytics Admin console, confirming parameter parsing, session continuation, and event conversion settings.

Frequently asked questions

  • Should I use page_view or a custom event name for virtual page views?

Use page_view. GA4 populates its Pages and Landing Page reports from the page_view event specifically. A custom name like virtual_pageview will not appear in those reports. It cannot be used as a Landing Page dimension. Name the dataLayer event whatever you like; the GA4 event name must be page_view.

  • Will manual tracking cause double counting?

Yes, for Single Page Application if you don’t disable the automatic sources. Turn off “Page changes based on browser history events” in Enhanced Measurement, and set send_page_view to false on the Google Tag so the initial load is counted by your own tag only.

  • Does GA4 session tracking work correctly in SPAs?

Session start and engagement time work normally, since they’re driven by the GA4 script rather than document loads. The metrics that break are those derived from pageviews, pages per session, landing pages, exit pages, and page-scoped conversion rates.

Yes. Hash changes fire hashchange, not pushState, and GA4’s automatic history tracking handles them inconsistently. A custom dataLayer push from the router is the dependable option for hash-mode apps.

  • How does this relate to iframe tracking problems?

Both stem from the same root cause. GTM‘s defaults assume one document per view. An iframe is an extra document GTM doesn’t own. An SPA route is a view without a document. Both require abandoning the default trigger and listening for an explicit signal instead postMessage for iframes, History Change or a dataLayer push for SPAs.


Implementation summary

  1. Pick your method: custom dataLayer push if you have dev access, or History Change trigger if you don’t.
  2. Fire from a post-navigation hook (NavigationEnd, afterEach, useEffect on location) after the title is set.
  3. Send a GA4 page_view event with page_location, page_path, page_title, and page_referrer.
  4. Disable automatic history tracking and send_page_view to prevent duplicates.
  5. Fix the collateral damage: scroll depth resets, element visibility “every time,” stable click selectors.
  6. Validate across four-plus routes, back button, and throttled network before shipping.

Skip to content