Skip to content
Greeto

June 29, 2026 · 8 min read · Updated June 29, 2026

Consent-Gated Analytics in Next.js (App Router): The Practical Guide

How to load analytics in Next.js only after a user consents, using the App Router, with the exact pattern and the pitfalls that break it.

By Tal Gerafi, Founder & Website Engineer

Next.jsPrivacy

To make analytics consent-gated in Next.js (App Router), do not render your analytics component in layout.tsx by default. Store the user's choice in a cookie or localStorage, read it in a small Client Component, and conditionally render the analytics tag only when consent is granted. Until then, no analytics script touches the page. This is GDPR-friendly because under EU rules, optional analytics needs consent before any tracking cookie or request fires — not after.

The mistake we see most: the banner looks right, "Reject" works, but the analytics request already went out on first paint. Below is the pattern that actually holds, plus the two real strategies (hard-gate vs Google Consent Mode), route-change tracking, and the pitfalls that quietly leak data.

Consent-gated means the analytics script does not load, and no analytics network request fires, until the user has explicitly said yes. Loading the script first and "disabling" it after a reject is not consent-gated — the cookie or beacon already happened. Under GDPR and the ePrivacy Directive, non-essential analytics cookies require prior consent, so the gate has to sit in front of the script, not behind it.

There are two honest ways to build this in Next.js:

  1. Hard-gate — the GA4 / analytics component is simply not rendered until consent is granted. Nothing loads, nothing fires. Simplest to reason about and the safest default for EU traffic.
  2. Google Consent Mode v2 — the gtag script loads early but in a "denied" state (ad_storage, analytics_storage set to denied), sends cookieless "pings", and switches to granted when the user accepts. Google designed this so you keep modeled conversions while respecting the choice.

Most B2B sites we build pick the hard-gate because it is unambiguous and easy to verify. Consent Mode is the better fit when you run Google Ads and care about conversion modeling. We compare both below.

The whole pattern is three small pieces: a consent store, a banner that writes the choice, and a gate that reads it and conditionally renders analytics. Because consent lives in the browser, the gate must be a Client Component.

A minimal AnalyticsGate:

'use client'

import { useEffect, useState } from 'react'
import { GoogleAnalytics } from '@next/third-parties/google'

export function AnalyticsGate() {
  const [consent, setConsent] = useState<'unknown' | 'granted' | 'denied'>('unknown')

  useEffect(() => {
    setConsent((localStorage.getItem('analytics-consent') as typeof consent) ?? 'unknown')
    const onChange = () =>
      setConsent((localStorage.getItem('analytics-consent') as typeof consent) ?? 'unknown')
    window.addEventListener('consent-changed', onChange)
    return () => window.removeEventListener('consent-changed', onChange)
  }, [])

  if (consent !== 'granted') return null
  return <GoogleAnalytics gaId={process.env.NEXT_PUBLIC_GA_ID!} />
}

Then render <AnalyticsGate /> in layout.tsx. Your banner's "Accept" handler writes localStorage.setItem('analytics-consent', 'granted') and dispatches a consent-changed event so the gate re-renders without a full reload. Because the gate returns null until consent is granted, @next/third-parties never injects the gtag script and GA4 never sees the user.

Two details people miss. First, start consent as unknown, not denied — on the server you can't read localStorage, so the gate must render nothing until the effect runs, which also avoids a hydration mismatch. Second, an <html suppressHydrationWarning>-style early return keeps server and client output identical (both render nothing) until the browser knows the answer.

This is the same supervised research → build → review loop Greeto uses on client sites: we verify the absence of the request before consent, not just the presence of a banner. The deeper "how" behind that workflow is in our guide on building production sites with Claude Code.

Both are legitimate. The choice is about what you're optimizing for and how much Google tooling you depend on.

FactorHard-gate (don't render until consent)Google Consent Mode v2
What loads before consentNothinggtag loads in denied state, sends cookieless pings
Cookies before consentNoneNone (storage denied)
EU compliance clarityVery high — easy to prove zero trackingHigh, but relies on Google's denied-state behavior
Conversion modeling (Google Ads)Lost for pre-consent usersPreserved via modeled conversions
Setup complexityLowMedium (default + update gtag calls)
Easiest to verifyYes — Network tab shows nothingHarder — pings look like traffic
Best forMost B2B/SaaS marketing sitesSites running Google Ads at scale

If you mostly need clean, trustworthy product analytics and a defensible privacy posture, hard-gate. If your paid acquisition depends on Google's conversion modeling, Consent Mode earns its extra wiring. You can also combine them: hard-gate everything except a Consent-Mode gtag that you load in denied state — but only do that if you genuinely use the modeled data, otherwise it's complexity for nothing.

The App Router does not do full page loads on client navigation, so a single page_view on mount under-counts. After consent is granted, add a small Client Component that fires a page_view whenever the path or query changes, using usePathname and useSearchParams:

'use client'

import { useEffect } from 'react'
import { usePathname, useSearchParams } from 'next/navigation'
import { sendGAEvent } from '@next/third-parties/google'

export function PageViews() {
  const pathname = usePathname()
  const searchParams = useSearchParams()

  useEffect(() => {
    sendGAEvent('event', 'page_view', { page_path: pathname })
  }, [pathname, searchParams])

  return null
}

Render <PageViews /> inside your consent gate so it only runs after granted. Wrap it in <Suspense> if you use useSearchParams, since that hook opts the subtree into client rendering. Keep the event name and payload shape identical to a normal session — if late-consent users send a differently-shaped page_view, your reports fracture and you'll spend a day chasing a "data quality" ghost that is really a schema bug.

These are the ones that pass a casual look and fail a real audit:

  • The script loads on first paint anyway. Someone put <GoogleAnalytics> in layout.tsx outside the gate "just to be safe." Verify by checking the Network tab on a fresh visit with no choice made — you should see zero google-analytics.com or googletagmanager.com requests.
  • State and storage drift. Runtime React state says "denied" but a stale localStorage key says "granted" (or vice versa). Pick one source of truth, read it on mount, and broadcast changes with an event so every consumer stays in sync.
  • Route changes bypass the gate. Gating looks correct on first load, then a client-side navigation re-mounts something that loads analytics regardless. Test accept → navigate → reject → navigate, not just the landing page.
  • Hydration mismatch from reading storage during render. Reading localStorage in the render body (not in useEffect) makes the server HTML and client HTML disagree. Default to unknown, decide in an effect.
  • No way to change your mind. Consent must be revocable. Give users a persistent "Cookie settings" link; in the EU, withdrawing consent has to be as easy as giving it.
  • Re-loading the script on every grant. If your banner re-grants on each page, you can double-initialize GA. Guard initialization so it happens once per session.

We treat "is the request actually absent before consent?" as a human-in-the-loop gate, the same way we verify a real domain is serving the new build before calling a migration done. A banner that looks compliant and a network trace that proves it are different things.

FAQ

It depends on your jurisdiction and legal basis, but the safe default is no. Under GDPR and the ePrivacy Directive, non-essential analytics cookies and identifiers generally need prior consent. Some privacy-first, cookieless tools argue they need no consent — confirm that with your own counsel rather than assuming.

Yes. Consent Mode is how you communicate the user's choice to Google's tags; it is not the consent mechanism itself. You still need a banner or preference UI where the user actually grants or denies, and Consent Mode reads that decision.

A stable key in localStorage or a first-party cookie works well. Cookies are readable on the server (useful if you want to decide rendering server-side), while localStorage is client-only. Either way, keep one source of truth and read it on mount to avoid hydration mismatches.

Yes. You lose volume from users who decline, but the data you keep is cleaner and your privacy posture is defensible. For product decisions, trustworthy data from consenting users usually beats inflated counts you can't stand behind.

What breaks most often in the Next.js App Router specifically?

Two things: hydration timing (reading consent during render instead of in an effect) and client-side route changes that either skip page_view tracking or re-trigger script loading. Test navigation flows, not just the first page load.

Where this fits

Consent gating is one slice of getting a Next.js marketing site right end to end. If you're moving off WordPress, analytics and consent should be wired during the rebuild, not bolted on after — see the WordPress to Next.js migration and SEO guide. And if you're tightening up how the same site shows up in AI search, the measurement that consent protects feeds directly into that work; our practical AEO playbook for B2B websites covers the answer-first side. Done properly, consent-gated analytics is not a banner component — it's a behavior contract across scripts, state, and events, and it earns trust without costing you decision-grade data.