Tutorial

How to add Google Analytics 4 (GA4) to a Next.js or React site (2026)

Wiring GA4 into a Next.js app is five minutes of code and an hour of "why aren't my page views firing on client navigations?" Here's the version that just works in 2026, including consent mode and custom events.

May 15, 202610 min readCruxBit Team

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

  1. 1Go to analytics.google.com, sign in
  2. 2Admin → Property → Create a new GA4 property if you don't have one
  3. 3Inside the property: Admin → Data Streams → Add Stream → Web → enter your URL
  4. 4Copy the Measurement ID (looks like G-XXXXXXXXXX)
.env.local
bash
NEXT_PUBLIC_GA_MEASUREMENT_ID=G-XXXXXXXXXX

2. The flow you're about to wire up

GA4 in Next.js App Router
   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 reports

3. Add the GA4 script with next/script

src/components/Analytics.tsx
typescript
"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

src/app/layout.tsx
typescript
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

src/lib/analytics.ts
typescript
// 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 ?? {});
}
Use it inline
typescript
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', …).

src/components/CookieBanner.tsx (excerpt)
typescript
"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

  1. 1Install the Google Analytics Debugger Chrome extension or open DevTools → Network and filter for collect
  2. 2Load your site. You should see a collect request with en=page_view
  3. 3Navigate to another page (no full reload). You should see another collect request
  4. 4In GA4: Reports → Realtime — your session should appear within a few seconds
  5. 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:

typescript
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_view on while also firing it manually. Pick one
  • Missing events on client navigation — caused by not having a usePathname/useSearchParams effect. The script alone isn't enough in App Router
  • Missing ?utm_* attribution — your manual page_view must include page_location with 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 gtag before it's defined — always guard with typeof window.gtag === "function"; next/script is 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.js with next/script in the root layout
  • Turn OFF GA4's automatic send_page_view and fire page_view manually on route changes
  • Use usePathname + useSearchParams in 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.

#Analytics#GA4#Next.js#React#Tutorial

Have a project?

Building something we've just written about?

Drop us a line. We respond within 24 hours with a candid, no-pressure take on whether we're the right partner.