Lukachyna

Connecting GA4 to Next.js SPAs Without Breaking Referral Tracking

Next.jsAnalyticsGA4

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.
typescript
// ❌ 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.

typescript
// 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,
  });
}
typescript
// 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.
Stepan Lukachyna

Frontend engineer, educator, and occasional researcher. Writes about web performance, architecture patterns, and the gaps in documentation no one tells you about.

Get in touch