Fixing Direct Traffic Inflation in GA4 for Single Page Applications
If your SPA built with Nuxt.js, Next.js, or React shows unusually high Direct traffic in GA4, the problem is likely that document.referrer doesn't update on client-side navigation. Learn how to fix this using GTM's History Change trigger and a custom page_referrer variable.
The Problem
If you’re running a Single Page Application (SPA) built with frameworks like Nuxt.js, Next.js, React Router, or Vue Router, you might notice an unusually high percentage of Direct traffic in your Google Analytics 4 reports.
This happens because:
SPAs don’t trigger traditional page loads - When users navigate between pages, the browser doesn’t perform a full page reload. Instead, JavaScript updates the content dynamically.
document.referrerdoesn’t update on SPA navigation - The browser’sdocument.referreronly captures the referrer from the initial page load. It doesn’t change when users navigate within your SPA.GA4’s
page_referrerparameter is empty or stale - Without proper configuration, GA4 receives an emptydr(document referrer) parameter, causing it to classify sessions as “Direct” even when users came from other pages on your site or external sources.
Diagnosing the Issue
Symptoms
- Abnormally high Direct traffic percentage (often 40-60%+)
- Low page depth per session despite good engagement
- Attribution issues affecting campaign performance analysis
How to Confirm
- Open your browser’s Developer Tools → Network tab
- Navigate between pages on your SPA
- Filter for requests to
google-analytics.comor your server-side endpoint - Look at the
drparameter in the GA4 collect requests
If dr= is empty on internal navigations, you have this issue:
/g/collect?v=2&tid=G-XXXXXX&...&dr=&dl=https://yoursite.com/page2/...
^^^
Empty!The Solution
Overview
We need to:
- Track SPA navigations using GTM’s History Change trigger
- Capture the previous page URL and pass it as
page_referrer - Prevent duplicate page views on initial page load
Implementation in Google Tag Manager
Step 1: Create the Page Referrer Variable
Create a Custom JavaScript variable that returns the correct referrer for both scenarios:
Variable Name: CJ - Page Referrer (SPA)
function() {
// Get the old URL from History Change event (for SPA navigations)
var dataLayer = window.dataLayer || [];
var historyOldUrl = null;
// Find the most recent gtm.historyChange event
for (var i = dataLayer.length - 1; i >= 0; i--) {
if (dataLayer[i].event === 'gtm.historyChange' && dataLayer[i]['gtm.oldUrl']) {
historyOldUrl = dataLayer[i]['gtm.oldUrl'];
break;
}
}
// If we have a history old URL and it's different from current URL, use it
if (historyOldUrl && historyOldUrl !== window.location.href) {
return historyOldUrl;
}
// Otherwise, return document.referrer (for initial page load)
return document.referrer || '';
}Step 2: Create a Helper Variable to Detect Same-URL History Changes
Some SPAs trigger replaceState on initial load where oldUrl === newUrl. We need to block these to prevent duplicate page views.
Variable Name: CJ - Is Same URL (History Change)
function() {
var dataLayer = window.dataLayer || [];
// Find the most recent gtm.historyChange event
for (var i = dataLayer.length - 1; i >= 0; i--) {
if (dataLayer[i].event === 'gtm.historyChange') {
var oldUrl = dataLayer[i]['gtm.oldUrl'] || '';
var newUrl = dataLayer[i]['gtm.newUrl'] || '';
return oldUrl === newUrl;
}
}
return true; // Block if no history change found
}Step 3: Create the History Change Trigger
Create a new trigger:
- Trigger Name:
History Change - SPA Navigation - Trigger Type: History Change
- Fires on: All History Changes
Step 4: Create the Blocking Trigger
Create a trigger to prevent firing when it’s not a real navigation:
- Trigger Name:
Block - Same URL History Change - Trigger Type: Custom Event
- Event Name:
.*(regex match all) - Fire when:
{{CJ - Is Same URL (History Change)}}equalstrue
Step 5: Create the SPA Page View Tag
Create a new GA4 Event tag:
- Tag Name:
GA4 - Event - Page View (SPA Navigation) - Tag Type: Google Analytics: GA4 Event
- Measurement ID: Your GA4 ID (or use a variable)
- Event Name:
page_view - Event Parameters:
page_referrer:{{CJ - Page Referrer (SPA)}}page_location:{{Page URL}}page_title:{{Page Title}}
- Firing Trigger:
History Change - SPA Navigation - Blocking Trigger:
Block - Same URL History Change
Step 6: Update Your Existing Page View Tag
Add the page_referrer parameter to your existing Page View tag (the one that fires on initial page load):
- Event Parameters:
page_referrer:{{CJ - Page Referrer (SPA)}}
How It Works
┌─────────────────────────────────────────────────────────────────┐
│ Initial Page Load │
├─────────────────────────────────────────────────────────────────┤
│ 1. User lands on your site from Google │
│ 2. Original Page View tag fires │
│ 3. page_referrer = document.referrer (google.com) │
│ 4. GA4 correctly attributes: Organic Search │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ SPA Navigation │
├─────────────────────────────────────────────────────────────────┤
│ 1. User clicks internal link (Home → Product Page) │
│ 2. History Change event fires │
│ 3. SPA Page View tag fires │
│ 4. page_referrer = gtm.oldUrl (your homepage) │
│ 5. GA4 maintains session continuity │
└─────────────────────────────────────────────────────────────────┘Additional Considerations
Server-Side Tagging
If you’re using server-side GTM (Stape, Addingwell, etc.), ensure your server-side GA4 tag is configured to forward the page_referrer parameter from the client.
Disable Conflicting Enhanced Measurement
In GA4 Admin → Data Streams → Enhanced Measurement, consider disabling “Page changes based on browser history events” to prevent duplicate tracking. GTM will handle this instead.
Testing
- Open GTM Preview mode
- Navigate to your site
- Click through several pages
- Verify in the Preview panel:
- Initial load: Original Page View tag fires with external referrer
- SPA navigation: SPA Page View tag fires with internal referrer
- No duplicates on initial
replaceStateevents
Results
After implementing this solution:
- ✅ Accurate traffic source attribution - Sessions maintain their original source
- ✅ Proper page path tracking - All virtual pageviews are captured
- ✅ Reduced Direct traffic - Only truly direct visits are classified as Direct
- ✅ Better session stitching - GA4 can properly connect page views within sessions
Common Pitfalls
- Don’t forget the blocking trigger - Without it, you may get duplicate page views
- Test thoroughly - SPAs vary in how they handle routing; test your specific framework
- Check consent timing - Ensure consent is granted before page view tags fire
- Coordinate with developers - For best results, have developers push a custom
virtualPageviewevent with all necessary data
Related Resources
- Tracking Strategy: How to Build a Measurement System That Works - Learn how to build a comprehensive tracking strategy
- Server-Side Tracking Overview - For more advanced tracking implementations
- GA4 Debug Mode: Complete Troubleshooting Guide - Debug and verify your SPA tracking setup
This solution was implemented for a Nuxt.js e-commerce site experiencing 50%+ Direct traffic. After deployment, Direct traffic dropped to expected levels (~15-20%) and campaign attribution improved significantly.