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 viacookies()fromnext/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 needsdocumentaccess), 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 rootapp/layout.tsx. - GTM container:
afterInteractive. Needs consent defaults set first (viabeforeInteractiveinline 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
afterInteractivefor the CMP means tracking scripts with the same strategy might fire first. The CMP always needsbeforeInteractive. - CMP in a page component. If it loads in
app/page.tsxinstead of the root layout, navigating to any other route means no CMP. - Assuming
cookies()is synchronous. Current Next.js versions requireawait cookies(). Withoutawait, you get a Promise, not a cookie store. Same forheaders(). - 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: falseon 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
beforeInteractivestrategy in rootapp/layout.tsx - Consent Mode v2 defaults are set before GTM loads (or CookieBeam handles this automatically)
- All analytics/marketing scripts use
afterInteractiveorlazyOnload, neverbeforeInteractive - Consent-dependent components use
next/dynamicwithssr: falseinside Client Components - A
ConsentProvidercontext (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
- Cookie Consent for Single-Page Apps (React, Vue, Angular) — broader SPA patterns, route-change handling, and hydration timing
- Google Consent Mode v2: Complete Implementation Guide
- How to Block Scripts Until Cookie Consent — script blocking techniques across platforms
- Consent Mode v2: Advanced vs Basic
- Cookie Banner Performance & Core Web Vitals — relevant because
beforeInteractiveimpacts LCP timing - Next.js Script Component documentation