# Nextjs Cookie Consent: GDPR Banner and Consent Mode v2

> Build nextjs cookie consent that holds up under GDPR: equal Accept and Reject buttons, per-category switches, Consent Mode v2 and embeds that wait for consent.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 16, 2026
- Category: [Next.js and Tailwind CSS](https://wingo-ui.com/blog/category/nextjs-tailwind)
- Reading time: 12 min
- Canonical: https://wingo-ui.com/blog/nextjs-cookie-consent

## TL;DR

Next.js cookie consent that holds up under GDPR shows Accept all and Reject all as equal buttons on the first layer, keeps every optional category off until the visitor turns it on, and stores the choice with a policy version and a timestamp. Set every Google Consent Mode signal to denied in a beforeInteractive script in the root layout, then send gtag('consent', 'update', ...) from a client component once the choice is known. Keep third-party embeds such as YouTube behind a placeholder until their category is allowed.

You need a cookie banner before analytics can go live in the EU, and the quick version, one big Accept button next to a small "manage options" link, is the shape European regulators have flagged. This tutorial builds nextjs cookie consent that follows their guidance: Accept all and Reject all with equal weight, a settings panel with one switch per category, Google Consent Mode v2 values sent from the stored choice, and YouTube embeds that stay blocked until marketing cookies are allowed.

The code targets the App Router on Next.js 16, React 19 and Tailwind CSS v4. It uses the [Cookie Consent component](https://wingo-ui.com/components/cookie-consent) for the UI and the [useConsent hook](https://wingo-ui.com/components/use-consent) for the record behind it. Nothing here is legal advice: read the guidance of your own data protection authority, or ask a lawyer, for the details that apply to your site.

## What does GDPR actually require from a cookie banner?

A banner must get consent before any non-essential cookie is set, make rejecting as easy as accepting, and let the visitor change their mind later. Two laws meet here. The ePrivacy Directive (Article 5(3)) says you need consent before you store or read anything on the visitor's device, unless it is strictly necessary for the service they asked for. GDPR defines what valid consent is: freely given, specific, informed, a clear positive action, and as easy to withdraw as to give.

The most practical reading of those rules for a UI developer is the [EDPB cookie banner taskforce report](https://www.edpb.europa.eu/system/files/2023-01/edpb_20230118_report_cookie_banner_taskforce_en.pdf), adopted on January 17, 2023 to coordinate how authorities handle the cookie banner complaints filed by noyb. It lists the patterns authorities agreed on:

- **No reject button next to Accept.** A vast majority of authorities considered a banner with no reject option on any layer that has an accept button an infringement.
- **Pre-ticked boxes.** Categories that start switched on in the settings layer do not produce valid consent.
- **Reject hidden as a link.** A "continue without accepting" link buried in the text, without enough visual support, does not count as a real choice.
- **Deceptive contrast.** A reject button whose text contrast is so low that it is unreadable could be manifestly misleading.
- **"Essential" cookies that are not.** Some sites label cookies "strictly necessary" that serve other purposes. The site owner has to keep the cookie list and be able to show why each one is essential.
- **No way back.** Visitors need an easy, always available way to withdraw, such as a link in a standard place or a small floating icon.

Turned into a spec for nextjs gdpr cookie consent, that gives six requirements: Accept all and Reject all side by side at the same size, every optional category off by default, a settings layer with per-category switches, nothing optional loaded before the choice, a footer link that reopens the settings, and a record of what was chosen and when.

## How do you add a cookie consent banner to Next.js?

Install the Cookie Consent component and render it once in the root layout, next to a footer link that reopens the settings. The CLI also pulls in the `useConsent` hook and the items the banner builds on, such as Button, Switch, Dialog and Collapsible:

```bash
npx wingo-ui@latest add cookie-consent
```

Then add it to the root layout, after the page content:

```tsx
// app/layout.tsx
import type { ReactNode } from "react"
import { CookieConsent, CookieSettingsLink } from "@/components/ui/cookie-consent"
import "./globals.css"

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>
        {children}
        <footer className="flex justify-center py-8 pb-[calc(2rem+var(--cookie-consent-offset,0px))]">
          <CookieSettingsLink />
        </footer>
        <CookieConsent policyHref="/cookie-policy" delay={500} />
      </body>
    </html>
  )
}
```

That is a complete nextjs cookie banner. Here is what it does without extra props:

- It shows only for visitors who have not decided. The record lives in a `ui-consent` cookie for 180 days (`maxAgeDays`), and the bar renders after hydration, so a visitor who already chose never sees it flash.
- Accept all, Reject all and Settings are equal buttons. `tone` colors only the Accept button; Reject keeps a neutral soft look at the same size. On a phone they stack as three full-width 48px buttons.
- The bar is fixed to the bottom edge, so it never shifts the layout. It writes its visible height to `--cookie-consent-offset` on `<html>`, which is how the footer above keeps its link reachable while the bar is open.
- Settings opens a dialog on desktop and a full-height bottom sheet below 768px. Strictly necessary is locked on; the other switches start off.
- Every choice is announced to screen readers through a polite live region.

`CookieSettingsLink` calls `openConsentSettings()`, so the settings open from the footer of any page. That covers the "easy to withdraw" requirement with a link in a predictable place.

> Live demo (Cookie Consent): Press Settings, flip Analytics on and Marketing off, then Save choices: the bar slides away and the footer link brings the settings back. Try it at [Cookie Consent](https://wingo-ui.com/components/cookie-consent) and install it with `npx wingo-ui@latest add cookie-consent`.

The root layout is a server component, so you cannot pass `onDecide` from it: functions do not cross the server to client boundary. The next section reads the choice from a small client component instead.

## How do you build a React cookie consent banner with per-category switches?

Pass a `categories` list that matches what your site really sets: the settings panel renders one switch per entry and a "Show cookies" toggle that lists each cookie's name, provider, duration and purpose. The default list is Strictly necessary, Preferences, Analytics and Marketing. Keep your own list in a plain data file:

```tsx
// app/consent-config.ts
import type { ConsentCategoryConfig } from "@/components/ui/cookie-consent"

export const CONSENT_VERSION = 1

// plain data: server files import this, so it must not read values from a "use client" module
export const CONSENT_CATEGORIES: ConsentCategoryConfig[] = [
  {
    id: "necessary",
    label: "Strictly necessary",
    description: "Sign-in, security and remembering this choice.",
    required: true,
    cookies: [{ name: "ui-consent", provider: "acme.com", duration: "180 days", purpose: "Remembers your cookie choice" }],
  },
  { id: "preferences", label: "Preferences", description: "Language, theme and saved filters." },
  {
    id: "analytics",
    label: "Analytics",
    description: "Shows us how the site is used.",
    cookies: [{ name: "_ga", provider: "Google Analytics", duration: "2 years", purpose: "Tells visitors apart" }],
  },
  {
    id: "marketing",
    label: "Marketing",
    description: "Ads and embedded content such as YouTube videos.",
    cookies: [{ name: "VISITOR_INFO1_LIVE", provider: "YouTube", duration: "6 months", purpose: "Video playback" }],
  },
]
```

List the cookies your own audit finds; the ones above are examples. Write the array out instead of mapping over `DEFAULT_CONSENT_CATEGORY_CONFIG`: the layout is a server component, and in a server file every export of a `"use client"` module is a client reference. You can pass a reference through as a prop, but `DEFAULT_CONSENT_CATEGORY_CONFIG.map(...)` throws "You cannot dot into a client module from a server component" when the layout renders. The `import type` above is erased at build time, so it is safe.

Then pass the list and the version to the banner:

```tsx
<CookieConsent
  categories={CONSENT_CATEGORIES}
  version={CONSENT_VERSION}
  policyHref="/cookie-policy"
  delay={500}
/>
```

`version` is the policy version. When you add a category or a new vendor, raise it: records saved under another version count as undecided, so the bar comes back and asks again. Use the same constant everywhere you read consent, because `useConsent`, `ConsentGate` and the banner each take `version` and only agree when it matches.

A few props worth knowing for a React cookie consent banner:

| Prop | Default | What it changes |
|---|---|---|
| `variant` | `"bar"` | `"card"` is a 400px floating card instead of a full-width bar |
| `showReject` | `true` | Reject all on the first layer; keep it on in the EU |
| `blocking` | `false` | Dims the page and traps focus until a choice; use only where legally advised |
| `storage` | `"cookie"` | `"local"` or `"session"` instead of a cookie |
| `labels` | English copy | Every string; `COOKIE_CONSENT_LABELS_RO` is ready Romanian copy |

The full list, with the playground for each, is on the [cookie consent docs page](https://wingo-ui.com/components/cookie-consent). The switches in the settings panel are the same [Switch](https://wingo-ui.com/components/switch) the rest of the library uses, and the panel is a [Dialog](https://wingo-ui.com/components/dialog) that turns into a sheet on phones.

## How do you set up Google Consent Mode v2 in Next.js?

Set every consent signal to denied in an inline script that runs before any Google tag, then send an update from the stored choice. Google's [consent mode developer guide](https://developers.google.com/tag-platform/security/guides/consent) (last updated October 13, 2026, checked October 16, 2026) lists `ad_storage` and `analytics_storage` plus the two parameters consent mode v2 added, `ad_user_data` and `ad_personalization`. The `gtag('consent', 'default', ...)` call must come before any command that sends measurement data, such as `config` or `event`; the guide warns that defaults do not work when the consent code runs out of order.

Google's [consent mode overview](https://developers.google.com/tag-platform/security/concepts/consent-mode) (last updated July 30, 2026, checked October 16, 2026) describes two setups. In basic consent mode, Google tags do not load until the visitor interacts with the banner, and stay blocked without consent. In advanced consent mode, the tags load on page open and send measurements without cookies while consent is denied. Basic is the stricter reading of "nothing optional before the choice", so the google consent mode v2 nextjs setup below uses it.

### Step 1: set the defaults before anything else

An inline `next/script` with `strategy="beforeInteractive"` is written into the initial HTML, and the Next.js bootstrap runs it before hydration, so it always runs before any client component. The Next.js docs require this strategy to live in the root layout, and an inline script needs an `id`. Because it runs as a classic script, `function gtag()` becomes a global that `window.gtag` can call later:

```tsx
// app/consent-defaults.ts
export const CONSENT_DEFAULTS = `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('consent', 'default', {
  ad_storage: 'denied',
  ad_user_data: 'denied',
  ad_personalization: 'denied',
  analytics_storage: 'denied',
  functionality_storage: 'denied',
  personalization_storage: 'denied',
  security_storage: 'granted'
});`
```

The script goes into the body of the root layout. Step 2 below fills in `GoogleTag`; this is the final layout:

```tsx
// app/layout.tsx
import type { ReactNode } from "react"
import Script from "next/script"
import { CookieConsent, CookieSettingsLink } from "@/components/ui/cookie-consent"
import { CONSENT_CATEGORIES, CONSENT_VERSION } from "./consent-config"
import { CONSENT_DEFAULTS } from "./consent-defaults"
import { GoogleTag } from "./google-tag"
import "./globals.css"

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>
        <Script id="consent-defaults" strategy="beforeInteractive">
          {CONSENT_DEFAULTS}
        </Script>
        {children}
        <footer className="flex justify-center py-8 pb-[calc(2rem+var(--cookie-consent-offset,0px))]">
          <CookieSettingsLink />
        </footer>
        <CookieConsent categories={CONSENT_CATEGORIES} version={CONSENT_VERSION} policyHref="/cookie-policy" delay={500} />
        <GoogleTag />
      </body>
    </html>
  )
}
```

### Step 2: send the update and load the tag from the stored choice

`toGoogleConsentMode(choices)` from the hook maps the categories to Consent Mode values: analytics to `analytics_storage`, marketing to the three ad signals, preferences to functionality and personalization, and `security_storage` always granted. A small client component sends the update every time the choice changes and loads `gtag.js` only once analytics or marketing is allowed:

```tsx
// app/google-tag.tsx
"use client"

import { useEffect } from "react"
import { toGoogleConsentMode, useConsent } from "@/hooks/use-consent"
import { CONSENT_VERSION } from "./consent-config"

declare global {
  interface Window {
    dataLayer: unknown[]
    gtag: (...args: unknown[]) => void
  }
}

const GA_ID = process.env.NEXT_PUBLIC_GA_ID
let loaded = false

export function GoogleTag() {
  const { hydrated, choices } = useConsent({ version: CONSENT_VERSION })

  useEffect(() => {
    if (!hydrated || !GA_ID || typeof window.gtag !== "function") return
    // the queue keeps order: default, then update, then js and config
    window.gtag("consent", "update", toGoogleConsentMode(choices))
    if (loaded || !(choices.analytics || choices.marketing)) return
    loaded = true
    window.gtag("js", new Date())
    window.gtag("config", GA_ID)
    const script = document.createElement("script")
    script.async = true
    script.src = `https://www.googletagmanager.com/gtag/js?id=${GA_ID}`
    document.head.appendChild(script)
  }, [hydrated, choices])

  return null
}
```

With `<GoogleTag />` in the layout next to the banner, the order is fixed. Because the commands go through the `dataLayer` queue, `gtag.js` processes them in order when it arrives: denied defaults, the visitor's real values, then the config. A returning visitor who accepted gets the update on the first render after hydration, with no banner.

`choices` is memoized from the stored record, so the effect runs once on load and again after each decision. When a visitor withdraws, the same effect sends `denied` and the tag stops using its cookies. Cookies it already set stay in the browser until they expire; if your policy promises to remove them, clear them in an `onConsentChange` listener from the hook.

If you choose advanced consent mode instead, load `gtag.js` unconditionally and add `wait_for_update: 500` to the defaults. Google's guide uses that parameter to give a banner that loads asynchronously a number of milliseconds to send its update before tags send data.

## How do you block YouTube embeds until consent is given?

Wrap the iframe in `ConsentGate`, so it is not in the HTML until marketing is allowed. An embedded player is a third-party page that can read and write its own storage as soon as it loads, and Article 5(3) covers any storage access, not only cookies, so treat the embed as marketing even on youtube-nocookie.com unless your own audit says otherwise. `ConsentGate` renders its children only when the category is on, renders nothing during hydration, and otherwise shows a placeholder with an "Allow and load" button that turns that one category on:

```tsx
// app/page.tsx
import { ConsentGate } from "@/components/ui/cookie-consent"
import { CONSENT_CATEGORIES, CONSENT_VERSION } from "./consent-config"

export default function Page() {
  return (
    <ConsentGate category="marketing" categories={CONSENT_CATEGORIES} version={CONSENT_VERSION} className="aspect-video">
      <iframe
        title="Acme product tour"
        src="https://www.youtube-nocookie.com/embed/abc123"
        className="aspect-video w-full rounded-2xl"
        allowFullScreen
      />
    </ConsentGate>
  )
}
```

The page stays a server component; only the gate is a client component, and the iframe passes through as children. The placeholder names the category from your config ("This content uses marketing cookies") and explains that the visitor can change it later in the cookie settings. Pass `fallback` for your own placeholder, such as a thumbnail with a play button.

For your own components, the hook gives you the same check. `has("marketing")` is true only after a decision that allowed it, and `save({ ...choices, marketing: true })` turns one category on without touching the others.

> Live demo (useConsent): Flip the switches and press Save choices: the Consent Mode values below update, and the video slot loads only once Marketing is on. Try it at [useConsent](https://wingo-ui.com/components/use-consent) and install it with `npx wingo-ui@latest add use-consent`.

## What does the stored consent record look like?

The record is a small JSON object with the policy version, the choices, an ISO timestamp and the method, which is what you need to show that consent was given (GDPR Article 7(1)). The hook stores one record per site, readable as `record` from `useConsent` or with `getConsent()` outside React:

```json
{
  "version": 1,
  "choices": { "necessary": true, "preferences": false, "analytics": true, "marketing": false },
  "decidedAt": "2026-10-16T09:41:12.000Z",
  "method": "custom"
}
```

`method` is `accept-all`, `reject-all` or `custom`. If you need a server-side log, send the record to your API in an `onConsentChange` listener; the cookie itself only proves what this browser holds.

Because the default storage is a cookie, server code can read the same record. Next.js already URL-decodes cookie values, so `JSON.parse` is enough. Compare the version yourself, the way the hook does:

```ts
// app/server-consent.ts
import { cookies } from "next/headers"
import type { ConsentRecord } from "@/hooks/use-consent"
import { CONSENT_VERSION } from "./consent-config"

export async function readConsent(): Promise<ConsentRecord | null> {
  const raw = (await cookies()).get("ui-consent")?.value
  if (!raw) return null
  try {
    const record = JSON.parse(raw) as ConsentRecord
    return record.version === CONSENT_VERSION ? record : null
  } catch {
    return null
  }
}
```

Calling `cookies()` makes the route dynamic, so use this in a route handler or a page that is dynamic anyway, not in the root layout of a static site.

Pitfalls to check before you ship:

- **Rendering the banner on the server.** A static page cannot know the choice, so a server-rendered banner flashes for everyone who already decided. Gate it on `hydrated`; the component does. The [next-themes hydration error post](https://wingo-ui.com/blog/next-themes-hydration-error) explains the same mismatch for themes.
- **Loading analytics in the layout "for now".** A plain `<Script src=".../gtag/js">` in the layout runs before any choice. Load it from the consent state instead.
- **Forgetting the phone.** A settings dialog with four categories and cookie tables does not fit a 390px screen, which is why the panel becomes a bottom sheet below 768px. The [responsive dialog post](https://wingo-ui.com/blog/shadcn-responsive-dialog-on-mobile) covers that pattern.
- **Mismatched versions.** If the banner uses version 2 and `GoogleTag` uses the default 1, the tag reads the visitor as undecided. Keep one constant.
- **Function props from the layout.** `onDecide` and the function labels (`showCookies`, `gateTitle`) cannot cross from a server layout. Override only the string labels there, or wrap the banner in a small client component that owns the functions.

## What should you do next?

Install the banner with `npx wingo-ui@latest add cookie-consent`, add the defaults script and the `GoogleTag` component, and wrap every third-party embed in `ConsentGate`. Both the component and the hook are part of Wingo UI Pro; the [pricing page](https://wingo-ui.com/pricing) lists what each plan includes. More App Router and Tailwind v4 guides live in [Next.js and Tailwind CSS](https://wingo-ui.com/blog/category/nextjs-tailwind), starting with the [Tailwind v4 design tokens guide](https://wingo-ui.com/blog/tailwind-v4-design-tokens) that the banner's colors come from.

## Components in this post

- [Cookie Consent](https://wingo-ui.com/components/cookie-consent) (Pro): A React cookie consent banner for GDPR: a bottom bar or card with Accept, Reject and Settings, per-category switches, and a gate for embeds. Install: `npx wingo-ui@latest add cookie-consent`
- [useConsent](https://wingo-ui.com/components/use-consent) (Pro): A React cookie consent hook: which categories a visitor allowed, when and under which policy version, shared by banner, links and scripts. Install: `npx wingo-ui@latest add use-consent`

## FAQ

### Does a cookie banner need a Reject all button?

In the EU, regulators treat a missing reject option on the layer that has the accept button as a problem. The EDPB cookie banner taskforce report of January 17, 2023 says a vast majority of authorities considered it an infringement, so put Reject all next to Accept all at the same size.

### How do I set up Google Consent Mode v2 in Next.js?

Put an inline next/script with strategy beforeInteractive in app/layout.tsx that defines gtag and calls gtag('consent', 'default', ...) with ad_storage, ad_user_data, ad_personalization and analytics_storage set to denied. Then call gtag('consent', 'update', ...) from a client component as soon as the visitor decides, and on every later visit once the stored choice is read.

### Can I store cookie consent in localStorage instead of a cookie?

Yes: useConsent and CookieConsent accept storage local or session. The default cookie has two advantages: server code can read it with cookies() from next/headers, and it expires on its own, so the visitor is asked again after maxAgeDays (180 by default).

### Why does my cookie banner flash for visitors who already chose?

A static page cannot know the stored choice, so a banner rendered on the server shows for everyone until the client reads storage. Render the banner only after hydration and only while the visitor is undecided, which is what the hydrated flag of useConsent is for.

### Are strictly necessary cookies exempt from consent?

Cookies that the service needs to work, such as a session cookie or the cookie that stores the consent choice itself, do not need consent under the ePrivacy rules. Labeling analytics or ad cookies as essential does not make them exempt, and the EDPB taskforce report lists misclassified essential cookies among the practices authorities reviewed.

---

Source: https://wingo-ui.com/blog/nextjs-cookie-consent
