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.
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:
[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
- 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. - 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)orwindow.history.replaceState({}, '', pathname)immediately during the component mount lifecycle. - Step 3 (Delayed Analytics Dispatch): GA4's
gtag.jsscript or Google Tag Manager's history change listener executes asynchronously. By the timegtag('event', 'page_view')readsdocument.location.hrefto construct thepage_locationparameter, 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:
- Captures all incoming marketing parameters (
utm_source,utm_medium,utm_campaign,utm_term,utm_content,gclid,fbclid) on initial landing. - Stores them in
sessionStorage, ensuring attribution data persists across client-side SPA route changes. - Attaches persistent campaign data to custom GA4 event payloads and preserves attribution even if the visual URL bar is cleaned up.
"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:
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:
// 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 fromsessionStorageif the current{{Page URL}}lacks query parameters. Pass this variable into thepage_locationfield 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:
Preview Social Share Cards
Ensure your Open Graph and Twitter card destination URLs include the correct UTM parameters.
Test Organic SERP Rendering
Compare organic query landing paths against paid campaign tracking parameters.