How to add Google Analytics 4 to Next.js

By

Learn how to add Google Analytics 4 to Next.js 16 App Router with the GoogleAnalytics component from @next/third-parties, or with next/script in the root layout.

~~~

To add Google Analytics 4 to a Next.js 16 App Router site, install @next/third-parties and render its GoogleAnalytics component in the root layout. That’s it for a basic setup. GA4 records client-side navigations on its own, so you don’t need a route change listener like we did in the Pages Router days.

I’ll show that first, then the manual next/script version for when you want control over the gtag snippet, and finally the old Pages Router code for the apps that still use it.

Set up the property ID

Create a Google Analytics property and save the measurement ID (it looks like G-XXXXXXXXXX) in a NEXT_PUBLIC_GOOGLE_ANALYTICS environment variable, in your .env.local file.

The NEXT_PUBLIC_ prefix matters. Next.js only exposes environment variables to the browser when they start with it. Name the variable GOOGLE_ANALYTICS and process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS will be undefined in the client code, and tracking silently won’t work. That’s the most common reason this setup fails.

The GoogleAnalytics component

Install the package. Its version tracks Next.js, so pin the same major:

npm install @next/third-parties@16

Then add the component to your root layout, app/layout.js (in a TypeScript project it’s layout.tsx, with children typed as React.ReactNode):

import { GoogleAnalytics } from '@next/third-parties/google'

export default function RootLayout({ children }) {
  const gaId = process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS

  return (
    <html lang="en">
      <body>{children}</body>
      {gaId ? <GoogleAnalytics gaId={gaId} /> : null}
    </html>
  )
}

The component loads gtag.js after hydration and calls gtag('config', ...) once, for the first pageview. Client-side navigations are handled by GA4 itself: with Enhanced Measurement on (the default for new properties), GA4 sends a pageview every time the browser history changes, which is what the App Router does when you click a link.

Check that setting once in the GA admin: open your web data stream, then Enhanced measurement, and under Page views make sure “Page changes based on browser history events” is ticked. If it’s off, in-app navigations go missing and you’ll wonder why every session has exactly one pageview.

The gaId ? ... : null check keeps GA out of local development, where the variable is usually not set.

Doing it by hand with next/script

If you’d rather own the gtag snippet, for example to pass extra config options, load it with next/script from the root layout:

import Script from 'next/script'

export default function RootLayout({ children }) {
  const gaId = process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS

  return (
    <html lang="en">
      <body>
        {children}
        {gaId ? (
          <>
            <Script
              src={`https://www.googletagmanager.com/gtag/js?id=${gaId}`}
              strategy="afterInteractive"
            />
            <Script id="google-analytics" strategy="afterInteractive">
              {`
                window.dataLayer = window.dataLayer || [];
                function gtag(){dataLayer.push(arguments);}
                gtag('js', new Date());
                gtag('config', '${gaId}');
              `}
            </Script>
          </>
        ) : null}
      </body>
    </html>
  )
}

The first script loads the gtag library. The second defines gtag() and records the initial pageview. Shared chrome like this belongs in the layout the same way other wrappers do in Adding a wrapper component to your Next.js app.

There’s still no navigation listener here, because Enhanced Measurement takes care of in-app navigations.

Sending pageviews yourself

There’s one case where you do want a listener: when you send pageviews manually, because you want to control the page_path or fire them at a different moment. If you go this way, first turn OFF “Page changes based on browser history events” in Enhanced Measurement. Otherwise GA4 counts every navigation twice, once from its own history listener and once from yours.

The App Router does not have router.events. You listen to the pathname and search params in a Client Component instead:

'use client'

import { useEffect, useRef } from 'react'
import { usePathname, useSearchParams } from 'next/navigation'

export function GoogleAnalyticsNav() {
  const pathname = usePathname()
  const searchParams = useSearchParams()
  const isFirstLoad = useRef(true)

  useEffect(() => {
    if (isFirstLoad.current) {
      isFirstLoad.current = false
      return
    }

    const gaId = process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS
    if (!gaId || typeof window.gtag !== 'function') return

    const query = searchParams.toString()
    const url = query ? `${pathname}?${query}` : pathname

    window.gtag('config', gaId, {
      page_path: url,
    })
  }, [pathname, searchParams])

  return null
}

Render it in the layout next to the scripts, wrapped in <Suspense fallback={null}>. The wrapper is not optional: useSearchParams() on a statically rendered page makes next build fail unless the hook sits inside a Suspense boundary.

Every time the location changes, the effect runs and registers a pageview. The isFirstLoad ref skips the very first run, because the inline gtag('config', ...) in the layout already recorded that one.

To verify it works, deploy and open the Realtime report in Google Analytics while you click around your site. Don’t trust the regular reports for testing, they can take a day to show data.

Pages Router variant

On older pages/ apps, the script tags went in _document.js and the route change listener in pages/_app.js, using next/router events:

import { useEffect } from 'react'
import { useRouter } from 'next/router'

function MyApp({ Component, pageProps }) {
  const router = useRouter()

  useEffect(() => {
    const handleRouteChange = url => {
      window.gtag('config', process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS, {
        page_path: url,
      })
    }
    router.events.on('routeChangeComplete', handleRouteChange)
    return () => {
      router.events.off('routeChangeComplete', handleRouteChange)
    }
  }, [router.events])

  return <Component {...pageProps} />
}

The same double counting warning applies here: with Enhanced Measurement’s history tracking on, this listener duplicates every pageview, so on a GA4 property either drop the listener or turn that option off.

Note: the Pages Router solution was adapted from a post by Marie Starck on mariestarck.com.

Tagged: Next.js · All topics

Want me to talk about your product? You can sponsor this site.

~~~

Related posts about next: