#GA4#Next.js#Analytics#UTM Tracking#Web Performance

Why GA4 Strips UTM Parameters on SPA Route Transitions (And How to Fix It)

Diagnose why Next.js App Router and SPA client transitions lose UTM campaign attribution, causing paid traffic to register as (direct) / (none), with battle-tested session persistence fixes.

O
OmniSEO Engineering
Technical SEO & Analytics Architecture
Sep 22, 2026
7 min read

You spend thousands of dollars distributing custom-tagged campaign links across paid search, newsletters, and social ads (?utm_source=twitter&utm_medium=paid_social&utm_campaign=q3_launch). Yet when you open your Google Analytics 4 (GA4) Traffic Acquisition reports, you are greeted by an alarming reality: massive spikes in (direct) / (none) traffic and Unassigned channel groupings, while your paid campaigns report single-digit sessions.

This issue is not caused by ad blockers, broken links, or crawler bots. It is the result of a subtle architectural race condition between Single Page Application (SPA) client-side routing transitions (such as Next.js App Router, Astro client islands, and React Router) and Google Analytics 4's Enhanced Measurement page_view lifecycle.

In this guide, we diagnose the exact timing conflict that strips UTM parameters in modern SPAs, compare framework vulnerabilities, and provide a production-ready TypeScript session persistence component for Next.js and GTM.

Popular

Generate Clean, Compliant UTM Links

Audit your campaign parameters, enforce lowercase conventions, and prevent character encoding drops before launching campaigns.


Section 1: The SPA Navigation Lifecycle vs. The Enhanced Measurement Race Condition

In a traditional multi-page website (MPA), every link click triggers a full browser document request. The browser passes the full query string to the server, and the tracking tag on the newly loaded HTML document executes with the complete window.location.href.

In modern Single Page Applications (Next.js App Router, Astro, Remix, Vite React), navigation happens entirely in memory on the client side via the HTML5 History API (pushState and replaceState). This creates an attribution trap:

CODE
[Inbound Campaign Click]
       │
       ▼
1. Browser loads URL with query: https://example.com/?utm_source=linkedin&utm_medium=social
       │
       ▼
2. Client Component mounts & executes router.replace('/landing') to "clean" the URL
       │
       ▼
3. window.location.search is now EMPTY ("")
       │
       ▼
4. GA4 Enhanced Measurement or GTM History Change trigger fires
       │
       ▼
[Result: page_location dispatched WITHOUT UTMs → Attributed to (direct) / (none)]

The 3-Step Race Condition Breakdown

  1. Step 1 (Inbound Landing): The visitor arrives on https://example.com/?utm_source=linkedin&utm_medium=social&utm_campaign=enterprise_launch. The browser parses the raw URL string.
  2. Step 2 (Premature URL Sanitization): Many engineering teams configure client components or middleware to strip marketing query parameters to keep URLs visually clean, prevent duplicate social shares, or sanitize referral tokens. They call router.replace(pathname) or window.history.replaceState({}, '', pathname) immediately during the component mount lifecycle.
  3. Step 3 (Delayed Analytics Dispatch): GA4's gtag.js script or Google Tag Manager's history change listener executes asynchronously. By the time gtag('event', 'page_view') reads document.location.href to construct the page_location parameter, the UTM query string has already been purged. GA4 receives a bare URL path with no attribution parameters, defaulting the session to (direct) / (none).

Section 2: SPA Frameworks vs. Query Persistence Vulnerability

Different web application architectures handle query parameter lifecycles and analytics hydration differently. Below is an overview of how popular modern frameworks interact with GA4 attribution:

| Framework / Architecture | Routing Model | Default Query Persistence | Parameter Strip Risk | Primary Root Cause | | :--- | :--- | :--- | :--- | :--- | | Next.js App Router (v14/v15) | Hybrid Server/Client Transitions | High (URL preserved unless replaced) | Critical on premature router.replace() | useSearchParams hydration runs before third-party tracking scripts finish dispatching | | Astro (View Transitions) | MPA with Client Islands | High (Full page reload by default) | Medium on custom swap handlers | Client routers re-dispatching page_view events without retaining landing parameters | | Remix / React Router v7 | Client-Side SPA Transitions | High | High during state resets | Custom navigate(path, { replace: true }) stripping search params during onboarding modals | | Nuxt 3 (Vue / Nitro) | Universal SSR + Client Hydration | High | High on route middleware | Middleware redirects dropping query objects on dynamic route rewrites |


Section 3: The Battle-Tested Next.js Solution (UtmSessionPersister.tsx)

To permanently eliminate UTM parameter stripping without preventing URL cleanup, implement a client-side session persister.

This solution:

  1. Captures all incoming marketing parameters (utm_source, utm_medium, utm_campaign, utm_term, utm_content, gclid, fbclid) on initial landing.
  2. Stores them in sessionStorage, ensuring attribution data persists across client-side SPA route changes.
  3. Attaches persistent campaign data to custom GA4 event payloads and preserves attribution even if the visual URL bar is cleaned up.
TYPESCRIPT
"use client";

import { useEffect, Suspense } from "react";
import { useSearchParams, usePathname } from "next/navigation";

const TRACKED_PARAMS = [
  "utm_source",
  "utm_medium",
  "utm_campaign",
  "utm_term",
  "utm_content",
  "gclid",
  "fbclid",
  "msclkid",
] as const;

function UtmSessionPersisterInner() {
  const searchParams = useSearchParams();
  const pathname = usePathname();

  useEffect(() => {
    if (typeof window === "undefined") return;

    let hasNewParams = false;
    const currentStored: Record<string, string> = JSON.parse(
      sessionStorage.getItem("omni_utm_attribution") || "{}"
    );

    TRACKED_PARAMS.forEach((paramKey) => {
      const paramValue = searchParams.get(paramKey);
      if (paramValue) {
        currentStored[paramKey] = paramValue;
        hasNewParams = true;
      }
    });

    if (hasNewParams) {
      sessionStorage.setItem("omni_utm_attribution", JSON.stringify(currentStored));

      // Push custom attribution payload to GTM dataLayer or gtag
      if (typeof window !== "undefined" && (window as unknown as { dataLayer?: unknown[] }).dataLayer) {
        (window as unknown as { dataLayer: unknown[] }).dataLayer.push({
          event: "utm_parameters_captured",
          campaign_attribution: currentStored,
          landing_path: pathname,
        });
      }
    }
  }, [searchParams, pathname]);

  return null;
}

export function UtmSessionPersister() {
  return (
    <Suspense fallback={null}>
      <UtmSessionPersisterInner />
    </Suspense>
  );
}

Add <UtmSessionPersister /> into your root layout.tsx inside the <body> tag:

TYPESCRIPT
import { UtmSessionPersister } from "@/components/analytics/UtmSessionPersister";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <UtmSessionPersister />
        {children}
      </body>
    </html>
  );
}

Section 4: The GA4 / GTM Configuration Fix

If you use Google Tag Manager (GTM) or standard gtag.js script tags, configure your initial config command to prioritize the persistent landing URL over the stripped runtime URL:

JAVASCRIPT
// Inline GA4 Configuration with Session-Aware page_location
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());

// Retrieve stored attribution if present
const storedAttribution = sessionStorage.getItem('omni_utm_attribution');
const campaignData = storedAttribution ? JSON.parse(storedAttribution) : {};

gtag('config', 'G-XXXXXXXXXX', {
  send_page_view: false, // Manage pageviews manually on SPA route transitions
  page_location: window.location.href,
  campaign_source: campaignData.utm_source,
  campaign_medium: campaignData.utm_medium,
  campaign_name: campaignData.utm_campaign,
});

GTM Best Practice: In Google Tag Manager, configure a Custom JavaScript Variable named {{JS - Persistent Page Location}} that reconstructs the full URL from sessionStorage if the current {{Page URL}} lacks query parameters. Pass this variable into the page_location field of your GA4 Configuration and Event tags.


Section 5: Lateral Tool Bridges

Validate and streamline your social and marketing campaign links with our zero-latency developer utilities:

Updated

Preview Social Share Cards

Ensure your Open Graph and Twitter card destination URLs include the correct UTM parameters.

Popular

Test Organic SERP Rendering

Compare organic query landing paths against paid campaign tracking parameters.


Section 6: Frequently Asked Technical Questions

Featured Interactive Tool

Launch Campaign UTM Builder

Generate custom campaign URLs with GA4 UTM tracking parameters, instant validation, and 1-click clipboard copying.

O
Written by OmniSEO Engineering
Technical SEO & Analytics Architecture • OmniSEO Tools Core Engineering