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

Фронтенд-інженер, викладач і дослідник. Пише про продуктивність веб-застосунків, архітектурні патерни та прогалини в документації, про які ніхто не розповідає.

Зв'язатись