Tracking single-page apps: why your pageviews are wrong
In a React/Vue/SPA, the page never fully reloads — so GA4 records one pageview for a whole session of navigation. Here is how to fire virtual pageviews on route changes.
In a single-page application the browser only loads a document once, so the analytics tag only fires a pageview once. Every subsequent route change is invisible unless you tell the tag about it. The result is a property where sessions look like one-page visits, engagement metrics are meaningless, and conversion funnels break at the first step — even though the tracking code is installed perfectly correctly.
Why the default breaks
Traditional analytics is built on document loads: a navigation triggers a page load, the tag runs, a pageview is recorded.
React, Vue, Angular, Svelte and their routers replace the document load with a client-side state change. The URL updates via the History API, the view re-renders, and no page load event occurs. The tag has nothing to react to.
GA4's enhanced measurement includes a "page changes based on browser history events" option that catches many of these automatically — but it fires on history state changes, which does not always align with what your app considers a route change, and it cannot know when your view has finished rendering. It is a reasonable safety net and a poor primary implementation.
The right approach: fire from your router
Your router knows exactly when navigation completed. Use it.
// React Router example
useEffect(() => {
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: 'virtual_page_view',
page_path: location.pathname + location.search,
page_title: document.title
});
}, [location]);
Three details decide whether this works properly:
Fire after the title updates. If you push before your app sets the document title, every virtual pageview carries the previous page's title. Push in an effect that runs after render, or pass the title explicitly.
Do not double-count the first load. The initial document load already produces a pageview from the tag itself. If your router effect also fires on mount, the landing page counts twice. Skip the first invocation, or configure your GA4 tag not to send an automatic page view and let the router own all of them — the second option is cleaner and easier to reason about.
Send the full path. Include the query string if it is meaningful, exclude it if it contains noise, and never let it contain personal data — keeping PII out of GA4 applies with extra force here, because SPA routes often carry IDs and tokens.
Handle the cases routers miss
Modals, tabs, and multi-step wizards are conceptually pages to your users and invisible to your router. Decide deliberately:
- A checkout step in a modal is a funnel step. Fire a virtual pageview or a dedicated event, or your funnel has a hole exactly where the money is.
- A filter that updates the query string usually is not a pageview. Firing one per keystroke floods your data.
- A tab within a page is usually an interaction, not a navigation. Track it as an event.
The test: would you want it as a row in a page report, and would a user call it a different page? If yes, virtual pageview. If no, event.
The measurement pitfalls specific to SPAs
Scroll and engagement tracking gets confused. Enhanced measurement's scroll tracking fires on reaching a depth of the document; in an SPA the document does not reset between routes, so scroll events fire inconsistently. If scroll depth matters, implement it against your own route changes.
Session and engagement timing behaves differently. A user on one route for ten minutes is genuinely engaged, but without further events GA4 may see no activity. Ensure meaningful interactions emit events.
Ad platform pixels need the same treatment. Meta, TikTok, and Google Ads tags fire once on document load too. If your conversion happens on a client-side route — a confirmation view rather than a confirmation URL — the pixel never fires. This is the most expensive SPA tracking bug, because it looks like nothing at all: the tag is installed, it just never runs at the moment that matters.
GTM's History Change trigger is available and works, but it fires on every history state change including ones your app does not consider navigation. Prefer an explicit dataLayer push from your router; use History Change only when you cannot touch the application code.
Server-rendered frameworks
Next.js, Nuxt, Remix and similar frameworks navigate client-side after the first load, so they have exactly the SPA problem despite rendering on the server. The first page is a real document load; every subsequent navigation is not.
Hook the framework's router events, and be careful about the double-count on initial load — the pattern is the same, the frameworks just make it easier to forget because the first page behaves traditionally.
Verifying it
- Navigate through five routes and confirm five pageviews with distinct, correct paths.
- Check the titles — a common failure is every virtual pageview carrying the previous page's title.
- Check the landing page is not doubled.
- Complete a conversion on a client-side route and confirm the GA4 event and every ad platform pixel fired.
- Use the back button and confirm a pageview is recorded — history navigation is easy to miss.
- Check average engagement time looks plausible rather than near-zero.
Debugging tags in the browser covers watching the requests as you click through.
FAQ
Does GA4 track single-page apps automatically?
Partially. Enhanced measurement can fire pageviews on browser history events, which catches many route changes but not all, and it cannot know when your view finished rendering. Explicit router-driven events are more reliable.
Should I use GTM's History Change trigger for my SPA?
It works and is the pragmatic choice when you cannot modify application code. If you can, an explicit dataLayer push from your router is more precise, because it fires when your app says navigation completed.
Why do my SPA pageviews have the wrong page title?
Because the event fires before the app updates the document title. Push after render, or pass the title explicitly rather than reading it at push time.
Do ad platform pixels need SPA handling too?
Yes, and this is the costliest oversight. Meta, TikTok, and Google Ads tags fire on document load; if your conversion happens on a client-side route, the pixel never fires at the moment of conversion.
Should modals be tracked as pageviews?
If a user would describe it as a separate page and you want it in page reports — a checkout step, for instance — yes. If it is an interaction within the current page, use an event instead.
Check whether your app is initialising a dataLayer and loading its tags correctly: the free tracking audit inspects any URL in seconds.
See where your tracking stands
Run the same 13-check audit referenced in this post against any URL. No signup, results in seconds.