UTM Parameters Not Tracked in Single-Page Apps: Why They Break and How to Validate They Survive

UTM parameters "disappear" in single-page apps because client-side navigation — React Router, Vue Router, Next.js App Router, Nuxt 3, SvelteKit, and every other client-side routing library — re-renders the view without a full page load. When a visitor arrives at /landing?utm_source=linkedin&utm_medium=paid-social&utm_campaign=q3, the browser loads a fresh HTML document, gtag.js initialises, and a page_view event fires with the UTM parameters. That works correctly. But when the visitor clicks a <Link>, <router-link>, or goto() call, the router calls window.history.pushState — the URL changes, new content renders, and gtag.js fires nothing. GA4 attributes conversions on every downstream page to (direct)/(none). The universal client-side fix is to capture UTM parameters from location.search on first load, persist them to sessionStorage immediately, and attach them to each analytics event manually. But that fix only addresses your app's client-side layer — it does nothing to prove the campaign link you built actually delivers UTM parameters to the landing page in the first place. A redirect hop, a link shortener, or an edge rewrite can strip parameters before your SPA ever sees them, and your sessionStorage capture will be empty with no indication why. MissingLinkz's mlz preflight follows the full redirect chain and confirms UTM parameters reach the final URL — the pre-publish check that separates a broken link from a broken app.

Hub-and-spoke diagram: a central dark terminal shows mlz preflight output with 'ready: true' and UTM parameters confirmed at destination. Ten framework labels surround it — React Router, Angular, Vue, SvelteKit on the left; Next.js, Nuxt, Cloudflare Workers on the right; Google Tag Manager and Safari ITP above; Cookie Consent and JS Redirect below — each connected by dashed purple lines.

The universal root cause: client-side routing does not reload the page

Traditional web apps use the browser's native navigation model: every link click sends a new HTTP request, the server returns a full HTML document, and the browser parses it from scratch. Every page load initialises gtag.js fresh, and GA4 reads UTM parameters from the new URL.

Single-page apps replace this model entirely. The initial HTML document loads once. After that, all navigation happens inside the JavaScript runtime using the History API — specifically window.history.pushState, which updates the URL bar without making a new HTTP request and without reloading the page. The script tags in <head>, including gtag.js, do not re-execute. GA4's automatic page_view event, which fires when gtag.js loads, fires exactly once per session.

The same mechanism underlies every affected framework in this cluster: React Router's <Link>, Vue Router's <RouterLink>, Next.js App Router's <Link>, Nuxt 3's <NuxtLink>, SvelteKit's <a> interception, Angular's RouterLink directive, and Remix's <Link>. The specific APIs differ; the underlying mechanism is identical.

Edge environments introduce a second class of breakage: middleware, CDN redirect rules, and consent gates that rewrite or intercept the URL before the SPA ever loads. The app's client-side routing is irrelevant here — the UTM parameters are gone before JavaScript runs.

The universal client-side fix: capture once, persist, attach at event time

Regardless of framework, the fix for SPA UTM attribution follows the same three-step pattern:

Step 1 — Capture from location.search on first load
When the initial HTML document loads and JavaScript initialises, read UTM parameters from window.location.search using URLSearchParams. This is the only moment the full URL including UTM parameters is guaranteed to be present.
Step 2 — Persist to sessionStorage immediately
Write the UTM parameters to sessionStorage before any navigation occurs. sessionStorage persists across client-side navigations within the same browser tab and is cleared when the tab closes — it is the correct storage primitive for UTM attribution within a single session. Do not use localStorage for this purpose: it persists across sessions and will contaminate future visits with stale campaign data.
Step 3 — Read from sessionStorage and attach to each analytics event
In your framework's route-change listener, read UTM parameters from sessionStorage and include them in the page_view event sent to GA4 via window.gtag('event', 'page_view', { page_path: location.pathname + location.search }). The routing listener implementation differs per framework — see the deep-dive guides below.

This pattern fixes attribution for pages visited after the initial landing. It does not affect the initial page_view event, which fires correctly with UTM parameters already present in the URL.

Validate the campaign link before you debug the app

Before adding any framework-specific tracking code, verify that the campaign link you generated actually delivers UTM parameters to the landing page. MissingLinkz's mlz preflight follows every redirect hop in the chain and reports whether UTM parameters survive to the final URL.

This step catches the failure mode that client-side debugging cannot: a link shortener, a vanity domain redirect, a Cloudflare redirect rule, or an edge middleware rewrite that strips ?utm_source=... before the browser receives the HTML response. If the parameters are stripped at the network layer, your sessionStorage capture will always be empty regardless of how correctly you implement the framework-side fix.

mlz preflight --url "https://example.com/landing" --source "linkedin" --medium "paid-social" --campaign "q3-launch"
mlz preflight — confirm UTM parameters reach the landing page before debugging routing
{
  "ready": true,
  "tracked_url": "https://example.com/landing?utm_source=linkedin&utm_medium=paid-social&utm_campaign=q3-launch",
  "checks": [
    { "check": "redirects",    "status": "pass", "message": "No redirects detected. UTM parameters intact." },
    { "check": "ssl",          "status": "pass", "message": "URL uses HTTPS." },
    { "check": "resolution",   "status": "pass", "message": "Destination responded with 200." },
    { "check": "og_tags",      "status": "pass", "message": "Open Graph tags present." }
  ],
  "recommendation": "All checks passed. Network layer clean — UTM parameters confirmed at destination."
}

A clean result means the network is not the problem: UTM parameters reach the browser, and the attribution loss is in the app's client-side routing code. Proceed to the framework-specific guide below. If "redirects": "fail" shows a dropped query string, fix the network layer first — see How to Check if a Redirect Strips UTM Parameters and How to Check if UTM Parameters Survive a 301 vs 302 Redirect for the common patterns.

Framework and environment deep dives

Each environment in this cluster breaks UTM attribution through a distinct mechanism. Select the guide that matches your stack:

React Router v6 (standalone + Create React App)
React Router's <Link> calls history.pushState. The fix uses useLocation from react-router-dom inside a useEffect hook to fire page_view on every route change. Applies to any React app using React Router independently — not inside a framework like Next.js or Remix.
Next.js App Router
Next.js App Router uses React Server Components on the server and client-side navigation via <Link> in the browser. The fix uses a client component that subscribes to usePathname and useSearchParams from next/navigation to re-fire GA4 events on each navigation. Applies to Next.js 13+ with the App Router directory structure.
Angular and Vue SPA
Angular's RouterLink and Vue Router's <RouterLink> both use history.pushState. Angular's fix subscribes to Router.events filtered to NavigationEnd; Vue's fix calls router.afterEach() in the App.vue mounted hook. Both persist UTMs to sessionStorage on first load.
SvelteKit
SvelteKit intercepts anchor clicks and goto() calls using history.pushState. The fix uses the afterNavigate lifecycle function from $app/navigation inside the root +layout.svelte file. This runs after every navigation including the initial page load.
Nuxt 3
Nuxt 3's <NuxtLink> uses Vue Router under the hood. The fix uses router.afterEach() inside a Nuxt plugin registered in plugins/analytics.client.ts, which runs only in the browser and fires page_view on every route change.
Remix
Remix is built on React Router v6 and uses the same pushState-based navigation. The fix wraps useLocation and useEffect in a PageViewTracker component placed inside app/root.tsx, which wraps every route in the application.
Gatsby
Gatsby uses React Router internally and exposes a gatsby-browser.js API file with an onRouteUpdate hook. The fix exports an onRouteUpdate function that fires page_view on every client-side navigation — or uses the gatsby-plugin-google-gtag plugin, which wires this hook automatically.
Google Tag Manager
GTM's default GA4 configuration fires on the initial page load only. In an SPA, GTM must be configured with a custom History Change trigger that fires a GA4 event on every pushState or replaceState call. Without this trigger, all client-side navigations are invisible to GA4 tags in GTM — including UTM attribution for downstream conversions.
Safari Intelligent Tracking Prevention (ITP)
Safari ITP is not a routing problem — it is a browser privacy feature that caps JavaScript-set cookies at 7 days and restricts storage access after a "redirect-through-tracker" hop. If your campaign link passes through a link shortener or click tracker before the landing page, ITP may classify the referrer as a tracker and corrupt UTM attribution for 25–35% of mobile and desktop Safari traffic. The fix requires direct link delivery or server-set cookies.
Cloudflare Workers and Edge Middleware
Cloudflare Workers, Vercel Edge Middleware, and similar platforms execute before the HTML response reaches the browser. A misconfigured rewrite or redirect rule that drops the query string strips UTM parameters before your SPA ever initialises. The fix must be in the edge configuration, not the client-side app.
Cookie Consent Gates
Cookie consent managers (Cookiebot, OneTrust, Osano) can redirect users to a consent page and back before allowing the campaign URL to load. If that redirect drops the query string, all UTM parameters are lost before GA4's consent mode can read them. The fix ensures the consent redirect preserves the full original URL including query parameters.
JavaScript Redirects
window.location.href = '...', window.location.replace(), and window.location.assign() are full-page navigations but they construct the destination URL in JavaScript. If the code omits window.location.search when building the target URL, UTM parameters are stripped. The fix ensures any JS redirect carries the original query string to the destination.

Two diagnostic commands: when to use each

MissingLinkz exposes two commands useful for SPA UTM debugging:

mlz check <url> — network-layer validation
Validates URL format, HTTPS, HTTP resolution, redirect chain, and response time. Use this when you want a fast diagnostic of whether UTM parameters survive the redirect chain without running the full pre-publish suite. Useful during development when you want to confirm your landing URL resolves cleanly before building campaign links.
mlz preflight --url ... --source ... --medium ... --campaign ... — full pre-publish check
Builds the UTM-tagged link, validates the destination (SSL, resolution, redirects, UTM survival), and inspects the landing page for social sharing readiness (OG tags, Twitter Card, viewport). Returns a ready: true/false verdict. Use this before any campaign goes live — it confirms both the link structure and the destination in one command. See the mlz preflight comparison guide for what it checks that other tools miss.

For SPA debugging specifically: run mlz check first to rule out the network layer quickly. If the redirect chain is clean and UTM parameters survive, the problem is in your app's routing code — pick the relevant framework guide above. If mlz check shows a redirect that drops the query string, fix that first; see How to Check if a Redirect Strips UTM Parameters.

FAQ

What exactly is "utm parameters not tracked in single page app"?
It means GA4 receives a page_view event on the initial landing page with correct UTM attribution, but all subsequent navigations within the same tab produce page_view events with no UTM data — or no page_view events at all. Conversions on downstream pages appear as (direct)/(none) in GA4 reports. The cause is that single-page app routers use window.history.pushState to change the URL without reloading the page, and gtag.js has no listener for pushState calls by default.
Should I fix the network layer or the app code first?
Network layer first, always. Run mlz check or mlz preflight on your campaign URL before touching any app code. If UTM parameters are being stripped by a redirect, a link shortener, or an edge rule, no amount of client-side tracking code will recover them — sessionStorage will be populated with an empty string. Only after confirming the network layer is clean should you proceed to framework-specific routing fixes.
Do all SPAs have exactly the same UTM tracking problem?
They share the same root cause — pushState-based navigation bypasses gtag.js — but the implementation details differ significantly. Gatsby exposes onRouteUpdate in gatsby-browser.js; SvelteKit uses afterNavigate in +layout.svelte; Vue Router uses router.afterEach(); Angular subscribes to Router.events. The fix pattern is universal but the specific code is framework-dependent. Edge environments like Cloudflare Workers and cookie consent gates break attribution through completely different mechanisms — they strip UTMs before JavaScript initialises, which no client-side fix can address.
Can mlz preflight validate UTM survival through a redirect chain before production?
Yes. mlz preflight follows the complete redirect chain — including HTTP 301, 302, 307, and 308 redirects, link shorteners, and vanity domain redirects — and reports whether UTM parameters survive to the final destination. Run it against your staging or preview URL before deploying to production. If the network layer is clean in staging and clean in production, the attribution issue is definitively in the app's routing code, not the link.
Why does GA4 show (direct)/(none) instead of my UTM source for some sessions?
Three common causes: (1) UTM parameters were stripped by a redirect before the browser loaded the page — run mlz check to verify. (2) The user visited a downstream page and GA4 could not attribute the session because no page_view event with UTM data was fired on that navigation — fix with the framework-specific routing hook. (3) Safari ITP has restricted the cookie or storage access that would carry UTM attribution across a tracker redirect — see the Safari ITP guide for the specific mitigation.

Validate UTM survival before you debug your SPA routing

Rule out the network layer in one command. If mlz preflight shows UTM parameters intact at the destination, you know exactly where to look next.

npm install -g missinglinkz

Then: mlz preflight --url "https://your-landing-page.com" --source linkedin --medium paid-social --campaign q3

Recommended posts