GA4 for Next.js

A complete, production-ready Google Analytics 4 setup for Next.js using the official @next/third-parties package: real traffic, Core Web Vitals, and custom events, with a consent gate that means what it says.

Adapted from a guide by Andi Ashari, first published on Medium.

Paste into your AI chat

Copy the entire guide as markdown to use as context in Cursor, Claude, ChatGPT, or any AI tool.

Why @next/third-parties

The official package does the fiddly parts for you.

You could drop raw gtag snippets into your head, but the official package is the calmer path. It loads after hydration so it does not fight your page for the main thread, tracks pageviews automatically, is typed, and has Web Vitals support built in.

  • Official Next.js solution
  • Loads after hydration (fast)
  • Automatic pageview tracking
  • Type-safe with TypeScript
  • Built-in Web Vitals support

Quick-start checklist

The whole setup, at a glance.

  1. 1Install @next/third-parties
  2. 2Add NEXT_PUBLIC_GA_MEASUREMENT_ID to your env
  3. 3Create the GoogleAnalytics component
  4. 4Create the WebVitals component
  5. 5Create lib/analytics.ts
  6. 6Integrate in app/layout.tsx (after children)
  7. 7Gate it all behind cookie consent
  8. 8Test that analytics is off in development
  9. 9Verify real events in the GA4 dashboard

Install and configure

Add the package, then put your measurement ID in the environment. Use a different ID for development, staging, and production, and never commit a real one.

Install

bash
npm install @next/third-parties@latest
# or
pnpm add @next/third-parties@latest
# or
bun add @next/third-parties@latest

Environment

env
# Google Analytics 4 Measurement ID
# Get from: https://analytics.google.com/analytics/web/
# Format: G-XXXXXXXXXX
NEXT_PUBLIC_GA_MEASUREMENT_ID=G-XXXXXXXXXX

The four files

A clean GA4 setup is four small files: two components, one utilities module, and the layout that ties them together. Expand each to see the code.

1. The GoogleAnalytics component

A thin client wrapper around @next/third-parties that only renders in production and only when a real measurement ID is set.

components/GoogleAnalytics.tsx

2. The WebVitals component

Reports Core Web Vitals (LCP, INP, CLS, FCP, TTFB) to GA4 with a single hook.

components/WebVitals.tsx

3. The analytics utilities

Typed helpers for custom events and page views, plus a small analytics object for the interactions most sites care about.

lib/analytics.ts

4. Layout integration

Render both components after {children} so analytics loads once the page is interactive, not before.

app/layout.tsx

Cookie consent: make "Reject" mean no data

By default the setup above starts collecting the moment a production page loads. In the EU and UK, and a growing list of other regions, you cannot load analytics or set analytics cookies until the visitor has actively agreed. Reject has to mean the tracker never runs, not that it runs quietly in the background.

The honest default: don't mount GoogleAnalytics or WebVitals at all until the visitor accepts. No script, no request, no cookies while they are undecided or have said no. Consent Mode v2 is the second layer underneath it.

Store the choice

A tiny context that starts as "unknown", reads any saved choice on mount, and persists new choices to localStorage.

tsx
// components/ConsentProvider.tsx
'use client'

import { createContext, useContext, useEffect, useState } from 'react'

type Consent = 'granted' | 'denied' | 'unknown'

const ConsentContext = createContext<{
  consent: Consent
  setConsent: (c: Consent) => void
}>({ consent: 'unknown', setConsent: () => {} })

export function useConsent() {
  return useContext(ConsentContext)
}

export function ConsentProvider({ children }: { children: React.ReactNode }) {
  // Start as 'unknown' so nothing analytics-related runs on the first paint
  const [consent, setConsentState] = useState<Consent>('unknown')

  // Read the saved choice once on mount
  useEffect(() => {
    const saved = localStorage.getItem('analytics-consent')
    if (saved === 'granted' || saved === 'denied') setConsentState(saved)
  }, [])

  const setConsent = (c: Consent) => {
    localStorage.setItem('analytics-consent', c)
    setConsentState(c)
    // Also tell GA about the change for this page load (Consent Mode, Layer 2)
    window.gtag?.('consent', 'update', {
      analytics_storage: c === 'granted' ? 'granted' : 'denied',
    })
  }

  return (
    <ConsentContext.Provider value={{ consent, setConsent }}>
      {children}
    </ConsentContext.Provider>
  )
}

Gate GA on the choice

GoogleAnalytics returns null until consent is granted, so the script is never injected and no request is made while the visitor is undecided or has rejected.

tsx
// components/GoogleAnalytics.tsx — only mounts once consent is granted
'use client'

import { GoogleAnalytics as NextGoogleAnalytics } from '@next/third-parties/google'
import { GA_MEASUREMENT_ID, isAnalyticsEnabled } from '@/lib/analytics'
import { useConsent } from './ConsentProvider'

export default function GoogleAnalytics() {
  const { consent } = useConsent()
  // Not in development, and not until the visitor has explicitly agreed
  if (!isAnalyticsEnabled() || consent !== 'granted') {
    return null
  }
  return <NextGoogleAnalytics gaId={GA_MEASUREMENT_ID} />
}

Consent Mode v2 defaults

Belt and braces: set analytics_storage to denied before anything loads, then update to granted on accept. Even a stray tag writes no cookies.

tsx
// app/layout.tsx — set consent defaults before anything else
import Script from 'next/script'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <Script id="consent-default" strategy="beforeInteractive">
          {`
            window.dataLayer = window.dataLayer || [];
            function gtag(){dataLayer.push(arguments);}
            window.gtag = window.gtag || gtag;
            gtag('consent', 'default', {
              ad_storage: 'denied',
              analytics_storage: 'denied',
              ad_user_data: 'denied',
              ad_personalization: 'denied',
              wait_for_update: 500,
            });
          `}
        </Script>
      </head>
      <body>{children}</body>
    </html>
  )
}

The banner

Shows only while the choice is still undecided. Reject and Accept both write the choice and hide the banner.

tsx
// components/CookieConsentBanner.tsx
'use client'

import { useConsent } from './ConsentProvider'

export function CookieConsentBanner() {
  const { consent, setConsent } = useConsent()
  if (consent !== 'unknown') return null // already decided, stay hidden

  return (
    <div role="dialog" aria-label="Cookie consent" className="cookie-banner">
      <p>We use analytics cookies to see how the site is used. They stay off until you accept.</p>
      <div>
        <button onClick={() => setConsent('denied')}>Reject</button>
        <button onClick={() => setConsent('granted')}>Accept</button>
      </div>
    </div>
  )
}

Wire it into the layout

Wrap the app in ConsentProvider and render the banner. GoogleAnalytics only mounts after Accept.

tsx
// app/layout.tsx (body excerpt) — wrap the app and render the banner
import { ConsentProvider } from '@/components/ConsentProvider'
import { CookieConsentBanner } from '@/components/CookieConsentBanner'
import GoogleAnalytics from '@/components/GoogleAnalytics'
import { WebVitals } from '@/components/WebVitals'

// ...inside <body>:
<ConsentProvider>
  {children}
  <GoogleAnalytics /> {/* only mounts after Accept */}
  <WebVitals />
  <CookieConsentBanner />
</ConsentProvider>

Checklist for honest consent

Verify each of these before you call it done.

  • The default state is "no analytics". Nothing loads on a first visit until the visitor chooses.
  • Reject writes the choice, hides the banner, and never mounts GA. Confirm in DevTools Network that there are zero google-analytics.com / googletagmanager.com requests after clicking Reject.
  • Accept mounts GA and, via Consent Mode, flips analytics_storage to granted.
  • The choice persists across page loads (localStorage) and the banner does not reappear until the visitor clears it.
  • Give people a way to change their mind later, such as a "Cookie settings" link that resets the choice.
  • WebVitals is gated on consent too, since it also reports to GA.

Worth doing once it's live

Separate IDs per environment

Keep staging traffic out of your production reports by resolving the measurement ID from the environment.

typescript
// lib/analytics.ts
export const GA_MEASUREMENT_ID =
  process.env.NEXT_PUBLIC_ENVIRONMENT === 'production'
    ? process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID_PROD
    : process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID_STAGING

Content Security Policy

If you send a CSP, allow the Google Analytics and Tag Manager domains or the tag is silently blocked.

javascript
// next.config.js
const cspHeader = `
  default-src 'self';
  script-src 'self' 'unsafe-eval' 'unsafe-inline' https://www.googletagmanager.com https://www.google-analytics.com;
  connect-src 'self' https://www.google-analytics.com https://analytics.google.com;
  img-src 'self' blob: data: https://www.google-analytics.com;
`

Test it before you trust it

Analytics should be silent in development and loud in production.

In development, open DevTools Network and filter for google-analytics or gtag. You should see no requests at all.

bash
# Start dev server
npm run dev

# Open browser DevTools -> Network tab
# Filter: google-analytics or gtag
# Should see: NO requests

Then build for production, accept the cookie banner, and check GA4 Reports then Realtime. Your own visit should show up within a few seconds. Reject first and confirm nothing fires, then accept and watch it come to life.

Prompt your AI assistant

Paste this to have your AI tool wire up GA4 the right way.

text
Add Google Analytics 4 to this Next.js app using the official
@next/third-parties/google package. Follow these rules:

1. Install @next/third-parties and read the measurement ID from
   NEXT_PUBLIC_GA_MEASUREMENT_ID. Never hardcode the ID.

2. Create a GoogleAnalytics client component that renders nothing in
   development (NODE_ENV === 'development') or when the ID is missing or
   still the G-XXXXXXXXXX placeholder.

3. Create a WebVitals client component using useReportWebVitals to send
   Core Web Vitals (LCP, INP, CLS, FCP, TTFB) to GA4.

4. Add a lib/analytics.ts with typed helpers: trackEvent, trackPageView,
   reportWebVitals, and a small analytics object for common interactions
   (external links, downloads, form submissions, search).

5. In app/layout.tsx, render <GoogleAnalytics /> and <WebVitals /> AFTER
   {children} so analytics loads after hydration and never blocks the page.

6. If the project has a CSP, add the Google Analytics and Tag Manager
   domains to script-src, connect-src, and img-src.

7. Gate everything behind cookie consent. The GoogleAnalytics and WebVitals
   components must render nothing until the visitor explicitly accepts, so a
   "Reject" genuinely loads no script and makes no requests. Store the choice,
   show a banner only while it is undecided, and set Google Consent Mode v2
   defaults to denied before anything loads.

Show me the diff before applying it, and do not commit any real
measurement ID.

Want the full guide as context for your AI coding assistant?