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.
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"
{
"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:
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:
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
reloadDocumentto a<Link>forces a full browser navigation, which causes a page reload.gtag.jsfires its automaticpage_viewon the new page. YourPageViewTrackercomponent also mounts on the new page and fires. This is not a double-fire problem — each full reload is a separate GA4 session context. UsereloadDocumentdeliberately 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 meansgtag.jsfires automatically on the resulting page — noPageViewTrackerinvolvement 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
useFetcherhook submits data in the background without navigating. These don't changelocation, souseEffectdoesn't fire and no spuriouspage_viewis sent. Fetcher-triggered mutations are correctly invisible toPageViewTracker.
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:
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"
{
"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'shistory.pushStatewithout a page reload.gtag.jsfirespage_viewonce when it loads and has no listener for React Router's navigation events. Add aPageViewTrackercomponent usinguseLocationanduseEffectinapp/root.tsxto firepage_viewon every route change. - Why use [location] as the dependency rather than [location.pathname]?
- Using
[location.pathname]as theuseEffectdependency meansuseEffectonly runs when the path segment changes — not when the query string changes. If a user navigates from/landing?utm_source=linkedinto/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.jsfires its automaticpage_viewwhen thegtag('config', ...)line executes, andPageViewTracker'suseEffectalso fires after the initial mount. To prevent duplication, addsend_page_view: falseto thegtag('config', 'G-XXXXXXXXXX', { send_page_view: false })call and letPageViewTrackerbe the sole source ofpage_viewevents. Verify in GA4 DebugView to confirm exactly one event fires per navigation. - Does this work in Remix v2 and Remix v1?
- Yes.
useLocationfrom@remix-run/reactis available in both Remix v1 and v2. The component structure changed somewhat between versions (v2 introducesLayoutexport), butPageViewTrackeras a standalone component dropped into either theAppfunction or theLayoutexport works in both. Remix v2'sLayoutexport 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+useEffectcomponent at the top of your component tree. In Remix, the equivalent is placing it inapp/root.tsxinside theLayoutor rootAppfunction. The API is identical because Remix is built on React Router — Remix re-exportsuseLocationfrom 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.
1,000 links/month free. No credit card.
Your API key
Save this now — it won't be shown again.
npm install -g missinglinkz
Then: mlz check "https://your-remix-app.com/landing?utm_source=linkedin&utm_medium=paid-social&utm_campaign=q3"