UTM Parameters in MPA vs SPA: Why Attribution Breaks Differently and How to Diagnose It

UTM attribution breaks in both multi-page apps and single-page apps, but through entirely different mechanisms — and the fix for one will not help the other. In an MPA (any server-rendered app: WordPress, Rails, Django, PHP, Next.js Pages Router with full server navigation), attribution loss is almost always caused by a redirect that strips the query string before the browser loads the landing page. GA4 fires correctly on every page load; it just never received the UTM parameters. In an SPA (React Router, Vue Router, Next.js App Router, SvelteKit, Nuxt 3, Angular), the landing page loads correctly with UTM parameters, GA4 fires on the initial load, and then — when the visitor navigates using the framework's client-side router — GA4 fires nothing, attributing all downstream page views and conversions to (direct)/(none). The network layer is not the problem. Before spending hours debugging your framework routing code or your redirect rules, run mlz preflight once. It follows every redirect hop and reports whether UTM parameters survive to the final URL. The result tells you definitively which failure mode you're dealing with — and which guide to read next.

Two-panel comparison diagram on a warm cream background. Left panel (MPA): navigation sequence showing a full page reload where a 301 redirect drops the query string before gtag.js loads — the page_view event fires but (direct)/(none) is recorded. Right panel (SPA): navigation sequence showing the initial load fires page_view correctly, but a Link click using history.pushState produces no page_view event — gtag.js is already loaded and receives no signal. Both panels show mlz preflight output diagnosing the failure mode.

Two architectures, two failure modes, one diagnostic tool

Understanding which failure mode affects your app saves significant debugging time. The observable symptom is identical in both cases — UTM parameters do not appear in GA4 reports — but the root cause and fix are completely different.

MPA failure mode: the network strips UTM parameters before GA4 sees them
In a multi-page app, every link click triggers a full HTTP request and a full page reload. The browser requests /landing?utm_source=google&utm_medium=cpc&utm_campaign=q3, the server responds, the HTML page loads from scratch, and gtag.js initialises and reads UTM parameters from document.location.search. This part works correctly. The failure happens when the campaign URL passes through a redirect: a link shortener, a CDN redirect rule, a vanity domain, or an HTTPS upgrade that drops ?utm_* from the query string before the final 200 response. GA4 fires a page_view event with the correct page path but no UTM data. The fix is in the redirect configuration, not the app code.
SPA failure mode: client-side navigation never re-initialises GA4
In a single-page app, the initial HTML document loads once. All navigation thereafter uses window.history.pushState — the URL changes but no HTTP request is made and no page reload occurs. 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 — on the initial page load. Every subsequent client-side navigation is invisible to GA4. The UTM parameters were delivered to the browser correctly; the problem is that no code sends them to GA4 on downstream navigations. The fix is a framework-specific routing hook that re-fires page_view events manually.

How to tell which failure mode you have in 60 seconds

Open browser DevTools, go to the Network tab, and reload your campaign URL (?utm_source=your_source&utm_medium=your_medium&utm_campaign=your_campaign). Watch for two things:

Check 1: does the URL in the final Network request still contain UTM parameters?
If the final document request (the 200 response) has a URL without ?utm_source=..., a redirect stripped them. This is the MPA failure mode — the problem is in the link or redirect chain, not the JavaScript. Run mlz check on your campaign URL to see exactly which hop dropped the query string.
Check 2: does your app use client-side routing?
Click a navigation link in your app. If the URL changes in the browser bar without the page reloading (no full document reload in the DevTools Network tab), you have an SPA using pushState. Check the GA4 DebugView — if no page_view event fires after the navigation, this is the SPA failure mode. The fix is a routing hook, not a redirect rule.
Check 3: run mlz preflight — it does both checks in one command
mlz preflight follows every redirect hop, reports whether UTM parameters survive to the final URL, and validates the landing page (SSL, OG tags, response time). If "redirects": "pass", the network layer is clean and the failure is in your app's routing code. If "redirects": "fail", the fix is in the redirect configuration. See below.

Using mlz preflight as the universal first diagnostic

MissingLinkz's mlz preflight runs the network-layer check for you. It constructs the UTM-tagged URL, follows every redirect in the chain, and reports whether UTM parameters survive.

mlz preflight --url "https://example.com/landing" --source "google" --medium "cpc" --campaign "q3-launch"

Result A — redirects pass (SPA failure mode):

mlz preflight — network layer clean: UTMs reached the browser
{
  "ready": true,
  "tracked_url": "https://example.com/landing?utm_source=google&utm_medium=cpc&utm_campaign=q3-launch",
  "checks": [
    { "check": "redirects", "status": "pass", "message": "No redirects. UTM parameters intact." },
    { "check": "ssl",       "status": "pass", "message": "URL uses HTTPS." }
  ],
  "recommendation": "Network layer clean. If GA4 loses attribution, the issue is in your SPA routing code."
}

A clean result confirms the SPA failure mode: UTM parameters reach the browser, but the framework's client-side router is not forwarding them to GA4 on subsequent navigations. See the SPA UTM hub article for the framework-specific fix.

Result B — redirects fail (MPA/network failure mode):

mlz preflight — redirect stripped UTMs at hop 1
{
  "ready": false,
  "checks": [
    { "check": "redirects", "status": "fail",
      "message": "Redirect at hop 1 dropped query string. UTM parameters lost." }
  ],
  "recommendation": "Fix the redirect rule to preserve the query string before debugging app code."
}

A "redirects": "fail" result identifies the network failure mode. No amount of SPA routing hook code will recover UTM parameters that were stripped before the browser received the HTML response. Fix the redirect first — see How to Check if a Redirect Strips UTM Parameters for the common patterns by platform.

Fixing MPA UTM attribution: preserve the query string in redirect rules

When mlz preflight shows a redirect that drops the query string, the fix is in the redirect configuration. Common patterns by platform:

Nginx: use $request_uri or $is_args$args
A common mistake in Nginx redirect rules is return 301 https://example.com$uri; — this copies the path but discards the query string. The correct form is return 301 https://example.com$request_uri; which preserves the full original URL including query parameters. Alternatively: return 301 https://example.com$uri$is_args$args;.
Cloudflare redirect rules: use Preserve query string option
In the Cloudflare dashboard under Rules > Redirect Rules, the redirect target URL has a Preserve query string checkbox. When this is unchecked, query strings including UTM parameters are silently dropped on every redirect. Enable it and redeploy the rule. Also check Cloudflare Workers scripts for any Response.redirect(url) calls that construct the URL without appending request.url's query string.
Netlify: use the splat redirect pattern
Netlify's _redirects file and netlify.toml redirect rules must use the :splat pattern or ?* suffix to pass the query string: /landing/:splat /landing/:splat 301. Without this, a redirect from /landing to /landing drops all query parameters.
Link shorteners and vanity domains: configure query string passthrough
Many link shorteners (Bitly, Rebrandly, custom short domains) have a query string passthrough setting. By default, some append UTM parameters to the destination URL; others strip them. Verify in the shortener's redirect configuration. If the shortener does not support query string passthrough, deliver the UTM-tagged URL directly rather than through a short link.

After making the redirect fix, re-run mlz preflight to confirm "redirects": "pass" before deploying to production.

Fixing SPA UTM attribution: choose the right framework guide

When mlz preflight confirms the network layer is clean, the fix is framework-specific. Each SPA routing library exposes a different hook for firing code on every client-side navigation:

React Router v6 (standalone)
Use useLocation from react-router-dom in a useEffect hook. The dependency array must be [location] (not just [location.pathname]) so the effect re-runs when the query string changes without a path change. See the full implementation in the React Router UTM guide.
Next.js App Router
Use usePathname and useSearchParams from next/navigation in a client component inside the root layout. The useSearchParams hook requires a Suspense boundary as of Next.js 14. See the full implementation in the Next.js UTM guide.
SvelteKit
Use afterNavigate from $app/navigation in the root +layout.svelte file. This lifecycle function runs after every navigation including the initial load, so there is no need for a separate initial-load handler. See the full implementation in the SvelteKit UTM guide.
Nuxt 3
Use router.afterEach() in a plugins/analytics.client.ts Nuxt plugin. The .client.ts suffix ensures the plugin only runs in the browser. See the full implementation in the Nuxt UTM guide.
Angular
Subscribe to Router.events filtered to NavigationEnd in the root AppComponent. Unsubscribe in ngOnDestroy to avoid memory leaks. See the full implementation in the Angular & Vue UTM guide.
Vue Router
Use router.afterEach() in the App.vue mounted lifecycle hook or in a dedicated composable. See the full implementation in the Angular & Vue UTM guide.
Gatsby, Remix, and GTM
Gatsby exposes exports.onRouteUpdate in gatsby-browser.js. Remix uses useLocation + useEffect in app/root.tsx. Google Tag Manager requires a History Change trigger wired to the GA4 configuration tag. See the SPA UTM hub article for links to all framework-specific guides.

Diagnostic decision tree: where to start

Step 1 — Run mlz preflight on your campaign URL
This is always the first step, regardless of whether you think you have an MPA or SPA. It takes 10 seconds and tells you definitively whether the problem is in the network layer or the app code.
Step 2a — If "redirects": "fail"
Fix the redirect rule to preserve the query string (see platform-specific patterns above). Re-run mlz preflight to confirm. No framework routing code is needed — GA4's automatic page_view event will pick up UTM parameters from the URL once the redirect is fixed.
Step 2b — If "redirects": "pass" and you have client-side routing
Select the framework-specific guide from the SPA cluster. Implement the routing hook that fires page_view on every navigation. Verify with GA4 DebugView: navigate to a second page in your app and confirm a page_view event fires with the correct page path.
Step 2c — If "redirects": "pass" and GA4 still shows (direct)/(none)
Check for secondary failure modes: Safari ITP may be restricting storage if your link passes through a tracker domain (see the Safari ITP guide), a cookie consent gate may be redirecting users through a consent page that drops the query string (see the cookie consent guide), or a JavaScript redirect on the landing page may be reconstructing the URL without the query string (see the JS redirect guide).

FAQ

What is the difference between MPA and SPA in terms of UTM tracking?
In an MPA (multi-page application), every navigation triggers a full HTTP request and a full HTML document reload. gtag.js re-initialises on every page, reads UTM parameters from the URL, and sends a page_view event automatically. In an SPA (single-page application), gtag.js initialises once on the first load and is never re-initialised. Client-side navigations use history.pushState, which changes the URL without triggering a page reload or re-executing any script tags. Automatic page_view events do not fire on SPA navigations.
Can an SPA also have the MPA failure mode (redirect stripping UTMs)?
Yes. An SPA can experience both failure modes simultaneously. The initial load of the SPA's HTML document goes through the network — including any redirect rules. If a redirect strips UTM parameters before the HTML response, the SPA receives no UTM parameters in the URL and has nothing to persist to sessionStorage or fire to GA4. The SPA routing fix is still needed, but it will operate on an empty state. Run mlz preflight first to rule out the network layer before adding routing hooks.
Does Next.js have both failure modes?
Yes. Next.js Pages Router with traditional server-side navigation behaves like an MPA for tracked pages — each page request is a full HTTP round-trip. Next.js App Router with <Link> components uses client-side navigation and is an SPA for the purposes of UTM tracking. Additionally, Next.js applications are often deployed behind Vercel's CDN, which may have redirect or rewrite rules that strip query strings. Run mlz preflight to determine which layer is causing the issue. See the Next.js UTM guide for the routing hook implementation.
Why does mlz preflight catch UTM stripping when browser DevTools doesn't always show it clearly?
Browser DevTools shows the redirect chain in the Network tab, but reading the query string across multiple redirect hops requires expanding each request manually. mlz preflight follows the entire chain automatically, compares the query string at the start and end of the chain, and reports whether UTM parameters survived — including partial stripping where some parameters survive and others do not. It also checks for UTM parameter encoding issues and case-sensitivity problems that can cause GA4 to misattribute sessions.

Diagnose MPA vs SPA UTM failure in one command

Run mlz preflight before you touch any redirect rules or framework routing code. The output tells you which layer is broken and which guide to read next.

npm install -g missinglinkz

Then: mlz preflight --url "https://your-landing-page.com" --source google --medium cpc --campaign q3

Recommended posts