If you run paid campaigns or email newsletters driving traffic to a modern Next.js single-page application (SPA), your conversion data in Google Analytics 4 might be silently misattributing traffic to (direct) / (none).
A user clicks an ad with complete campaign parameters (utm_source=meta&utm_medium=paid_social&utm_campaign=launch_q3), lands on your site, browses a few pages via client-side routing, and suddenly their purchase event is credited to a direct visit.
Here is why client-side routing causes UTM data drops in GA4, and how to preserve campaign attribution reliably in Next.js.
1. The Single-Page Routing Trap
In traditional multi-page websites, every navigation triggers a full browser reload (window.location), refreshing URL state across page boundaries.
Modern frameworks like Next.js utilize client-side navigation (next/link or useRouter) using the HTML5 History API:
- A visitor arrives at
/landing-page?utm_source=newsletter&utm_medium=email. - GA4's config snippet reads the query parameters from the initial document load and logs session attribution.
- The user clicks an internal link (
<Link href="/pricing">). - Next.js pushes the route without reloading the browser.
- The URL updates to
/pricing, stripping the query string fromwindow.location.search. - Subsequent virtual
page_viewevents fire with a clean path devoid of campaign parameters.
If your tracking setup re-instantiates or reads the location object directly on sub-route transitions, your session continuity risks attribution degradation or event fragmentation.
2. Three Common Mistakes in Next.js SPA Tracking
-
Stripping query params too early: Developers often clean up URLs for aesthetics using
router.replace(pathname)before tracking scripts initialize or server-side logs capture parameter payloads. -
Malformed parameter formats: Relying on unencoded parameters or trailing slashes placed after the query string (
?utm_source=twitter/) invalidates GA4 auto-parsing. -
Ignoring manual session persistence: Failing to store initial acquisition parameters into
sessionStorageor cookies for multi-step signup flows that traverse subdomains or isolated client components.
3. Preserving Campaign UTMs in Next.js App Router
Use a client component mounted in your root layout to catch and persist first-touch attribution parameters in sessionStorage on initial arrival:
// components/AttributionTracker.tsx
'use client';
import { useEffect } from 'react';
import { useSearchParams } from 'next/navigation';
const TRACKED_PARAMS = [
'utm_source',
'utm_medium',
'utm_campaign',
'utm_term',
'utm_content',
];
export function AttributionTracker() {
const searchParams = useSearchParams();
useEffect(() => {
// Only capture on initial entry if parameters exist
const utmData: Record<string, string> = {};
let hasUtm = false;
TRACKED_PARAMS.forEach((param) => {
const value = searchParams.get(param);
if (value) {
utmData[param] = value;
hasUtm = true;
}
});
if (hasUtm) {
sessionStorage.setItem('first_touch_utm', JSON.stringify(utmData));
}
}, [searchParams]);
return null;
}
Mount <AttributionTracker /> inside a <Suspense> wrapper in app/layout.tsx. If a user completes an unrouted checkout or external conversion, you can pull attribution directly from sessionStorage.getItem('first_touch_utm') to accompany server-side events.
4. Constructing Sanitized Tracking URLs
Parameter capitalization and unescaped spaces trigger fragmented rows in GA4 reports (Email vs email will register as two distinct channels).
Before deploying links across marketing channels:
- Campaign UTM Builder: Generate standard-compliant, lowercase, and sanitized campaign links with the free Campaign UTM Builder.
-
Programmatic Sanitization: If generating marketing tracking links inside server-side pipelines or automated scripts, install omniseo-core on npm and call
buildUtmUrl().
Top comments (0)