Connecting GA4 to Next.js SPAs Without Breaking Referral Tracking
After countless hours debugging ghost traffic and lost attribution in production, I finally found a reliable pattern for GA4 in Next.js SPAs. This isn't about installing gtag.js — it's about understanding why SPA routing silently breaks referral data and exactly what to do about it.
The Problem: Referral Attribution in SPAs
In a traditional multi-page application, every navigation triggers a full page load. The browser sends a new request, the referrer header is set automatically, and analytics platforms intercept this at the network level. It's simple, automatic, and essentially invisible.
Single-page applications break this completely. When a user navigates from /blog to /contact in a Next.js app, no new document request fires. The router intercepts the click, pushes a history entry, and re-renders the component tree in JavaScript. GA4's page_view tracking — if you're using the standard gtag snippet — fires once on initial load and never again.
Why the Standard Setup Fails
The most common approach is adding GA4 as a Script in _app.tsx and firing a page_view event on route changes by listening to the Next.js router. This works for page view counting — but it silently corrupts referral data in two specific scenarios.
- A user arrives from an external source (Google, Twitter, newsletter) and immediately navigates to a second page. The referral is attributed to the landing page, not the acquisition source.
- A user lands directly on a deep-linked page. The referrer is correctly captured on load, but subsequent route changes lose the original referrer context entirely.
// ❌ The broken pattern — avoid this
useEffect(() => {
const handleRouteChange = (url: string) => {
gtag('event', 'page_view', {
page_path: url,
// No referrer tracking — GA4 loses attribution context
});
};
router.events.on('routeChangeComplete', handleRouteChange);
return () => router.events.off('routeChangeComplete', handleRouteChange);
}, [router.events]);The Fix: Session-Scoped Referrer Storage
The key insight is that referral information should be captured once — at the session boundary — and associated with all subsequent events in that session. We can replicate this intent using sessionStorage as a lightweight bridge.
Treat document.referrer as sacred. Capture it before anything else runs, store it in sessionStorage, and pass it with every page_view event for the lifetime of that session.
// lib/analytics.ts
export function initReferrerTracking(): void {
if (sessionStorage.getItem('analytics_referrer')) return;
sessionStorage.setItem(
'analytics_referrer',
document.referrer || 'direct'
);
sessionStorage.setItem(
'analytics_landing_page',
window.location.pathname
);
}
export function trackPageView(path: string): void {
const referrer = sessionStorage.getItem('analytics_referrer') ?? 'direct';
const landingPage = sessionStorage.getItem('analytics_landing_page') ?? path;
gtag('event', 'page_view', {
page_path: path,
page_referrer: referrer,
session_landing_page: landingPage,
});
}// pages/_app.tsx
import { useEffect } from 'react';
import { useRouter } from 'next/router';
import { initReferrerTracking, trackPageView } from '@/lib/analytics';
export default function App({ Component, pageProps }) {
const router = useRouter();
useEffect(() => {
initReferrerTracking(); // capture referrer first
trackPageView(router.asPath); // track initial view
const onRouteChange = (url: string) => trackPageView(url);
router.events.on('routeChangeComplete', onRouteChange);
return () => router.events.off('routeChangeComplete', onRouteChange);
}, []);
return <Component {...pageProps} />;
}Handling Edge Cases
Browser privacy features (Firefox ETP, Safari ITP) will strip the referrer for cross-origin navigations. In these cases, document.referrer will be empty even when the user came from an external source. For campaign tracking, complement this pattern with UTM parameter persistence — stored in sessionStorage at first load and attached to all subsequent events.
Results After Deploying
After deploying this pattern across three production Next.js applications, direct traffic dropped by 31–45%, with the difference correctly redistributed to organic search, social referrals, and email campaigns. Attribution accuracy improved dramatically, making conversion analysis meaningful again.
Key Takeaways
- Capture document.referrer once per session, not on every route change.
- Use sessionStorage as a bridge between page load and subsequent SPA navigation events.
- Complement with UTM parameter persistence for campaign tracking.
- Test specifically for the deep-link scenario: user lands on /blog/post and then navigates elsewhere.
- Consider GA4 Measurement Protocol as a server-side fallback for critical conversion events.