Skip to main content
Back to Guides
Compliance13 min read

Next.js Cookie Consent with App Router: The 2026 Developer Guide

A deep technical guide to implementing cookie consent in Next.js App Router applications. Covers Server Component limitations, next/script strategies for CMP loading, Client Component consent wrappers, dynamic imports for consent-gated analytics, middleware-level enforcement, GTM with Consent Mode v2, and CookieBeam integration.

Next.js is arguably the default React framework for production applications in 2026. It's also one of the hardest environments to get cookie consent right. The App Router's split between Server Components and Client Components, streaming SSR, and middleware layer create a set of timing and execution constraints that generic CMP documentation doesn't address.

If you've tried dropping a consent script into your Next.js app and found that it fires before hydration, doesn't work across use client boundaries, or silently skips server-side enforcement, this guide covers every layer: CMP placement, consent state management, dynamic imports, middleware, Consent Mode v2, and CookieBeam integration. All examples use TypeScript.

Why the App Router Makes Cookie Consent Harder

The App Router introduced a fundamental change: components are Server Components by default. Server Components run on the server, render to HTML, and stream to the client. They can't access window, document, or any browser API. That single constraint breaks the core assumption of every CMP: that it can read and write cookies in the browser, show a banner in the DOM, and listen for user interaction.

Here's what breaks:

  • Server Components can't read document.cookie. You can read request cookies via cookies() from next/headers, but those are HTTP cookies from the request — not the live consent state updated client-side.
  • Streaming SSR sends HTML before hydration. Scripts injected into that HTML can execute before your consent wrapper has even mounted.
  • Layout renders before page. If your CMP loads in a page component, the layout (and its scripts) execute first — before the CMP is ready.
  • 'use client' boundaries need careful placement. Your consent provider must be a Client Component (it needs document access), but it sits inside a Server Component layout.

Where to Place the CMP Script: layout.tsx and next/script

The CMP script should go in your root layout (app/layout.tsx): it needs to load on every route, before any tracking script executes. Next.js provides <Script> from next/script with four loading strategies:

  • beforeInteractive — Injected into the initial HTML, executed before hydration. This is correct for the CMP. Must be placed in the root layout.
  • afterInteractive (default) — Loads after some hydration. Creates a gap where tracking scripts could fire before consent is established.
  • lazyOnload — Defers until browser idle. Ideal for non-critical tracking scripts, never for the CMP.
  • worker (experimental) — Offloads to a web worker. Not suitable for the CMP (needs DOM access).

Here's the correct placement:

// app/layout.tsx
import Script from 'next/script'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>
        {children}
        {/* CMP loads before any Next.js module hydrates */}
        <Script
          src="https://cdn.cookiebeam.com/banner/YOUR_BANNER_ID/default/loader.js"
          strategy="beforeInteractive"
        />
      </body>
    </html>
  )
}

beforeInteractive injects the script into the HTML <head>, so it executes before React hydration. By the time Client Components mount, consent state is established.

Client Component Wrapper for Consent State

Server Components can't access browser consent state. You need a Client Component that reads consent, subscribes to changes, and exposes the current state to the rest of your app. The pattern is a React context provider marked with 'use client', placed inside your root layout.

// components/ConsentProvider.tsx
'use client'

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

type ConsentState = {
  analytics: boolean
  marketing: boolean
  preferences: boolean
  loaded: boolean
}

const ConsentContext = createContext<ConsentState>({
  analytics: false,
  marketing: false,
  preferences: false,
  loaded: false,
})

export function ConsentProvider({ children }: { children: React.ReactNode }) {
  const [consent, setConsent] = useState<ConsentState>({
    analytics: false,
    marketing: false,
    preferences: false,
    loaded: false,
  })

  useEffect(() => {
    // Read initial consent state from cookies
    const stored = document.cookie
      .split('; ')
      .find(row => row.startsWith('cookiebeam_consent='))
    if (stored) {
      try {
        const value = JSON.parse(decodeURIComponent(stored.split('=')[1]))
        setConsent({ ...value, loaded: true })
      } catch {
        setConsent(prev => ({ ...prev, loaded: true }))
      }
    } else {
      setConsent(prev => ({ ...prev, loaded: true }))
    }

    // Listen for consent changes from the CMP
    const handler = (e: CustomEvent) => {
      const { analytics, marketing, preferences } = e.detail
      setConsent({ analytics, marketing, preferences, loaded: true })
    }
    window.addEventListener('cookiebeam:consent', handler as EventListener)
    return () => window.removeEventListener('cookiebeam:consent', handler as EventListener)
  }, [])

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

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

Place <ConsentProvider> inside your root layout, wrapping {children}. The root layout is a Server Component, but it can render Client Components as children — this is the standard App Router pattern. Any descendant Client Component can now call useConsent() to check consent and react to changes in real time.

Dynamic Imports for Consent-Dependent Analytics

In a traditional multi-page site, you block a tracking script by changing its type attribute or removing its src. In Next.js, tracking is often a component — a Google Analytics wrapper, a Meta Pixel initializer, a Hotjar integration. You don't want these components to even load their JavaScript bundles until consent is granted.

next/dynamic with ssr: false is the mechanism for this. It lazy-loads a component only on the client, and you can gate the import on consent state. There's a critical constraint: next/dynamic with ssr: false must be used inside a Client Component ('use client'), not a Server Component.

// components/ConsentGatedAnalytics.tsx
'use client'

import dynamic from 'next/dynamic'
import { useConsent } from './ConsentProvider'

const GoogleAnalytics = dynamic(
  () => import('./GoogleAnalytics'),
  { ssr: false }
)

const MetaPixel = dynamic(
  () => import('./MetaPixel'),
  { ssr: false }
)

export function ConsentGatedAnalytics() {
  const { analytics, marketing, loaded } = useConsent()

  if (!loaded) return null

  return (
    <>
      {analytics && <GoogleAnalytics />}
      {marketing && <MetaPixel />}
    </>
  )
}

This ensures the analytics bundle isn't in the server-rendered HTML, isn't downloaded until consent is true, and unmounts cleanly if consent is revoked. The underlying analytics component is straightforward:

// components/GoogleAnalytics.tsx
'use client'

import Script from 'next/script'

export default function GoogleAnalytics() {
  return (
    <>
      <Script
        src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX"
        strategy="afterInteractive"
      />
      <Script id="ga-config" strategy="afterInteractive">
        {`
          window.dataLayer = window.dataLayer || [];
          function gtag(){dataLayer.push(arguments);}
          gtag('js', new Date());
          gtag('config', 'G-XXXXXXX');
        `}
      </Script>
    </>
  )
}

afterInteractive is correct here — by the time this component renders, consent is already granted.

Middleware-Level Consent Enforcement

Next.js middleware (middleware.ts) runs on the Edge Runtime before every request. It's the only server-side layer where you can check consent before content is served. Read cookies from the NextRequest object and forward consent state as headers:

// middleware.ts
import { NextResponse, type NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  const consentCookie = request.cookies.get('cookiebeam_consent')
  const response = NextResponse.next()

  if (consentCookie) {
    try {
      const consent = JSON.parse(consentCookie.value)

      // Pass consent state to Server Components via headers
      response.headers.set('x-consent-analytics', consent.analytics ? '1' : '0')
      response.headers.set('x-consent-marketing', consent.marketing ? '1' : '0')
    } catch {
      // Invalid cookie — treat as no consent
      response.headers.set('x-consent-analytics', '0')
      response.headers.set('x-consent-marketing', '0')
    }
  } else {
    // No consent cookie — first visit or expired
    response.headers.set('x-consent-analytics', '0')
    response.headers.set('x-consent-marketing', '0')
  }

  return response
}

export const config = {
  matcher: [
    // Run on all pages but skip static assets and API routes
    '/((?!_next/static|_next/image|favicon.ico|api/).*)',
  ],
}

Server Components can then read these headers via await headers():

// app/page.tsx
import { headers } from 'next/headers'

export default async function Page() {
  const headerStore = await headers()
  const analyticsConsented = headerStore.get('x-consent-analytics') === '1'

  return (
    <main>
      {/* Only render server-side tracking pixel if consented */}
      {analyticsConsented && (
        <img
          src="https://analytics.example.com/pixel.gif?page=/"
          alt=""
          width={1}
          height={1}
        />
      )}
    </main>
  )
}

Caveat: middleware reads the cookie from the request, so it reflects consent at the time of navigation. If the user changes consent without navigating, the server won't see it until the next request. Server-side enforcement complements client-side blocking — it doesn't replace it.

Google Tag Manager with Consent Mode v2 in the App Router

Consent Mode v2 requires setting consent('default', ...) with all signals denied before GTM loads, then calling consent('update', ...) on user acceptance. In the App Router, this means an inline beforeInteractive script for defaults, followed by GTM:

// app/layout.tsx
import Script from 'next/script'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>
        {children}

        {/* 1. Consent defaults — must execute BEFORE GTM */}
        <Script id="consent-defaults" strategy="beforeInteractive">
          {`
            window.dataLayer = window.dataLayer || [];
            function gtag(){dataLayer.push(arguments);}
            gtag('consent', 'default', {
              'analytics_storage': 'denied',
              'ad_storage': 'denied',
              'ad_user_data': 'denied',
              'ad_personalization': 'denied',
              'functionality_storage': 'granted',
              'security_storage': 'granted',
              'wait_for_update': 500
            });
          `}
        </Script>

        {/* 2. GTM container — loads after consent defaults */}
        <Script
          src="https://www.googletagmanager.com/gtm.js?id=GTM-XXXXXXX"
          strategy="afterInteractive"
        />

        {/* 3. CMP — handles consent update */}
        <Script
          src="https://cdn.cookiebeam.com/banner/YOUR_BANNER_ID/default/loader.js"
          strategy="beforeInteractive"
        />
      </body>
    </html>
  )
}

CookieBeam handles the update call automatically. If you're building your own integration, it looks like this:

// Called by your consent handler when the user accepts
function updateGoogleConsent(categories: {
  analytics: boolean
  marketing: boolean
}) {
  const gtag = (window as any).gtag
  if (!gtag) return

  gtag('consent', 'update', {
    analytics_storage: categories.analytics ? 'granted' : 'denied',
    ad_storage: categories.marketing ? 'granted' : 'denied',
    ad_user_data: categories.marketing ? 'granted' : 'denied',
    ad_personalization: categories.marketing ? 'granted' : 'denied',
  })
}

The wait_for_update: 500 tells GTM to wait up to 500ms for a consent update before firing tags. For more on Consent Mode, see the Consent Mode v2 implementation guide.

next/script Strategies for Tracking Scripts

Choosing the right next/script strategy for each tracking script directly affects compliance. Here's a decision framework:

  • CMP / Consent Manager: beforeInteractive. Must load and initialize before anything else. Place in root app/layout.tsx.
  • GTM container: afterInteractive. Needs consent defaults set first (via beforeInteractive inline script), then loads after hydration.
  • GA4 / Google Ads: afterInteractive, but only rendered when consent is granted (use the dynamic import pattern above).
  • Meta Pixel, TikTok Pixel: afterInteractive, consent-gated. These should never load until marketing consent is given.
  • Hotjar, Clarity, heatmaps: lazyOnload, consent-gated. Session recording tools are never urgent and can safely defer to idle time.
  • Chat widgets (Intercom, Drift): lazyOnload. These set cookies but aren't page-critical. Gate on preferences consent if your categorization requires it.

The key rule: beforeInteractive is only for the CMP and consent defaults. Tracking scripts in beforeInteractive execute before any Client Component consent check has run. And afterInteractive without consent gating fires immediately after hydration — safe for GTM (which has Consent Mode), dangerous for standalone analytics scripts.

Server Actions and Consent Verification

Server Actions in Next.js let you run server-side code triggered by client interactions — form submissions, button clicks, data mutations. When a Server Action processes data that should only be collected with consent (like saving user behavior to an analytics database or triggering a server-side conversion event), you need to verify consent server-side.

The approach combines the middleware pattern (forwarding consent via headers) with the cookies() function inside the Server Action:

// app/actions.ts
'use server'

import { cookies } from 'next/headers'

export async function trackConversion(formData: FormData) {
  const cookieStore = await cookies()
  const consentCookie = cookieStore.get('cookiebeam_consent')

  let analyticsConsented = false
  if (consentCookie) {
    try {
      const consent = JSON.parse(consentCookie.value)
      analyticsConsented = consent.analytics === true
    } catch {
      // Invalid cookie — no consent
    }
  }

  // Always process the form (functional requirement)
  const orderId = formData.get('orderId') as string
  await processOrder(orderId)

  // Only track the conversion if analytics consent was given
  if (analyticsConsented) {
    await sendServerSideConversion({
      event: 'purchase',
      orderId,
      value: formData.get('value') as string,
    })
  }
}

Note that cookies() is async — you must await it. The functional logic (processing the order) should always run regardless of consent. Only tracking side-effects — sending data to analytics, firing Meta CAPI or Google Enhanced Conversions events — should be consent-gated. Without server-side verification, these APIs bypass client-side consent entirely.

How CookieBeam Integrates with Next.js

CookieBeam is built with Next.js, so this is first-party knowledge. The entire integration is a single <Script> component in your root layout:

// app/layout.tsx
import Script from 'next/script'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>
        {children}
        <Script
          src="https://cdn.cookiebeam.com/banner/YOUR_BANNER_ID/default/loader.js"
          strategy="beforeInteractive"
        />
      </body>
    </html>
  )
}

Replace YOUR_BANNER_ID with your banner's public ID from the dashboard.

Auto-Blocking

The loader automatically blocks scripts with type="text/plain" and data-category attributes, activating them only after consent. For next/script components:

// Consent-gated script using data attributes
<Script
  src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX"
  strategy="afterInteractive"
  data-category="analytics"
  data-cookiebeam-managed="true"
/>

Consent Mode v2 Out of the Box

CookieBeam automatically sets Consent Mode defaults and pushes consent('update', ...) on user choice. The boilerplate from the GTM section above is handled for you.

Callbacks

For application-level consent reactions beyond script blocking, use window.cookiebeamConfig:

// app/layout.tsx
import Script from 'next/script'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>
        {children}
        <Script id="cookiebeam-config" strategy="beforeInteractive">
          {`
            window.cookiebeamConfig = {
              onAccept: function(categories) {
                console.log('Consent granted:', categories);
              },
              onReject: function() {
                console.log('All non-essential cookies rejected');
              },
              onSettingsSave: function(preferences) {
                console.log('Preferences saved:', preferences);
              }
            };
          `}
        </Script>
        <Script
          src="https://cdn.cookiebeam.com/banner/YOUR_BANNER_ID/default/loader.js"
          strategy="beforeInteractive"
        />
      </body>
    </html>
  )
}

The config script uses beforeInteractive so it's defined before the loader reads it.

Combining Both Patterns

CookieBeam's auto-blocking handles DOM-level scripts (GTM, standalone pixels). The React-level dynamic import pattern from earlier handles component-level gating (React-wrapped analytics, embedded widgets). The two approaches complement each other.

Common Pitfalls

  • Wrong strategy for the CMP. Using afterInteractive for the CMP means tracking scripts with the same strategy might fire first. The CMP always needs beforeInteractive.
  • CMP in a page component. If it loads in app/page.tsx instead of the root layout, navigating to any other route means no CMP.
  • Assuming cookies() is synchronous. Current Next.js versions require await cookies(). Without await, you get a Promise, not a cookie store. Same for headers().
  • Server-side consent as the sole check. The cookies() value is only as fresh as the last navigation. If the user changed consent client-side without navigating, the server value is stale. Always pair server checks with client-side gating.
  • Missing ssr: false on analytics components. Without it, the tracking script's initialization code runs during SSR and gets included in streamed HTML — before any consent check.

Implementation Checklist

Use this checklist before shipping your Next.js App Router application with cookie consent:

  • CMP script uses beforeInteractive strategy in root app/layout.tsx
  • Consent Mode v2 defaults are set before GTM loads (or CookieBeam handles this automatically)
  • All analytics/marketing scripts use afterInteractive or lazyOnload, never beforeInteractive
  • Consent-dependent components use next/dynamic with ssr: false inside Client Components
  • A ConsentProvider context (or CookieBeam callbacks) exposes consent state to the component tree
  • Server Actions that process tracking data verify consent via await cookies()
  • Middleware forwards consent state as headers for Server Components that need it
  • No tracking scripts render during SSR — all are client-only
  • Cookie audit completed to identify all cookies your Next.js app sets
  • Consent banner tested across route navigations (not just initial page load)

Further Reading

Next.js Cookie Consent with App Router: 2026 Developer Guide | CookieBeam | CookieBeam