UTM Parameters Not Tracked in SvelteKit: How to Fix GA4 Attribution

SvelteKit intercepts qualifying anchor tag clicks and converts them to client-side navigations using window.history.pushState — the same technique that breaks GA4 UTM attribution in React, Vue, Angular, and Nuxt applications. When a visitor arrives at /landing?utm_source=google&utm_medium=cpc&utm_campaign=q3, the page loads normally and gtag.js fires a page_view event with the correct UTM parameters. But when they click an internal link or your code calls goto('/pricing'), SvelteKit navigates client-side without a page reload. gtag.js never fires again. If the conversion happens on /pricing, GA4 attributes it to (direct)/(none) and your campaign gets no credit. MissingLinkz's mlz check command is the right first step: it follows the full redirect chain and confirms whether UTM parameters reach the final URL — ruling out network-level stripping before you spend time debugging SvelteKit routing code. Once the network layer is confirmed clean, the fix is afterNavigate from $app/navigation in your root +layout.svelte, which fires page_view manually on every client-side navigation.

Left side shows a UTM parameter grid comparing the $page.url.searchParams values before and after a SvelteKit goto() navigation — utm_source, utm_medium, and utm_campaign return their values on initial load but null after client-side routing. Right side shows the afterNavigate fix in +layout.svelte firing page_view on each navigation, and an mlz check terminal confirming the network layer is clean.

Why SvelteKit breaks GA4 UTM attribution on client-side navigation

SvelteKit's router automatically handles anchor tag navigation. When a user clicks an <a href="/pricing"> link inside a SvelteKit application, the router intercepts the click, fetches the new page data (or uses a preloaded version), and swaps the rendered content — all without a browser-level page reload. The same thing happens when application code calls goto('/pricing') from $app/navigation.

The UA-layer consequence is identical to other SPAs: gtag.js fires a page_view event exactly once — when the script loads on the initial page request. SvelteKit's client-side router doesn't reload scripts; it updates window.history and re-renders Svelte components. gtag.js has no hook into SvelteKit's navigation lifecycle, so it never fires another page_view.

The attribution damage compounds through multi-page conversion flows. A user who clicks a paid search ad lands on /landing?utm_source=google&utm_medium=cpc&utm_campaign=q3 — UTMs captured. They navigate to /pricing — no page_view. They sign up on /signup — conversion attributed to (direct)/(none). Your Google Ads campaign appears to have driven zero conversions.

SvelteKit also supports SSR mode, where the initial HTML is rendered on the server. The first page load fires page_view correctly after client-side hydration. But all subsequent navigations via SvelteKit's router are client-side regardless of SSR setting.

Step zero: confirm the network layer isn't stripping UTMs

Before writing any SvelteKit code, confirm that UTM parameters reach the browser intact. SvelteKit applications are commonly deployed on Vercel, Cloudflare Pages, or Netlify — all of which support edge functions, redirect rules, and URL rewriting that can strip query parameters at the network layer before the page ever loads.

MissingLinkz's mlz check command follows every redirect hop and reports whether UTM parameters survive to the final URL:

mlz check "https://your-sveltekit-app.com/landing?utm_source=google&utm_medium=cpc&utm_campaign=q3"
mlz check — confirm UTMs reach the browser before debugging SvelteKit routing
{
  "url": "https://your-sveltekit-app.com/landing?utm_source=google&utm_medium=cpc&utm_campaign=q3",
  "valid": true,
  "checks": [
    { "check": "url_format",   "status": "pass", "message": "URL format is valid." },
    { "check": "ssl",          "status": "pass", "message": "URL uses HTTPS." },
    { "check": "resolution",   "status": "pass", "message": "Destination responded with 200." },
    { "check": "redirects",    "status": "pass", "message": "No redirects detected." },
    { "check": "response_time", "status": "pass", "message": "Response time: 193ms." }
  ],
  "valid": true
}

All checks passing confirms UTM parameters reach the browser correctly. The attribution problem is in the SvelteKit routing layer. If you see "redirects": "fail" with a dropped query string, fix the network layer first — see How to Check if a Redirect Strips UTM Parameters and UTM Parameters Stripped by Cloudflare Workers or Edge Middleware.

The fix: afterNavigate in +layout.svelte

SvelteKit provides afterNavigate from $app/navigation — a lifecycle callback that runs in the browser after every navigation completes, including the initial page load transition from SSR to client-side rendering. This is the correct hook for firing page_view events on every route change.

Place the fix in your root src/routes/+layout.svelte — the layout component that wraps every route. Running it at the root level ensures it fires on every navigation regardless of which route the user is on:

src/routes/+layout.svelte — afterNavigate fix
<script>
  import { afterNavigate } from '$app/navigation'

  afterNavigate(({ to }) => {
    if (to && typeof window.gtag === 'function') {
      window.gtag('event', 'page_view', {
        page_path: to.url.pathname + to.url.search
      })
    }
  })
</script>

<!-- Rest of your layout template -->
<slot />

to.url.pathname gives the path (e.g., /landing) and to.url.search gives the query string (e.g., ?utm_source=google&utm_medium=cpc&utm_campaign=q3). On the initial landing page load, to.url.search contains the full UTM string. GA4 reads these from the page_path parameter and attributes the session accordingly. On subsequent navigations to /pricing, to.url.search will be empty — but GA4's session model carries the UTM attribution forward automatically.

You should also disable GA4's automatic page_view to avoid a duplicate event on the initial render. In your app.html or wherever you initialise gtag.js, add send_page_view: false:

gtag('config', 'G-XXXXXXXXXX', { send_page_view: false });

With send_page_view: false, every page_view GA4 receives comes from your afterNavigate hook — one per navigation, starting with the initial load. Verify in GA4 DebugView that exactly one event fires per page with the correct page_path.

Reading UTM parameters from the $page store

SvelteKit's $page store from $app/stores exposes the current URL as a standard URL object. You can read UTM parameters reactively from $page.url.searchParams. This is useful if you need to display campaign context in your UI or pass UTMs to a backend API call:

Reading UTMs from the $page store
<script>
  import { page } from '$app/stores'

  // Reactive — updates whenever the route changes
  $: utm_source   = $page.url.searchParams.get('utm_source')
  $: utm_medium   = $page.url.searchParams.get('utm_medium')
  $: utm_campaign = $page.url.searchParams.get('utm_campaign')
</script>

Note that $page.url.searchParams.get('utm_source') returns null after navigating to a route that doesn't have UTM parameters in its URL — which is the normal case for internal navigations like /pricing or /signup. This is why you capture and store UTMs on the first load rather than reading them reactively from $page for analytics purposes.

Preserving UTMs across navigations for custom analytics pipelines

GA4's session model handles UTM attribution automatically. For standard GA4 reporting, you don't need to forward UTMs on every route change. However, if you're sending events to a custom backend, a CDP, or a data warehouse alongside GA4, you need the original UTMs available throughout the session.

The reliable pattern is to capture UTMs on first load in a SvelteKit layout or root +page.svelte and write them to sessionStorage:

src/routes/+layout.svelte — capture UTMs on first load
<script>
  import { onMount } from 'svelte'
  import { afterNavigate } from '$app/navigation'
  import { page } from '$app/stores'

  const UTM_KEYS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content']

  onMount(() => {
    // Capture UTMs from the initial landing URL
    UTM_KEYS.forEach((key) => {
      const val = $page.url.searchParams.get(key)
      if (val && !sessionStorage.getItem(key)) {
        sessionStorage.setItem(key, val)
      }
    })
  })

  // Fire page_view on every navigation
  afterNavigate(({ to }) => {
    if (to && typeof window.gtag === 'function') {
      window.gtag('event', 'page_view', {
        page_path: to.url.pathname + to.url.search
      })
    }
  })
</script>

onMount runs only in the browser, after SSR hydration — so sessionStorage is always available inside it. The !sessionStorage.getItem(key) guard prevents subsequent navigations with UTMs in their URLs from overwriting the original session attribution. Call a helper function that reads from sessionStorage whenever you need the original UTMs later in the session.

Opting specific links out of SvelteKit's client-side router

Sometimes you want a full page reload — for example, if a campaign landing page is on a completely separate subdomain or if you need gtag.js to fire its automatic page_view on the target page. SvelteKit provides two ways to force full reloads:

data-sveltekit-reload attribute
Adding data-sveltekit-reload to an anchor tag opts that link out of SvelteKit's router entirely — clicking it triggers a full browser navigation. Useful for links that point to pages that are not SvelteKit routes (e.g. a third-party checkout page).
rel="external" attribute
SvelteKit treats links with rel="external" as external navigations — they follow the standard browser navigation flow with a full page reload. The distinction from data-sveltekit-reload is that this is semantically meaningful to other tools and screen readers.

For internal routes in your SvelteKit app, use the afterNavigate fix rather than opting links out of the router — the SPA routing behaviour is intentional and performant. The analytics gap is the problem to solve, not SvelteKit's navigation model.

Pre-launch validation with mlz preflight

After adding the afterNavigate hook, validate the full campaign URL before launch. mlz preflight builds the tracked URL, checks the redirect chain, SSL, Open Graph tags, Twitter Card metadata, and UTM parameter structure in one command:

mlz preflight --url "https://your-sveltekit-app.com/landing" --source "google" --medium "cpc" --campaign "q3-launch"
mlz preflight — pre-launch SvelteKit campaign link validation
{
  "ready": true,
  "tracked_url": "https://your-sveltekit-app.com/landing?utm_source=google&utm_medium=cpc&utm_campaign=q3-launch",
  "checks": [
    { "check": "ssl",          "status": "pass", "message": "URL uses HTTPS." },
    { "check": "resolution",   "status": "pass", "message": "Destination responded with 200." },
    { "check": "redirects",    "status": "pass", "message": "No redirects detected." },
    { "check": "og_tags",     "status": "pass", "message": "All essential Open Graph tags present." },
    { "check": "twitter_card", "status": "pass", "message": "Twitter Card tags configured." }
  ],
  "summary": { "total": 12, "passed": 12, "warnings": 0, "failed": 0 },
  "recommendation": "All checks passed. Campaign link is ready to publish."
}

A clean preflight result confirms the network infrastructure is solid. Add mlz preflight to your SvelteKit deploy checklist — particularly when deploying to new environments or adding edge functions, which can introduce redirect rules that silently strip query parameters.

Frequently asked questions

Does SvelteKit automatically track route changes in GA4?
No. SvelteKit's router intercepts internal anchor clicks and goto() calls, navigating via window.history.pushState without a page reload. gtag.js fires page_view once at script load time and has no listener for SvelteKit's navigation events. Use afterNavigate from $app/navigation in your root +layout.svelte to fire page_view manually on every route change.
Why does the fix go in +layout.svelte instead of +page.svelte?
The root +layout.svelte wraps every route in your application. Placing afterNavigate there ensures it fires on navigation to any route, not just the specific page file it's defined in. If you placed it in +page.svelte files, you'd need to add it to every page separately — and you'd miss navigations that go from one page without the hook to another page.
Does afterNavigate fire on the initial page load?
Yes. afterNavigate fires after the initial client-side navigation from SSR to the client — the hydration step. This means the first call happens with to pointing to the current page, including any UTM parameters in to.url.search. This replaces the automatic page_view that gtag('config', 'G-XXXXXXXXXX') would have fired, which is why you should set send_page_view: false to avoid duplicates.
How is SvelteKit's routing different from React Router or Vue Router?
The root cause is identical — all three use history.pushState for client-side navigation, and gtag.js doesn't detect any of them automatically. The API differs: React Router uses useLocation() + useEffect, Vue Router uses router.afterEach(), and SvelteKit uses the afterNavigate lifecycle hook from $app/navigation. The page_path value constructed from to.url.pathname + to.url.search is the same concept across all three.
What if my SvelteKit app uses adapter-static with pre-rendered routes?
Static pre-rendering doesn't change the client-side routing behaviour. Pre-rendered routes still load via SvelteKit's client-side router after the initial page load. The afterNavigate fix applies equally to apps using adapter-static, adapter-cloudflare-pages, adapter-vercel, or any other adapter. The adapter determines where the HTML files are served from, not how navigation works in the browser.

Validate your SvelteKit campaign links before debugging the router

Run mlz check first to confirm UTM parameters survive the network layer, then add afterNavigate in +layout.svelte to capture every client-side navigation.

npm install -g missinglinkz

Then: mlz check "https://your-sveltekit-app.com/landing?utm_source=google&utm_medium=cpc&utm_campaign=q3"

Recommended posts