Adding Google Analytics 4 to a Next.js app sounds trivial — paste a script, done. The catch is that Next.js's App Router does client-side navigation, so the default GA4 page-view tracking only fires on the initial load. You'll think it's working, then notice every page in GA4 looks like "/ — 100% bounce." Here's the version that actually tracks correctly in 2026, plus consent mode for GDPR/DPDP compliance and a clean pattern for custom events.
Stack assumed
Next.js 16 (App Router) + TypeScript. The same pattern works in plain React with minor changes (swap the navigation hook).
1. Get your GA4 Measurement ID
- 1Go to
analytics.google.com, sign in - 2Admin → Property → Create a new GA4 property if you don't have one
- 3Inside the property: Admin → Data Streams → Add Stream → Web → enter your URL
- 4Copy the Measurement ID (looks like
G-XXXXXXXXXX)
NEXT_PUBLIC_GA_MEASUREMENT_ID=G-XXXXXXXXXX2. The flow you're about to wire up
First load Client navigation Custom event
────────── ─────────────────── ────────────
<Script> tag usePathname() / useSearchParams gtag('event', ...)
loads gtag.js fires inside an effect any user action
in <head> (click, submit)
│ │ │
▼ ▼ ▼
Initial page_view page_view on route custom event
automatically change (manual) in GA4 reports3. Add the GA4 script with next/script
"use client";
import Script from "next/script";
import { usePathname, useSearchParams } from "next/navigation";
import { useEffect, Suspense } from "react";
const GA_ID = process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID;
declare global {
interface Window {
gtag: (
command: "config" | "event" | "consent" | "set",
target: string,
params?: Record<string, unknown>
) => void;
dataLayer: unknown[];
}
}
function PageviewTracker() {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
if (!GA_ID || typeof window.gtag !== "function") return;
const url = pathname + (searchParams?.toString()
? `?${searchParams.toString()}` : "");
window.gtag("event", "page_view", {
page_path: url,
page_location: window.location.href,
page_title: document.title,
});
}, [pathname, searchParams]);
return null;
}
export default function Analytics() {
if (!GA_ID) return null;
return (
<>
<Script
src={`https://www.googletagmanager.com/gtag/js?id=${GA_ID}`}
strategy="afterInteractive"
/>
<Script id="ga-init" strategy="afterInteractive">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
window.gtag = gtag;
gtag('js', new Date());
// Default consent: deny analytics until the user opts in.
// If you don't need consent gating, replace 'denied' with 'granted'
// on analytics_storage.
gtag('consent', 'default', {
analytics_storage: 'denied',
ad_storage: 'denied',
functionality_storage: 'granted',
security_storage: 'granted',
});
gtag('config', '${GA_ID}', {
send_page_view: false, // We fire page_views manually below
});
`}
</Script>
<Suspense fallback={null}>
<PageviewTracker />
</Suspense>
</>
);
}Why send_page_view: false?
If you leave it on, GA4 fires its automatic page_view on initial load AND your manual one — every first page-view gets counted twice. Turn off the automatic one and let your effect own all page_views.
4. Mount it in the root layout
import Analytics from "@/components/Analytics";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
{children}
<Analytics />
</body>
</html>
);
}5. Track custom events
// Typed helper for custom events
type EventName =
| "sign_up"
| "purchase"
| "contact_submit"
| "cta_click";
export function track(event: EventName, params?: Record<string, unknown>) {
if (typeof window === "undefined") return;
if (typeof window.gtag !== "function") return;
window.gtag("event", event, params ?? {});
}import { track } from "@/lib/analytics";
<button
onClick={() => {
track("cta_click", { location: "hero", label: "Book a call" });
router.push("/contact");
}}
>
Book a call
</button>6. Consent — the part most blogs skip
EU (GDPR) and India (DPDP) both require explicit consent before non-essential cookies. The default-deny in step 3 puts you on the right side of that line. When the user accepts, call gtag('consent', 'update', …).
"use client";
export function grantAnalyticsConsent() {
if (typeof window === "undefined") return;
window.gtag?.("consent", "update", {
analytics_storage: "granted",
});
localStorage.setItem("consent-analytics", "granted");
}
export function denyAnalyticsConsent() {
window.gtag?.("consent", "update", {
analytics_storage: "denied",
});
localStorage.setItem("consent-analytics", "denied");
}
// On app boot, restore prior choice:
export function restoreConsent() {
const prior = localStorage.getItem("consent-analytics");
if (prior === "granted") grantAnalyticsConsent();
}7. Verify it's actually working
- 1Install the Google Analytics Debugger Chrome extension or open DevTools → Network and filter for
collect - 2Load your site. You should see a
collectrequest withen=page_view - 3Navigate to another page (no full reload). You should see another
collectrequest - 4In GA4: Reports → Realtime — your session should appear within a few seconds
- 5In GA4: Admin → DebugView (enable Debug mode with the GA Debugger extension) — shows events as they fire, with the params
Ad-blockers will block GA4
If you're testing with an ad-blocker on, you'll see zero events. Use an Incognito window with the extension disabled, or a different browser. Don't waste an hour wondering why "nothing is sending."
8. Plain React (Vite, CRA) variant
Same approach, different navigation hook. With react-router-dom:
import { useLocation } from "react-router-dom";
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,
page_location: window.location.href,
page_title: document.title,
});
}, [location]);
return null;
}9. Gotchas we keep seeing
- Double-counted page views — caused by leaving GA4's automatic
send_page_viewon while also firing it manually. Pick one - Missing events on client navigation — caused by not having a
usePathname/useSearchParamseffect. The script alone isn't enough in App Router - Missing
?utm_*attribution — your manual page_view must includepage_locationwith the full URL (search params included), or GA4 can't attribute the campaign - Wrong env var prefix — must be
NEXT_PUBLIC_*to be exposed to the browser - Calling
gtagbefore it's defined — always guard withtypeof window.gtag === "function";next/scriptis async, so it's not loaded immediately - Forgetting consent in EU / India — default-deny is the safe pattern; flip to granted only after explicit user opt-in
10. When to skip GA4 and use something else
- Privacy-conscious audience → Plausible, Fathom, Umami (no cookies, no consent banner needed)
- Product analytics (funnels, retention) → PostHog or Mixpanel; GA4 is web-traffic-shaped, not product-shaped
- Both → run them in parallel. They don't conflict
TL;DR
- Load
gtag.jswithnext/scriptin the root layout - Turn OFF GA4's automatic
send_page_viewand firepage_viewmanually on route changes - Use
usePathname+useSearchParamsin a client component for App Router - Default consent to denied; flip to granted only after explicit opt-in (GDPR / DPDP)
- Wrap custom events in a typed helper so you can grep usages later
- Test in Realtime / DebugView; ad-blockers will block events — don't debug with one on
Want a hand wiring GA4 plus event-driven conversion tracking, server-side tagging, or migration off Universal Analytics on a larger codebase? Drop us a paragraph about your stack and we'll send back a candid plan.