Skip to main content

How to Add Google Analytics 4 to Next.js 14 App Router (No Next/Script Errors)

 Migrating to the Next.js App Router fundamentally changes how scripts are injected and how client-side navigation is tracked. Developers attempting to drop legacy Google Analytics implementations into Next.js 14 often encounter hydration errors, strict mode warnings, or entirely missing pageview data.

Because the App Router defaults to React Server Components (RSC) and replaces the legacy next/router with next/navigation, traditional methods for tracking route changes no longer apply.

This guide details a production-grade architecture for Next.js Google Analytics integration. It prevents hydration mismatches, preserves server-side rendering optimizations, and guarantees accurate telemetry.

The Root Cause: Why GA4 Breaks in the App Router

In the legacy Pages Router, implementing analytics meant attaching an event listener to router.events.on('routeChangeComplete') inside _app.js.

The App Router eliminates router.events. Furthermore, the root layout.tsx is a Server Component. Injecting a standard <script> tag directly into a Server Component violates React hydration rules. When the server renders the HTML and the client attempts to hydrate it, the presence of an unmanaged inline script causes React to throw a hydration mismatch error.

Additionally, if you rely on the useSearchParams hook to track query strings in your analytics payload, failing to wrap that logic in a React <Suspense> boundary will silently de-optimize your entire application, forcing dynamic client-side rendering on every route.

The Architecture of GA4 App Router Tracking

To build a flawless React analytics integration, we must separate concerns:

  1. Script Injection: Managed by next/script to ensure it loads at the correct point in the browser lifecycle.
  2. Navigation Tracking: Managed by a dedicated Client Component utilizing usePathname and useSearchParams.
  3. Suspense Isolation: Wrapping the tracking component to protect the static rendering phase of the Server Component layout.

Step-by-Step Implementation

1. Define Global Window Types

Because we are interacting directly with the dataLayer, TypeScript will throw compilation errors unless we extend the global Window interface.

Create a definitions file at the root of your project (e.g., types/gtag.d.ts):

// types/gtag.d.ts
declare global {
  interface Window {
    window: Window;
    dataLayer: Record<string, any>[];
    gtag: (...args: any[]) => void;
  }
}

export {};

2. Create the GA4 Client Component

We isolate the tracking logic in a dedicated Client Component. This component listens to route transitions and pushes the updated path to the Google Tag Manager data layer.

Create a new file at components/GoogleAnalytics.tsx:

'use client';

import Script from 'next/script';
import { usePathname, useSearchParams } from 'next/navigation';
import { useEffect } from 'react';

export default function GoogleAnalytics({ measurementId }: { measurementId: string }) {
  const pathname = usePathname();
  const searchParams = useSearchParams();

  useEffect(() => {
    if (!pathname || !measurementId) return;

    // Construct the full URL including search parameters
    const url = searchParams.toString() 
      ? `${pathname}?${searchParams.toString()}` 
      : pathname;

    // Push the route change to the data layer
    if (typeof window.gtag !== 'undefined') {
      window.gtag('config', measurementId, {
        page_path: url,
      });
    }
  }, [pathname, searchParams, measurementId]);

  return (
    <>
      <Script
        strategy="afterInteractive"
        src={`https://www.googletagmanager.com/gtag/js?id=${measurementId}`}
      />
      <Script
        id="google-analytics"
        strategy="afterInteractive"
        dangerouslySetInnerHTML={{
          __html: `
            window.dataLayer = window.dataLayer || [];
            function gtag(){dataLayer.push(arguments);}
            gtag('js', new Date());
            gtag('config', '${measurementId}', {
              page_path: window.location.pathname,
            });
          `,
        }}
      />
    </>
  );
}

3. Integrate with the Root Layout

Inject the component into your root layout.tsx. Notice the inclusion of the <Suspense> boundary. This is a critical requirement for Next.js 14 performance.

// app/layout.tsx
import { Suspense } from 'react';
import GoogleAnalytics from '@/components/GoogleAnalytics';
import './globals.css';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const isProduction = process.env.NODE_ENV === 'production';
  const gaId = process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID;

  return (
    <html lang="en">
      <body>
        {children}
        
        {isProduction && gaId && (
          <Suspense fallback={null}>
            <GoogleAnalytics measurementId={gaId} />
          </Suspense>
        )}
      </body>
    </html>
  );
}

Deep Dive: How This Integration Works

The Suspense Boundary Requirement

In the App Router, reading useSearchParams outside of a <Suspense> boundary forces the closest Server Component to opt into dynamic rendering. By wrapping <GoogleAnalytics /> in <Suspense fallback={null}>, we allow the Next.js compiler to statically generate the layout during the build phase, preserving your Time to First Byte (TTFB) metrics.

Next/Script Loading Strategies

We use strategy="afterInteractive" for both script tags. This instructs Next.js to inject the scripts immediately after the page becomes interactive. This is the optimal setting for analytics—it ensures your tracking fires quickly without blocking the main thread or delaying the First Contentful Paint (FCP).

Dependency Array Precision

The useEffect dependency array contains [pathname, searchParams, measurementId]. Every time a user clicks a <Link> component, Next.js performs a soft navigation, updating the pathname and search params locally. React evaluates this change, triggers the effect, and fires the gtag('config') command, mimicking traditional pageview tracking perfectly.

Handling Custom Events in Next.js 14

Standard pageviews only cover the baseline. For granular GA4 App Router tracking, you will need to push custom events (e.g., button clicks, form submissions).

Create a utility function to ensure type safety and prevent runtime errors if ad blockers disable the gtag object.

// utils/analytics.ts
export const trackEvent = (
  eventName: string,
  eventParams?: Record<string, any>
) => {
  if (typeof window !== 'undefined' && typeof window.gtag !== 'undefined') {
    window.gtag('event', eventName, eventParams);
  }
};

You can then import and execute this utility inside any Client Component:

'use client';

import { trackEvent } from '@/utils/analytics';

export default function CheckoutButton() {
  const handleCheckout = () => {
    trackEvent('checkout_initiated', {
      currency: 'USD',
      value: 99.99,
    });
    // Proceed with checkout logic
  };

  return <button onClick={handleCheckout}>Checkout</button>;
}

Common Pitfalls and Edge Cases

Double Counting Pageviews (Enhanced Measurement)

By default, GA4 enables "Enhanced Measurement," which automatically tracks browser history state changes. Because Next.js uses the History API for App Router transitions, having Enhanced Measurement on while firing manual page_path updates via our useEffect will result in double-counted pageviews.

The Fix: Go to your GA4 Data Stream settings, click the gear icon next to Enhanced Measurement, and disable "Page changes based on browser history events." This hands full control back to your React code.

React Strict Mode Ghost Data

During local development, React 18+ Strict Mode mounts components, unmounts them, and remounts them to simulate state resilience. This causes the useEffect hook in our GoogleAnalytics component to run twice, firing two pageviews in your network tab. This behavior only occurs in development (next dev) and will not affect your production data. Do not add hacky ref counters to bypass this; trust the React lifecycle.

Content Security Policy (CSP) Blocks

If your Next.js application enforces a strict Content Security Policy, the inline script inside dangerouslySetInnerHTML will be blocked by the browser. To resolve this, you must generate a cryptographic nonce on the server, pass it to your next/script components via the nonce prop, and include it in your HTTP headers.

Conclusion

Integrating Google Analytics into the Next.js 14 App Router requires abandoning legacy router events in favor of modern React primitives. By leveraging a dedicated Client Component, properly isolating search parameters within a Suspense boundary, and utilizing the afterInteractive script strategy, you guarantee robust tracking data without sacrificing the performance benefits of Next.js Server Components.