Update time: June 26, 2025
If you’ve ever set up GA4 on a React, Angular, or Vue.js site and noticed that your page views look suspiciously low — or that every single visit seems to land on the same URL — you’re not alone.
Tracking Single Page Applications (SPAs) in GA4 is one of those topics that sounds simple until you actually try it. The page doesn’t reload, so GA4’s default page_view event fires once and then… nothing. That’s a problem, especially if you’re making business decisions based on that data.
In this guide, I’ll walk you through four methods for tracking SPAs in GA4 — from the easiest (Enhanced Measurement) to the most reliable (developer-driven dataLayer.push). I’ll also share what I’ve learned from experimenting with each approach, including the trade-offs that documentation usually glosses over.
What is a Single Page Application (SPA)
A Single Page Application (SPA) is a web app where all the HTML, JavaScript, and CSS are loaded once on the first visit. After that, when a user clicks around, the content updates dynamically — no full page reload. Frameworks like React, Angular, and Vue.js handle this by using the browser’s History API (pushState, replaceState) or DocumentFragment to manage URL changes and page state.
That’s the magic of SPAs — fast, fluid user experiences that feel almost like a native app. But that magic comes at a cost: your analytics tool doesn’t automatically know that the user “moved” to a new page, because technically, the page never reloaded.
On a traditional website, every page load fires a page_view event in GA4. On an SPA, the page loads once, and everything else happens in the background. If you don’t configure GA4 to handle this, you’ll see one page view per session — no matter how many pages the user visited. That’s the pain point.
Challenges of GA4 tracking SPA
Common tracking issues faced by GA4 in SPA include:
- Page views not recorded: Since the page does not reload, GA4’s default page_view event may only be triggered on the first load, and subsequent changes to the page content will not be automatically recorded.
- Inaccurate URLs and Page Titles: SPAs may update URLs through pushState, but GA4 uses document.location.pathname by default, which may not capture the full URL or correct page title.
- Rogue Referral: If not configured correctly, SPAs may cause session data to be broken or referrer data to be incorrect, affecting user journey analysis.
- Event duplication or omission: Automatic tracking (such as enhanced measurement) may cause page view events to be counted repeatedly or fail to trigger in some cases.
How to Tell if Your Site is an SPA
Before you try any of these methods, you need to confirm whether your site is actually an SPA. Here’s a quick way to check using Google Tag Manager’s Preview mode:
Step 1: In GTM, click「Triggers」——「New」——「Choose a trigger type to begin setup…」——「History Change」,Name it “History Change “, and make the following settings:
Step 2: Click Preview in GTM, then navigate from Page A to Page B on your site.
Step 3: Watch the Tag Assistant panel on the right. If you see a “History” entry appear — it means your site is an SPA. If the page reloads instead, it’s a traditional multi-page site.
That’s it. If “History” shows up, you’re dealing with an SPA, and you’ll need to use one of the methods below to track it properly.
Method 1: Enhanced Measurement
Enhanced Measurement is GA4’s built-in feature for automatically capturing events like page views, scrolls, outbound clicks, file downloads, and more. And the best part? For SPAs, it’s enabled by default — at least partially.
Here’s how it works: Enhanced Measurement listens to the browser’s History API (pushState and replaceState). When a URL change is detected, it fires a page_view event automatically. No custom code, no GTM configuration.
How to Check if It’s Enabled
Step 1: Go to 「Admin」 → 「Data Streams 」,click on your web stream.
Step 2: Find the Enhanced Measurement toggle and make sure it’s turne
Step 3: Click Show advanced settings and verify that “Change pages based on browser history event” is checked:
Key Takeaways
- Advantages:No additional setup required. Works out of the box for basic SPAs.
- Disadvantages:Can produce inaccurate URLs and page titles. GA4 relies on document.location.pathname, which may not reflect what pushState set the URL to.
- Best for:Simple SPAs where URL changes correspond directly to content updates and you don’t need custom page titles or query parameters.
By the way: Enhanced Measurement is a great starting point, but don’t assume it’s enough. I’ve seen many cases where the page_view fires with the wrong page_location because the SPA framework updates the URL slightly after GA4 already picked up the change.
Method 2: History Change Trigger
When Enhanced Measurement isn’t cutting it — maybe you’re seeing wrong URLs, or you need more control — the History Change trigger in GTM is your next step.
The History Change trigger listens for popstate events or History API URL changes, then fires a tag when a navigation happens. This gives you the ability to override page_location and page_referrer with custom values.
Step 1:Set Up the Trigger
In GTM , click「Triggers」——「New」——「Choose a trigger type to begin setup…」——「History Change」,Name it “History Change “, and make the following settings:
Step 2:Set Up the Data Layer Variable
Click 「History」, then look at the information in API Call, mainly find oldUrl and newUrl:
oldUrl— the previous page (your referral source)newUrl— the current page (what you want to track)
In GTM, click「 Variables」——「New」——「Choose a variable type to begin setup…」——「Data Layer Variable」, name it “dlv-oldUrl”, and then set as follows:
Use the same method to set dlv-newUrl
Step 3:Set Up the Tag
First, create a Google Tag (no trigger needed):
In GTM , click「Tags」——「New」——「Choose a tags type to begin setup…」——「Google Analytics」——「Google Tag」,Name it “GA4 update”, and make the following settings:
Then, create a GA4 Event tag:
In GTM , click「Tags」——「New」——「Choose a tags type to begin setup…」——「Google Analytics」——「Google Analytics: GA4 Event」,Name it “GA4-Page View Event”, and make the following settings:
GA4 update is triggered before GA4-Page View Event and is mainly used to update configuration parameters:
How to Track Normal Page Loads
Here’s the catch: if a normal (initial) page load does not trigger History Change, you’ll need a separate GA4 tag with a Page View trigger to handle the first load, such as:
If the initial page load does trigger History Change (some SPAs do this), you can merge everything into a single GA4-Page View tag. But you’ll still need the Google Tag with send_page_view: false to load the configuration without double-firing.

Key Takeaways
- Advantages:Full control over page_location and page_referrer. No developer involvement needed.
- Disadvantages:Still relies on History API data, which may not always reflect the actual page state
- Best for:SPAs that use the History API for navigation and need custom page parameters.
Method 3:Delayed Send(History Change&dataLayer.push)
This method is an optimization of Method 2. The idea is simple: instead of firing the page_view event immediately when a History Change happens, you wait a few milliseconds to let the SPA render the new page, then grab the accurate URL and page title, and send them via dataLayer.push.
I’ve found this approach especially useful for SPAs that update the document title or URL after the History API event fires — which is surprisingly common.
Step 1:Set Up the History Change Trigger
In GTM , click「Triggers」——「New」——「Choose a trigger type to begin setup…」——「History Change」,Name it “History Change “, and make the following settings:
Step 2: Set Up the Delayed Send (Custom HTML Tag)
In GTM , click「Tags」——「New」——「Choose a tags type to begin setup…」——「Custom HTML」,Name it “SPA-Delayed Send”, and make the following settings:
I’ve experimented with different delays. 500ms is usually a good balance — long enough for the SPA framework to update the page, short enough that the user won’t navigate away before it fires. But if your SPA is particularly slow to render, you might need 800ms or even 1000ms.
Don’t get me wrong: the delay is a trade-off. Too short, and you still get inaccurate URLs. Too long, and you risk losing data when users navigate quickly through your site.
Step 3:Set Up the Data Layer Variable
In GTM, click「 Variables」——「New」——「Choose a variable type to begin setup…」——「Data Layer Variable」, name it “dlv—pagePath”, and then set as follows:
Use the same method to set dlv-pageTitle and dlv-pageUrl.
Step 4: Set Up the Custom Event Trigger
In GTM , click「Triggers」——「New」——「Choose a trigger type to begin setup…」——「Custom Event」,Name it “Custom Event—Pageview “, and make the following settings:
Step 5:Set Up the Tag
Create a Google Tag (no trigger):
In GTM , click「Tags」——「New」——「Choose a tags type to begin setup…」——「Google Analytics」——「Google Tag」,Name it “GA4 update”, and make the following settings:
Then create a GA4 Event tag:
In GTM , click「Tags」——「New」——「Choose a tags type to begin setup…」——「Google Analytics」——「Google Analytics: GA4 Event」,Name it “GA4-Page View Event”, and make the following settings:
GA4 update is triggered before GA4-Page View Event and is mainly used to update configuration parameters:
Key Takeaways
- Advantages: More accurate URLs and page titles than Methods 1 or 2. No developer involvement.
- Disadvantages: Risk of data loss if the delay is too long (user navigates away before the event fires) or too short (inaccurate URLs persist).
- Best for: SPAs that update the page title or URL slightly after the History API fires.
Method 4: dataLayer.push (Developer-Driven)
If you want maximum accuracy and have access to your development team, this is the method I recommend. It’s the most reliable approach, but it does require developer effort.
The idea is straightforward: whenever the SPA changes routes or updates content, the developer manually pushes a page_view event to the data layer with the correct URL, page title, and any other parameters you need.
Step 1: Developer Implements dataLayer.push
Your development team needs to add the following code to every page/route change in the SPA:
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
'event': 'Pageview',
'pageUrl': 'https://www.example.com/something/contact-us',
'pagePath': '/something/contact-us',
'pageTitle': 'Contact us'
});
</script>
This step is where the heavy lifting happens. Each route needs its own push. Your devs should put this in the SPA‘s route change handler.
Step 2:Set Up the Data Layer Variable
In GTM, click「 Variables」——「New」——「Choose a variable type to begin setup…」——「Data Layer Variable」, name it “dlv—pagePath, and then set as follows:
Use the same method to set dlv-pageTitle and dlv-pageUrl.
Step 3: Set Up the Custom Event Trigger
In GTM , click「Tags」——「New」——「Choose a triggers type to begin setup…」——「Custom Event」,Name it “Custom Event—Pageview”, and make the following settings:
Step 4:Set Up the Tag
In GTM , click「Tags」——「New」——「Choose a tags type to begin setup…」——「Google Analytics」——「Google Tag」,Name it “GA4 Basic Tracking”, and make the following settings:
Key Takeaways
- Advantages: Maximum accuracy. Full control over every parameter. Works with any SPA architecture, even those that don’t use the History API.
- Disadvantages: Requires significant development effort and ongoing coordination with the dev team.
- Best for:All SPA architectures — this is the recommended approach for production-level tracking.
Comparison Table
| Method | Advantages | Disadvantages |
|---|---|---|
| Method 1: Enhanced Measurement | Easy to use, no coding required | Inaccurate URLs and page titles |
| Method 2: History Change | No developer involvement | Inaccurate URLs and page titles |
| Method 3: Delayed Send | No developer involvement, more accurate URLs | Risk of data loss with long delays |
| Method 4: dataLayer.push | Maximum flexibility and accuracy | Requires significant development work |
Final Words
So, which method should you choose? It depends on your situation:
- If you have a simple SPA and don’t need perfect accuracy — start with Enhanced Measurement.
- If Enhanced Measurement isn’t enough and you have no developer access — go with History Change (Method 2).
- If you need better accuracy but still can’t involve developers — try the Delayed Send approach (Method 3).
- If you want the most reliable, production-grade tracking — push for Method 4 with your development team.
I’ve seen all four approaches in production, and honestly, there’s no one-size-fits-all answer. I’ve also seen cases where a combination of Method 2 and Method 4 works best — use History Change for the initial page load and dataLayer.push for deeper, content-heavy routes.








