A single-page application loads exactly once, and that quietly breaks the one thing GA4 counts on: a fresh page load for every screen. The user clicks through five “pages,” your framework swaps the content without a reload, and GA4 records a single pageview for the whole visit. Here is how to fix that, from the no-code route to full control.
Why SPAs break default GA4 tracking
Classic sites load a new HTML document on every navigation, and the GA4 tag fires a page_view each time. A SPA (React, Vue, Angular, and friends) rewrites the DOM in place using the History API and never reloads the document. The tag fires once, on the initial load, and stays silent for every route change after that. Your engagement, funnels, and landing-page reports all inherit the gap.
Option 1: Enhanced Measurement (the no-code route)
If your SPA uses the History API for routing – most do – GA4 can track route changes on its own. In Admin, open your web data stream, go to Enhanced Measurement, expand Page views, and enable both “Page loads” and “Page changes based on browser history events.” GA4 then listens for history changes and sends a page_view on each one.
This is the fastest fix, and for many SPAs it is enough. The catch is that it relies on the History API being used cleanly, so you still need to verify it fires where you expect.
Option 2: Manual page_view with gtag
When you want control – for example, to send the view only after the new route’s title is set – disable the automatic view and send it yourself:
// On initial load: configure the tag but suppress the automatic page_view
gtag('config', 'G-XXXXXXXXXX', { send_page_view: false });
// In your router, after each route change:
gtag('event', 'page_view', {
page_title: document.title,
page_location: window.location.href
});
Turning off send_page_view prevents the tag from firing on load, so you are the only one sending views. That avoids the most common SPA bug, which is one framework event and one GA4 auto-event both firing for the same screen.
Option 3: GTM History Change trigger
If you tag through Google Tag Manager, use the built-in History Change trigger. Create a GA4 event tag for page_view, and fire it on the History Change trigger, which listens for the gtm.historyChange-v2 event. Pass the new URL and title from the data layer so each virtual pageview carries the right context.
Don’t double-count the first page
Whichever route you pick, the initial load is where duplicates hide. If Enhanced Measurement’s “Page loads” is on and your router also fires a manual view for the entry route, you get two views for the first screen. Pick one owner for the first pageview:
- Using manual gtag or GTM, set send_page_view: false so only your code fires.
- Using Enhanced Measurement alone, do not add a manual page_view on top of it.
- Never mix a hardcoded gtag snippet with a GTM tag for the same property.
How to verify it works
Open GA4 DebugView, then click through your app’s routes. You should see one page_view per route change, each with the correct page_location. In Realtime, the pages report should list your virtual paths, not just the entry URL. If a route is missing, it is usually a router that replaces state without a History API event, which you then track with an explicit manual view.
A single-page app loads once and, left alone, tells GA4 the whole session was one page. Choose one method – Enhanced Measurement for speed, gtag or GTM for control – make sure only one owner fires the first view, and confirm it in DebugView. Do that and your SPA reports every screen it should, instead of collapsing the whole journey into a single load.
Want a stronger data analyst role or a raise? Grab the FREE Product Analyst Playbook and get the exact roadmap to your next offer.
