TCF 2.3 became mandatory on February 28, 2026, and CMPs that haven't updated still produce non-compliant TC strings being rejected by certified SSPs right now. Meanwhile, CNIL fined Google €325 million in September 2025 specifically for consent implementation failures.
Your team shipped the banner. QA passed it. But if GA4 fires on the first render before a returning visitor's stored consent is applied, the implementation is non-compliant regardless of what the banner looks like.
Implementing GDPR-compliant cookie consent in a Next.js or React app requires more than dropping in a banner component: you must default all non-essential tracking to denied before any scripts fire, avoid hydration mismatches that cause banners to flash or fail silently, wire Google Consent Mode v2 update commands correctly, and correctly surface a valid TCF 2.3 TC string to ad tech vendors through the window.__tcfapi interface.
Key takeaways
- The single most common implementation mistake is loading analytics or ad scripts before consent state is resolved. Under GDPR, non-essential processing cannot begin until the user has actively granted consent — "default denied" is not optional, it is the law.
- Hydration mismatches are the most frequent React-specific failure mode. If your banner reads from localStorage during server render, Next.js will throw a hydration error because server and client HTML won't match. Consent state must be read inside useEffect or a client component boundary only.
- Google Consent Mode v2 requires two calls in sequence: gtag('consent', 'default', {...}) with all categories set to 'denied' before any Google tags fire, and gtag('consent', 'update', {...}) with granted categories once the user interacts with the banner.
- The Next.js App Router and Pages Router require different approaches. With App Router, consent state read from cookies in Server Components can suppress the banner render server-side, avoiding the client-side flash entirely. With Pages Router, the pattern is client-only via useEffect.
- TCF 2.3 (mandatory from February 28, 2026) requires the disclosedVendors segment in every TC string. If you use a third-party CMP that generates TCF strings, verify it has been updated for TCF 2.3, older CMPs still produce non-compliant strings.
Why React and Next.js need special handling
A standard cookie consent banner works by setting a cookie or localStorage entry when the user makes a choice, then checking that value on subsequent page loads to suppress the banner. In a server-rendered React app, this creates two problems.
Hydration mismatch: The server renders HTML without access to the browser's localStorage or cookies that store consent state. If the client-rendered output differs (e.g., banner visible on server, hidden on client because consent was already given), React throws a hydration error and the page may fail to become interactive.
Script initialization race: Third-party scripts loaded through <Script> tags with strategy="afterInteractive" or strategy="lazyOnload" can fire before the consent banner has been shown and a decision made, especially on fast connections or when the banner has a loading delay. Under GDPR, this is a violation regardless of how quickly it happens.
Both problems have known solutions, but they must be addressed explicitly, they are not handled automatically by any framework or CMP's default configuration.
If you need a consent management platform that handles these React-specific issues automatically, Secure Privacy's Cookie & Consent Solution includes a Next.js SDK that manages Consent Mode defaults, hydration safety, and TCF 2.3 string generation without custom code. The sections below explain the architecture so you know what a CMP handles on your behalf — and so you can verify or replicate it if you prefer to build your own.
Core architecture: consent-first script loading
The correct loading order for any Next.js application with Google Analytics or ad tech:
- Consent default: Before any Google tag fires, set all categories to denied.
- CMP loads: Your consent banner initializes and checks for a stored decision.
- If prior consent exists: Update consent state to granted for the relevant categories before user sees the banner.
- If no prior consent: Show the banner. On user action, call the update command.
- Google tags fire: Only after consent state is resolved to granted for the relevant category.
This order is enforced by where you place your <Script> tags and what strategy prop you use.
In Next.js App Router (app/layout.tsx):
import Script from 'next/script'
export default function RootLayout({ children }) {
return (
<html>
<head>
{/* Step 1: consent defaults, must be inline, before any gtag script */}
<script
dangerouslySetInnerHTML={{
__html: `
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',
wait_for_update: 500
});
gtag('js', new Date());
`
}}
/>
</head>
<body>
{children}
{/* Step 2: GA script loads after consent defaults are set */}
<Script
src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"
strategy="afterInteractive"
/>
</body>
</html>
)
}The inline <script> in <head> runs synchronously before any other scripts. The wait_for_update: 500 tells Google to hold for 500ms for a consent update before modeling behavior, useful for returning visitors whose stored consent is about to be read and applied.
Avoiding hydration mismatch
The banner component must be a Client Component ('use client') and must defer its consent-state read to after mount:
'use client'
import { useState, useEffect } from 'react'
export function ConsentBanner() {
const [showBanner, setShowBanner] = useState(false)
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
const stored = localStorage.getItem('consent_decision')
if (!stored) {
setShowBanner(true)
} else {
// Apply stored consent on load
const decision = JSON.parse(stored)
applyConsentDecision(decision)
}
}, [])
// Render nothing until mounted, prevents hydration mismatch
if (!mounted) return null
if (!showBanner) return null
return (
<div role="dialog" aria-label="Cookie consent">
{/* Banner UI */}
</div>
)
}The if (!mounted) return null guard is the critical line. The server renders nothing for this component (because mounted is always false on the server); the client renders the banner after mount if no consent decision is stored. Server and client initial HTML match: no hydration error.
If you're using Secure Privacy, this component ships with the SDK. You register it in your layout and skip writing the mounting guard and storage logic entirely.
Storage choice: localStorage is simpler but unavailable in Server Components and inaccessible to middleware. An HttpOnly-safe cookie named something like consent_decision can be read in both middleware and Server Components via the cookies() API, useful for the App Router server-side suppression pattern below.
App Router: server-side banner suppression
With the Next.js App Router, you can read a consent cookie in your root layout Server Component and pass a hasConsent prop to the banner, allowing the server to suppress the banner on the initial HTML render for returning visitors:
// app/layout.tsx (Server Component)
import { cookies } from 'next/headers'
import { ConsentBanner } from '@/components/ConsentBanner'
export default async function RootLayout({ children }) {
const cookieStore = await cookies()
const consentCookie = cookieStore.get('consent_decision')
const hasStoredConsent = !!consentCookie?.value
return (
<html>
<body>
{children}
{/* Pass server-read consent state to client component */}
<ConsentBanner initialHasConsent={hasStoredConsent} />
</body>
</html>
)
}In the ConsentBanner Client Component, accept initialHasConsent as a prop and use it as the initial useState value. This eliminates the banner flash on returning visits entirely, the server already knows consent was given and the component initializes with showBanner: false.
Test this pattern in an incognito window before deployment, open DevTools → Application → Cookies before touching the banner. If _ga or _gid appear before you interact, the default is not firing.
One constraint: you cannot call cookies().set() in a Server Component directly. Setting the consent cookie must happen in a Server Action or Route Handler:
// app/actions/consent.ts
'use server'
import { cookies } from 'next/headers'
export async function saveConsentDecision(decision: ConsentDecision) {
const cookieStore = await cookies()
cookieStore.set('consent_decision', JSON.stringify(decision), {
maxAge: 60 * 60 * 24 * 365, // 1 year
httpOnly: false, // must be readable by client JS for Consent Mode update
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production'
})
}Secure Privacy's SDK manages cookie writing and the server-side suppression pattern as part of its Next.js integration.
Wiring Google Consent Mode v2 update
When the user clicks "Accept all" or makes granular choices, you need to call gtag('consent', 'update', {...}). The update must happen before any tag fires its first hit. The wait_for_update: 500 in the default gives you the window:
function applyConsentDecision(decision: ConsentDecision) {
if (typeof window.gtag !== 'function') return
window.gtag('consent', 'update', {
analytics_storage: decision.analytics ? 'granted' : 'denied',
ad_storage: decision.advertising ? 'granted' : 'denied',
ad_user_data: decision.advertising ? 'granted' : 'denied',
ad_personalization: decision.advertising ? 'granted' : 'denied',
})
}Call applyConsentDecision in two places:
- On banner accept/reject: immediately after the user makes a choice.
- On page load for returning visitors: inside the useEffect that reads stored consent, before the banner state is determined.
The second call ensures that on a returning visitor's second page view, consent is applied programmatically in the same 500ms window, otherwise GA4 defaults to denied on every new page load until the update fires.
For TypeScript users, declare the gtag function on window in a global type definition file to avoid type errors:
// types/gtag.d.ts
declare global {
interface Window {
gtag: (...args: unknown[]) => void
dataLayer: unknown[]
}
}Secure Privacy's SDK fires both the default and update calls in the correct sequence automatically. If you're wiring Consent Mode manually, the sequence above is what you're implementing.
TCF 2.3 integration for programmatic advertising
If your app serves programmatic ads through the IAB Transparency and Consent Framework, your CMP must expose the window.__tcfapi function so ad tech vendors can read the TC string. In a React SPA, the right way to listen for TCF events is through the addEventListener command, not by reading window.__tcfapi('getTCData', ...) directly, which can fail if the CMP hasn't initialized yet:
useEffect(() => {
if (typeof window.__tcfapi !== 'function') return
window.__tcfapi('addEventListener', 2, (tcData, success) => {
if (!success) return
if (tcData.eventStatus === 'tcloaded' || tcData.eventStatus === 'useractioncomplete') {
// tcData.tcString contains the encoded TC string
// Pass to your ad serving layer here
console.log('TC string ready:', tcData.tcString)
}
})
}, [])TCF 2.3 requirement: From February 28, 2026, every TC string must include the disclosedVendors segment indicating which vendors were actually shown in the CMP UI. This is enforced at the CMP level, your CMP vendor must have updated to TCF 2.3. You can verify by checking tcData.tcString decodes with a disclosedVendors bitfield present using the IAB TCF decoder.
For consent management platform vendors that generate TCF strings: confirm TCF 2.3 certification with your vendor before the February deadline if you have not already, strings generated by non-certified CMPs will be rejected by certified SSPs and DSPs in the programmatic supply chain.
Verify your TCF string includes the disclosedVendors segment using the IAB decoder before your next production deploy. A five-minute check now avoids a supply chain rejection that can cut programmatic revenue to zero.
Secure Privacy is a TCF 2.3 certified CMP. The disclosedVendors segment is generated by the SDK, not your application code — if you're using SP, the check above is verification rather than a build obligation.
Pages Router pattern
For apps still on the Pages Router (pages/_app.tsx), the pattern is client-only since there is no Server Component layer:
// pages/_app.tsx
import { useEffect } from 'react'
import type { AppProps } from 'next/app'
import { ConsentBanner } from '@/components/ConsentBanner'
export default function App({ Component, pageProps }: AppProps) {
useEffect(() => {
// Apply stored consent on every page navigation
const stored = localStorage.getItem('consent_decision')
if (stored) {
const decision = JSON.parse(stored)
applyConsentDecision(decision)
}
}, [])
return (
<>
<Component {...pageProps} />
<ConsentBanner />
</>
)
}Place the consent default gtag calls in pages/_document.tsx inside a <script> tag in <Head>, this is the Pages Router equivalent of the inline script approach above, and it runs before any <Script strategy="afterInteractive"> tags.
Secure Privacy's SDK supports both App Router and Pages Router under the same integration.
Using a third-party CMP with Next.js
If you use a third-party CMP rather than building consent logic from scratch, the integration pattern is the same but the CMP's script load strategy matters:
- Load the CMP script with strategy="beforeInteractive" if the CMP handles Google Consent Mode defaults internally. This ensures it runs before any other scripts and sets defaults before GA fires.
- Do not use strategy="lazyOnload" for a CMP, this can allow other scripts to initialize first.
- Verify the CMP sets gtag('consent', 'default', ...) before its own banner JavaScript: some CMPs fire the default after their own initialization, which can create a window where GA fires without consent state.
Check your implementation by opening Chrome DevTools → Application → Cookies before interacting with the banner. No non-essential cookies (_ga, _gid, _fbp, ad network cookies) should exist before you click "Accept."
Run this check on every deploy that touches your <Script> tag order or layout files, a dependency update can silently change script load order and break your consent-first architecture.
Secure Privacy's Next.js SDK loads with strategy="beforeInteractive" and sets consent defaults before its banner script runs, handling the load-order requirements above automatically.
Common mistakes and how to fix them
Banner flashes on every load for returning visitors Cause: consent state is read inside useEffect, so the server renders the banner visible, the client immediately hides it on mount. Fix: use the App Router server-side suppression pattern, or set the initial showBanner state to false and only set it true inside the useEffect after confirming no stored decision exists. Secure Privacy's SDK eliminates this flash by default.
Consent Mode shows "denied" in Tag Assistant even after accepting Cause: the gtag('consent', 'update', ...) call is not firing, or is firing after the GA hit. Fix: check that applyConsentDecision is called synchronously in the banner's onClick handler before any other side effects. Check that the gtag function is defined on window at the time of the call.
TCF string not available to ad vendors Cause: the CMP script loads after the ad library that calls window.__tcfapi. Fix: load the CMP with a higher priority script strategy, or use the addEventListener pattern which queues until the CMP is ready.
Consent not persisting across navigations in App Router Cause: localStorage reads in useEffect only run client-side, and with App Router's default prefetching behavior, navigation can render a Server Component that doesn't carry the consent cookie. Fix: set the consent decision as an HttpOnly-safe cookie (readable by middleware and Server Components) in addition to localStorage, and read it in the root layout as shown above. Secure Privacy's SDK handles cross-navigation persistence as part of its App Router integration.
Frequently asked questions
Do I need a cookie banner for a Next.js app that only uses localStorage and no cookies?
Yes, if the stored data is used for non-essential purposes (analytics, advertising, personalization). GDPR and ePrivacy Directive obligations apply to any storage mechanism used for non-essential processing, not just HTTP cookies. localStorage, sessionStorage, IndexedDB, and fingerprinting all require consent if used for non-essential purposes.
Can I use next/headers cookies() to read consent state in a Server Component?
Yes, if you set the consent decision as a regular (non-HttpOnly) cookie accessible to JavaScript. The cookies() function in Next.js App Router reads all cookies sent with the request, including ones set by client-side JavaScript, as long as they are not HttpOnly.
Does Consent Mode v2 mean I don't need a cookie banner?
No. Consent Mode v2 is a signal-passing mechanism that tells Google how to model behavior when consent is denied, it does not replace the requirement to obtain consent. You still need a GDPR-compliant consent banner; Consent Mode v2 is what you use to communicate the user's decision to Google's tags.
What's the difference between strategy="beforeInteractive" and an inline script for consent defaults?
next/script with strategy="beforeInteractive" loads the script before the page becomes interactive but after the HTML is parsed, which can still be after some other scripts depending on the page. An inline <script> in <head> runs synchronously during HTML parsing, guaranteed to run before anything else. For Google Consent Mode defaults, the inline approach is safer.
Our app is a single-page application with client-side routing. Do we need to re-check consent on every route change?
You do not need to re-show the banner on every route change. Consent is granted globally for the session/period, not per-page. However, gtag('consent', 'update', ...) should fire once on app initialization (to apply stored consent) and once when the user makes a new choice. Client-side route changes do not require a new consent check or update call.
We use React without Next.js (Create React App or Vite). Does this guidance apply?
Most of it applies. The hydration mismatch issue is Next.js-specific (CRA and Vite are client-side only, so there is no server/client mismatch). The Google Consent Mode default/update pattern, TCF API integration, and script loading order guidance apply identically to any React app.
Getting consent architecture right in a React or Next.js app is an engineering problem with legal consequences. Everything in this guide — the hydration-safe banner component, Consent Mode v2 default and update sequencing, TCF 2.3 string generation, and server-side banner suppression — is what Secure Privacy's Cookie & Consent Solution handles for you. If your team would rather integrate with a tested SDK than build and maintain this infrastructure, the result is the same compliance outcome with significantly less custom code.




