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

Remix is built on React Router v6, and its <Link> component performs client-side navigation using window.history.pushState — the same mechanism that breaks GA4 UTM attribution across React Router, Gatsby, Vue, Angular, and SvelteKit applications. When a visitor arrives at /landing?utm_source=google&utm_medium=cpc&utm_campaign=q3, Remix's server-side rendering delivers the initial HTML, gtag.js loads, and a page_view event fires correctly. But when they click a <Link to="/pricing">, Remix's router intercepts the click, fetches the route data via a loader if needed, and re-renders the Outlet content in the browser — all without reloading the page. gtag.js never fires again. Conversions on downstream pages are attributed to (direct)/(none) in GA4. 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 debug Remix's routing code. Once the network layer is confirmed clean, the fix is a PageViewTracker component using useLocation from @remix-run/react and useEffect from React, placed inside the root layout in app/root.tsx.

Large dark terminal on the left shows the full app/root.tsx fix: a PageViewTracker function component using useLocation and useEffect with [location] as the dependency array, firing page_view on every route change. Right side shows before/after panels — without the fix GA4 sees direct/none, with the fix GA4 captures utm_source correctly — plus a smaller mlz check terminal confirming the network layer is clean.

Why Remix breaks GA4 UTM attribution on client-side navigation

Remix is a full-stack React framework that uses server-side rendering for every route by default. On the first page request, the server renders the full HTML response including the document element (head, body), loader data, and serialized state. The browser displays this HTML immediately. gtag.js loads from the script tag, fires a page_view event with the correct UTM parameters, and GA4 attributes the session.

The problem begins with the first internal navigation. Remix uses React Router v6 as its underlying router — the same router that powers standalone React Router apps. When a user clicks a <Link> component from @remix-run/react, the router calls window.history.pushState to update the URL and triggers a client-side fetch of the new route's loader data if needed. The page content updates without a browser reload, and the URL bar changes. gtag.js set up no listener for these pushState calls and fires nothing.

Remix differs from pure SPAs in that some navigations can trigger full server round-trips: forms with method="post" or links with the reloadDocument prop force a full page reload. These navigations work fine with GA4. But standard <Link> components — the majority of navigation in any Remix app — are client-side and invisible to gtag.js.

The attribution loss is especially damaging in multi-step funnels. A user clicks a paid LinkedIn ad, lands on /solutions?utm_source=linkedin&utm_medium=paid-social&utm_campaign=q3, reads the page, clicks <Link to="/pricing">, and converts on /checkout. GA4 sees: one page_view on /solutions with the correct attribution, then a conversion on /checkout with no session data. The campaign shows zero ROI.

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

Before adding any React code, confirm that UTM parameters reach the Remix app intact. Remix applications commonly deploy to Fly.io, Vercel, Cloudflare Workers, or Railway — all of which support redirect rules and edge middleware that can rewrite URLs and strip query parameters before the Remix server ever sees the request.

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

mlz check "https://your-remix-app.com/landing?utm_source=linkedin&utm_medium=paid-social&utm_campaign=q3"
mlz check — confirm UTMs reach the browser before debugging Remix routing
{
  "url": "https://your-remix-app.com/landing?utm_source=linkedin&utm_medium=paid-social&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: 241ms." }
  ],
  "valid": true
}

All checks passing confirms UTM parameters reach the browser. The attribution problem is in Remix's client-side routing layer. If "redirects": "fail" shows 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 for common patterns when deploying to Cloudflare's edge network.

The fix: useLocation + useEffect in app/root.tsx

Remix's root route (app/root.tsx or app/root.jsx) is the right place for GA4 tracking because it wraps every other route in the application — the <Outlet /> component in the root renders all child routes. Create a PageViewTracker component that fires page_view on every location change:

app/root.tsx — PageViewTracker component
import { useLocation } from '@remix-run/react'
import { useEffect } from 'react'

function PageViewTracker() {
  const location = useLocation()

  useEffect(() => {
    if (typeof window.gtag !== 'function') return
    window.gtag('event', 'page_view', {
      page_path: location.pathname + location.search
    })
  }, [location])
  // Use [location], not [location.pathname] —
  // location.search can change without the pathname changing

  return null
}

Place <PageViewTracker /> inside your root Layout function, after the script tag that loads gtag.js:

app/root.tsx — using PageViewTracker in the root Layout
export function Layout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <Meta />
        <Links />
        {/* gtag.js loads here via your Scripts component */}
      </head>
      <body>
        {/* PageViewTracker fires after every navigation */}
        <PageViewTracker />
        {children}
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  )
}

The [location] dependency array means useEffect runs after every render where location changed — which is every route transition. Using [location] rather than [location.pathname] is important: Remix supports query parameter changes on the same route (e.g., different campaign UTMs on the same landing page URL) and these should fire separate page_view events.

location.search contains the full query string including UTM parameters. On the initial landing page, page_path sent to GA4 will be /landing?utm_source=linkedin&utm_medium=paid-social&utm_campaign=q3 — the complete path with UTMs. GA4 reads these from page_path and attributes the session. On subsequent <Link> navigations to /pricing, location.search is empty and GA4 carries the session attribution forward.

How Remix's loaders affect UTM tracking behaviour

Remix's data loading model means route transitions often trigger server-side loader calls. When a user clicks <Link to="/pricing">, Remix fetches /pricing?_data=routes%2Fpricing in the background to get the route's loader data, then updates the React tree. This server round-trip is NOT the same as a full page reload — the browser URL changes via pushState, no new HTML document loads, and gtag.js doesn't fire.

The PageViewTracker component handles this correctly because useLocation returns the new location after Remix's router completes the navigation — after the loader data arrives and the new route component mounts. There is no race condition between the loader fetch and the page_view event.

Links with reloadDocument prop
Adding reloadDocument to a <Link> forces a full browser navigation, which causes a page reload. gtag.js fires its automatic page_view on the new page. Your PageViewTracker component also mounts on the new page and fires. This is not a double-fire problem — each full reload is a separate GA4 session context. Use reloadDocument deliberately and only for links that genuinely need a full reload.
Form submissions with method="post"
Remix's <Form method="post"> submissions by default perform a full page reload after the action completes (the POST-redirect-GET pattern). This means gtag.js fires automatically on the resulting page — no PageViewTracker involvement needed. Your component will also mount on the new page and fire, but again this is a new page load context, not a double-fire.
Fetchers (useFetcher)
Remix's useFetcher hook submits data in the background without navigating. These don't change location, so useEffect doesn't fire and no spurious page_view is sent. Fetcher-triggered mutations are correctly invisible to PageViewTracker.

Persisting UTMs across navigations for custom analytics

If you're sending analytics events to a custom backend, a data warehouse, or a customer data platform alongside GA4, extend PageViewTracker to capture UTMs from the initial landing URL into sessionStorage:

app/root.tsx — extend PageViewTracker to persist UTMs in sessionStorage
const UTM_KEYS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content']

function PageViewTracker() {
  const location = useLocation()

  useEffect(() => {
    // Fire page_view on every navigation
    if (typeof window.gtag === 'function') {
      window.gtag('event', 'page_view', {
        page_path: location.pathname + location.search
      })
    }

    // Persist landing UTMs for the full session
    const params = new URLSearchParams(location.search)
    UTM_KEYS.forEach((key) => {
      const val = params.get(key)
      if (val && !sessionStorage.getItem(key)) {
        sessionStorage.setItem(key, val)
      }
    })
  }, [location])

  return null
}

The !sessionStorage.getItem(key) guard prevents overwriting the original landing UTMs if a user navigates to a URL that contains different UTM parameters mid-session. Read the stored values with sessionStorage.getItem('utm_source') from anywhere in your application. Remix's server-side rendering means sessionStorage is only accessible in the browser — the useEffect context ensures this access always happens client-side.

Pre-launch validation with mlz preflight

After adding PageViewTracker to app/root.tsx, validate the full campaign URL before launch. MissingLinkz's mlz preflight command builds the tracked URL, follows the redirect chain, checks SSL, Open Graph tags, Twitter Card metadata, and UTM parameter formatting in a single command:

mlz preflight --url "https://your-remix-app.com/landing" --source "linkedin" --medium "paid-social" --campaign "q3-launch"
mlz preflight — pre-launch Remix campaign link validation
{
  "ready": true,
  "tracked_url": "https://your-remix-app.com/landing?utm_source=linkedin&utm_medium=paid-social&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."
}

Add mlz preflight to your Remix deploy checklist. Remix applications on Cloudflare Workers or Fly.io can have edge-level routing rules that silently rewrite or redirect URLs — catch these before activating a campaign rather than diagnosing missing GA4 data after the fact.

Frequently asked questions

Does Remix automatically track route changes in GA4?
No. Remix's <Link> component performs client-side navigation via React Router v6's history.pushState without a page reload. gtag.js fires page_view once when it loads and has no listener for React Router's navigation events. Add a PageViewTracker component using useLocation and useEffect in app/root.tsx to fire page_view on every route change.
Why use [location] as the dependency rather than [location.pathname]?
Using [location.pathname] as the useEffect dependency means useEffect only runs when the path segment changes — not when the query string changes. If a user navigates from /landing?utm_source=linkedin to /landing?utm_source=google (same pathname, different UTMs), the event won't fire. Using [location] (the full location object) fires the effect on any navigation, including query string changes on the same route.
Will this cause a double page_view on the initial load?
Possibly — gtag.js fires its automatic page_view when the gtag('config', ...) line executes, and PageViewTracker's useEffect also fires after the initial mount. To prevent duplication, add send_page_view: false to the gtag('config', 'G-XXXXXXXXXX', { send_page_view: false }) call and let PageViewTracker be the sole source of page_view events. Verify in GA4 DebugView to confirm exactly one event fires per navigation.
Does this work in Remix v2 and Remix v1?
Yes. useLocation from @remix-run/react is available in both Remix v1 and v2. The component structure changed somewhat between versions (v2 introduces Layout export), but PageViewTracker as a standalone component dropped into either the App function or the Layout export works in both. Remix v2's Layout export is the better placement — it wraps everything including error boundaries.
How is this different from fixing the same issue in plain React Router?
It's nearly identical. In a standalone React Router v6 app, you place the same useLocation + useEffect component at the top of your component tree. In Remix, the equivalent is placing it in app/root.tsx inside the Layout or root App function. The API is identical because Remix is built on React Router — Remix re-exports useLocation from its own package but it resolves to React Router's implementation.

Validate your Remix campaign links before debugging the router

Run mlz check first to confirm UTM parameters survive the network layer, then add PageViewTracker using useLocation + useEffect in app/root.tsx to capture every client-side navigation.

npm install -g missinglinkz

Then: mlz check "https://your-remix-app.com/landing?utm_source=linkedin&utm_medium=paid-social&utm_campaign=q3"

Recommended posts