- How I Built a GDPR-Compliant Cookie Consent System in Next.js 15
How I Built a GDPR-Compliant Cookie Consent System in Next.js 15
A complete guide to building a custom cookie consent banner in Next.js 15—supporting both server and client-side consent handling, perfect for GDPR compliance.

Recently, I was deep into building a headless e-commerce shop with Shopify. Everything was modern and lightning-fast, but then I hit a common wall: cookie consent. I needed to keep my store GDPR-compliant, but all the hosted solutions I found wanted a monthly subscription. Instead of adding another bill, I decided to build my own cookie consent system in Next.js.
During this project, I learned a big lesson: you can solve cookie consent in two ways—on the server or on the client. Each comes with trade-offs. Below, I’ll show you both, so you can choose the one that fits your project best. In the end, you’ll have a full-featured solution: a customizable banner, advanced options, and a way for users to edit preferences anytime.
Why Cookie Consent Matters
If you use analytics, ads, or tracking of any kind, laws like GDPR and CCPA require you to get explicit user consent. That means you should not load non-essential scripts (for example, Google Analytics or Tag Manager) until the user accepts. A good consent system also lets users change their minds later.
Two Approaches
- Server-side: Cookie reading and logic runs on every request. Easy to start, but disables static rendering.
- Client-side: Cookie logic happens in the browser. Supports static generation and better performance, but is a bit trickier.
1. Server-Side Cookie Consent (Simple but Dynamic)
This approach is easy to grasp. It reads the consent cookie on the server, before rendering your page. That means you can instantly show or hide analytics and the consent banner. There’s just one catch: this makes your entire app rendered dynamically. Static optimization is lost. If you’re fine with that—this is for you.
File Structure Example
app/ ├── layout.tsx │ ├── cookie-settings/ │ └── page.tsx │ components/ ├── cookie-consent/ │ ├── cookie-banner.tsx │ ├── banner-controller.tsx │ └── cookie-settings-page.tsx └── analytics/ └── ga4-consent.tsx lib/ ├── cookie-utils.ts └── cookie-config.ts actions/ └── cookie-consent.ts
Step 1: Cookie Config Basics
First, we create a config file that holds the names, version, and settings used for our cookie consent. This gives us a single place to update all consent-related constants later, like expiration length or the cookie’s structure.
// lib/cookie-config.ts
export const CONSENT_COOKIE_NAME = "cookie_consent";
export const CONSENT_DURATION = 12 * 30 * 24 * 60 * 60 * 1000; // 12 months in milliseconds
export const CONSENT_VERSION = "1.0";
export const DEFAULT_CONSENT = {
essential: true,
analytics: false,
consentGiven: false,
timestamp: Date.now(),
version: CONSENT_VERSION,
};
What this does:
- Sets the name, expiration, and version for your consent cookie.
- Defines the default consent state when a new user arrives.
- This config will be imported by all other files to keep everything in sync.
Step 2: Read and Validate Consent on the Server
Next, we build utility functions that let us check and validate the consent cookie from server-side code. This includes getting the value, checking expiration, and knowing whether to show analytics or the cookie banner.
// lib/cookie-utils.ts
import { cookies } from "next/headers";
import {
CONSENT_COOKIE_NAME,
CONSENT_DURATION,
CONSENT_VERSION,
} from "./cookie-config";
export interface CookieConsent {
essential: boolean;
analytics: boolean;
consentGiven: boolean;
timestamp: number;
version: string;
}
export async function getCookieConsent(): Promise<CookieConsent | null> {
try {
const cookieStore = await cookies();
const consentCookie = cookieStore.get(CONSENT_COOKIE_NAME);
if (!consentCookie) return null;
const consent: CookieConsent = JSON.parse(consentCookie.value);
if (
typeof consent !== "object" ||
typeof consent.essential !== "boolean" ||
typeof consent.analytics !== "boolean" ||
typeof consent.consentGiven !== "boolean" ||
typeof consent.timestamp !== "number" ||
typeof consent.version !== "string"
) {
return null;
}
return consent;
} catch (error) {
console.error("Error parsing cookie consent:", error);
return null;
}
}
export async function hasValidConsent(): Promise<boolean> {
const consent = await getCookieConsent();
if (!consent || !consent.consentGiven) return false;
const isExpired = Date.now() - consent.timestamp > CONSENT_DURATION;
if (isExpired) return false;
return consent.version === CONSENT_VERSION;
}
export async function shouldShowAnalytics(): Promise<boolean> {
const consent = await getCookieConsent();
const hasConsent = await hasValidConsent();
return hasConsent && consent?.analytics === true;
}
export async function shouldShowBanner(): Promise<boolean> {
return !(await hasValidConsent());
}
What this does:
- Reads the cookie safely on the server.
- Returns the consent state or
null
if missing/broken/expired. - Makes it easy to decide whether to show analytics or the consent banner before page render.
Step 3: Server Actions to Save Consent
We need backend handlers to actually store consent choices. These "actions" set the cookie and define different scenarios: accept all, reject all, or save a custom choice. They will be called from the UI.
// actions/cookie-consent.ts
"use server";
import { cookies } from "next/headers";
import {
CONSENT_COOKIE_NAME,
CONSENT_DURATION,
CONSENT_VERSION,
} from "@/lib/cookie-config";
const COOKIE_OPTIONS = {
httpOnly: false,
secure: process.env.NODE_ENV === "production",
sameSite: "lax" as const,
path: "/",
maxAge: CONSENT_DURATION / 1000,
};
export async function acceptAllCookies() {
const consent = {
essential: true,
analytics: true,
consentGiven: true,
timestamp: Date.now(),
version: CONSENT_VERSION,
};
const cookieStore = await cookies();
cookieStore.set(CONSENT_COOKIE_NAME, JSON.stringify(consent), COOKIE_OPTIONS);
}
export async function rejectAllCookies() {
const consent = {
essential: true,
analytics: false,
consentGiven: true,
timestamp: Date.now(),
version: CONSENT_VERSION,
};
const cookieStore = await cookies();
cookieStore.set(CONSENT_COOKIE_NAME, JSON.stringify(consent), COOKIE_OPTIONS);
}
export async function saveCustomConsent(analytics: boolean) {
const consent = {
essential: true,
analytics,
consentGiven: true,
timestamp: Date.now(),
version: CONSENT_VERSION,
};
const cookieStore = await cookies();
cookieStore.set(CONSENT_COOKIE_NAME, JSON.stringify(consent), COOKIE_OPTIONS);
}
What this does:
- Each function sets the consent cookie with the selected options.
- By using separate functions, it’s simple to wire into three buttons: Accept All, Reject All, or use a custom choice.
- This makes it easy to call the right logic from the frontend when needed.
Step 4: Server-Side Banner Controller
The banner controller decides whether to render the cookie consent banner before the page is hydrated. Placing this logic server-side means it will show or not show the banner without flashing on screen.
// components/cookie-consent/banner-controller.tsx
import { shouldShowBanner } from "@/lib/cookie-utils";
import { CookieBanner } from "./cookie-banner";
/**
* WARNING: Uses dynamic rendering.
*/
export async function BannerController() {
const showBanner = await shouldShowBanner();
return <CookieBanner showBanner={showBanner} />;
}
What this does:
- Checks on the server if the consent banner should be visible on page load.
- Renders
<CookieBanner showBanner={...} />
with the correct prop. - Helps avoid showing the banner after hydration if not needed.
Step 5: Cookie Banner Component
Now we build the actual consent banner. This shows three buttons. It will trigger the server actions you made before. This component is a client component so it can use hooks and handle user clicks.
// components/cookie-consent/cookie-banner.tsx
"use client";
import { useState, useTransition } from "react";
import { acceptAllCookies, rejectAllCookies } from "@/actions/cookie-consent";
import { CookieSettings } from "./cookie-settings";
export function CookieBanner({ showBanner }) {
const [showSettings, setShowSettings] = useState(false);
const [isPending, startTransition] = useTransition();
if (!showBanner) return null;
const handleAcceptAll = () => {
startTransition(async () => {
await acceptAllCookies();
window.location.reload();
});
};
const handleRejectAll = () => {
startTransition(async () => {
await rejectAllCookies();
window.location.reload();
});
};
if (showSettings) {
return (
<CookieSettings
onBack={() => setShowSettings(false)}
onClose={() => setShowSettings(false)}
/>
);
}
return (
<div>
<div>
<p>
We use cookies for basic functionality and analytics. You can accept all, reject all, or customize your choices.
</p>
<button onClick={handleAcceptAll} disabled={isPending}>Accept All</button>
<button onClick={handleRejectAll} disabled={isPending}>Reject All</button>
<button onClick={() => setShowSettings(true)} disabled={isPending}>Cookie Settings</button>
</div>
</div>
);
}
What this does:
- Shows the consent banner only if it needs to be shown.
- Provides three choices: Accept All, Reject All, or open settings.
- Uses React hooks for UI state and transitions.
- Clicking a button runs the server action and reloads to update the app.
Step 6: Google Analytics Consent Component
For GDPR, you can’t load Google Analytics unless the user gave explicit consent. This component only inserts the analytics script if consent has been given.
// components/analytics/ga4-consent.tsx
"use client";
import { useEffect } from "react";
import Script from "next/script";
export function GA4Consent({ gaId, hasAnalyticsConsent }) {
useEffect(() => {
if (!gaId) return;
window.dataLayer = window.dataLayer || [];
window.gtag = function () {
window.dataLayer.push(arguments);
};
window.gtag("consent", "default", {
analytics_storage: hasAnalyticsConsent ? "granted" : "denied",
ad_storage: "denied",
});
}, [gaId, hasAnalyticsConsent]);
if (!gaId) return null;
return (
<>
<Script src={`https://www.googletagmanager.com/gtag/js?id=${gaId}`} strategy="afterInteractive" />
<Script id="ga4-init" strategy="afterInteractive" dangerouslySetInnerHTML={{ __html: `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${gaId}');
`}} />
</>
);
}
What this does:
- Loads Google Analytics only if the user gave permission for analytics cookies.
- Uses consent mode to set whether analytics tracking is allowed or denied.
- Ensures tracking is GDPR compliant and never runs without consent.
Step 7: Add Banner and Analytics to Layout
At this stage, we need to tie the cookie consent banner and Google Analytics script into our main app layout. We do this in the app/layout.tsx
file so the banner and tracking are available on all pages.
First, we check if the user has already consented to analytics cookies on the server. If they have, we load Google Analytics. We always show the cookie banner controller so users can give or change their consent.
// app/layout.tsx
import { BannerController } from "@/components/cookie-consent/banner-controller";
import { GA4Consent } from "@/components/analytics/ga4-consent";
import { shouldShowAnalytics } from "@/lib/cookie-utils";
export default async function RootLayout({ children }) {
// Check consent state on the server
const hasAnalyticsConsent = await shouldShowAnalytics();
return (
<html lang="en">
<body>
{children}
{/* Show cookie consent banner */}
<BannerController />
{/* Load Google Analytics if user accepted analytics cookies */}
{process.env.NEXT_PUBLIC_GA_ID && (
<GA4Consent gaId={process.env.NEXT_PUBLIC_GA_ID} hasAnalyticsConsent={hasAnalyticsConsent} />
)}
</body>
</html>
);
}
What this does:
- The layout imports the banner controller and the analytics consent component.
- It calls
shouldShowAnalytics()
so we know if analytics scripts should load or not based on user consent. <BannerController />
decides if banner is visible.<GA4Consent ... />
runs Google Analytics scripts, but only if the user gave permission.- This setup ensures the consent banner always appears when needed, and analytics tracking is only loaded for users who allow it.
Step 8: Cookie Settings Page
Finally, we make a page where users can review and change their consent at any time. Just link to /cookie-settings
from your footer or privacy policy.
// app/cookie-settings/page.tsx
import { Metadata } from "next";
import { getCookieConsent } from "@/lib/cookie-utils";
import { CookieSettingsPage } from "@/components/cookie-consent/cookie-settings-page";
export const metadata: Metadata = {
title: "Cookie Settings",
description: "Manage your cookie preferences",
};
export default async function Page() {
const consent = await getCookieConsent();
return (
<div className="container mx-auto py-8">
<CookieSettingsPage currentConsent={consent} />
</div>
);
}
What this does:
- Loads the current consent state on the server for this user.
- Renders a settings page where visitors can review and update their preferences.
- Keeps your app compliant by letting users find consent controls at any time.
2. Client-Side Cookie Consent (Best for Static Sites)
If you want fast static pages and SEO, move all consent logic to the client. This approach is also more scalable for more services.
Step 1: Client-Side Cookie Helpers
First, we make helper functions to manage the consent cookie in the browser. These will read, write, and validate consent without involving the server. By doing this client-side, we can keep our site statically generated and fast.
// lib/client-cookie-utils.ts
export interface CookieConsent {
essential: boolean;
analytics: boolean;
consentGiven: boolean;
timestamp: number;
version: string;
}
const CONSENT_COOKIE_NAME = "cookie_consent";
const CONSENT_DURATION = 12 * 30 * 24 * 60 * 60 * 1000; // 12 months
const CONSENT_VERSION = "1.0";
export function getCookieConsent(): CookieConsent | null {
try {
if (typeof window === "undefined") return null;
const cookie = document.cookie
.split("; ")
.find((row) => row.startsWith(CONSENT_COOKIE_NAME + "="));
if (!cookie) return null;
const cookieValue = cookie.split("=")[1];
if (!cookieValue) return null;
const consent = JSON.parse(decodeURIComponent(cookieValue));
// Validate consent structure
if (
typeof consent !== "object" ||
typeof consent.essential !== "boolean" ||
typeof consent.analytics !== "boolean" ||
typeof consent.consentGiven !== "boolean" ||
typeof consent.timestamp !== "number" ||
typeof consent.version !== "string"
) {
console.warn("Invalid cookie consent structure, resetting consent");
return null;
}
return consent;
} catch (error) {
console.error("Error parsing cookie consent:", error);
return null;
}
}
export function hasValidConsent(): boolean {
const consent = getCookieConsent();
if (!consent || !consent.consentGiven) return false;
const isExpired = Date.now() - consent.timestamp > CONSENT_DURATION;
if (isExpired) return false;
return consent.version === CONSENT_VERSION;
}
export function shouldShowAnalytics(): boolean {
const consent = getCookieConsent();
return hasValidConsent() && consent?.analytics === true;
}
export function shouldShowBanner(): boolean {
return !hasValidConsent();
}
export function setCookieConsent(
consent: Omit<CookieConsent, "timestamp" | "version">
): void {
try {
const fullConsent: CookieConsent = {
...consent,
timestamp: Date.now(),
version: CONSENT_VERSION,
};
const cookieValue = encodeURIComponent(JSON.stringify(fullConsent));
const maxAge = CONSENT_DURATION / 1000;
document.cookie = `${CONSENT_COOKIE_NAME}=${cookieValue}; path=/; max-age=${maxAge}; samesite=lax${process.env.NODE_ENV === "production" ? "; secure" : ""}`;
// Dispatch custom event for other components to listen
window.dispatchEvent(
new CustomEvent("consentUpdated", { detail: fullConsent })
);
console.log("Cookie consent updated:", fullConsent);
} catch (error) {
console.error("Error setting cookie consent:", error);
}
}
What this does:
- Provides utility functions to read and write cookie consent in the browser.
- Lets you check if consent is still valid, whether to show tracking, and whether to show the banner.
- Saves consent as a JSON string in a cookie, and informs the rest of the app when changes happen.
Step 2: Client-Side Banner Controller
Instead of checking consent on the server, we now check on the client using React state and effects. This ensures your app uses static generation (epicreact.dev, react.dev), and shows or hides the banner at the right time.
// components/cookie-consent/banner-controller.tsx
"use client";
import { useEffect, useState } from "react";
import { CookieBanner } from "./cookie-banner";
import { shouldShowBanner } from "@/lib/client-cookie-utils";
export function BannerController() {
const [showBanner, setShowBanner] = useState(false);
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
setShowBanner(shouldShowBanner());
setIsLoaded(true);
}, []);
useEffect(() => {
const handleConsentChange = () => {
setShowBanner(shouldShowBanner());
};
window.addEventListener('storage', handleConsentChange);
window.addEventListener('consentUpdated', handleConsentChange);
return () => {
window.removeEventListener('storage', handleConsentChange);
window.removeEventListener('consentUpdated', handleConsentChange);
};
}, []);
if (!isLoaded) return null;
return <CookieBanner showBanner={showBanner} />;
}
What this does:
- Renders the banner only when needed, and only after checking the cookie on the client.
- Updates banner visibility if consent changes (even in another tab).
- Ensures everything runs in the browser, which fits with Next.js's support for Client Components using the
"use client"
directive (react.dev, tutorialspoint.com).
Step 3: Client-Side Banner and Preferences Component
This is the UI part the user interacts with. Because all consent actions now happen in the browser, this version just calls the client-side helpers. It also allows users to open advanced settings if they want.
// components/cookie-consent/cookie-banner.tsx
"use client";
import { useState } from "react";
import { setCookieConsent } from "@/lib/client-cookie-utils";
import { CookieSettings } from "./cookie-settings";
interface CookieBannerProps {
showBanner: boolean;
}
export function CookieBanner({ showBanner }: CookieBannerProps) {
const [showSettings, setShowSettings] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
if (!showBanner) return null;
const handleAcceptAll = () => {
setIsProcessing(true);
setCookieConsent({
essential: true,
analytics: true,
consentGiven: true,
});
setIsProcessing(false);
};
const handleRejectAll = () => {
setIsProcessing(true);
setCookieConsent({
essential: true,
analytics: false,
consentGiven: true,
});
setIsProcessing(false);
};
if (showSettings) {
return (
<CookieSettings
onBack={() => setShowSettings(false)}
onClose={() => setShowSettings(false)}
/>
);
}
return (
<div>
<div>
<p>
We use cookies for site functionality and analytics. You can accept all, reject all, or customize preferences.
</p>
<button onClick={handleAcceptAll} disabled={isProcessing}>Accept All</button>
<button onClick={handleRejectAll} disabled={isProcessing}>Reject All</button>
<button onClick={() => setShowSettings(true)} disabled={isProcessing}>Cookie Settings</button>
</div>
</div>
);
}
What this does:
- Renders a simple cookie consent banner in the browser.
- Uses the client-side helper to save the user’s preference to a cookie.
- Allows users to open up advanced settings or just make a quick decision.
Step 4: Client-Side Analytics Consent
We need to load analytics scripts only if the user allows it. This component checks consent on the client and updates tracking accordingly.
// components/analytics/ga4-consent.tsx
"use client";
import { useEffect, useState } from "react";
import Script from "next/script";
import { shouldShowAnalytics } from "@/lib/client-cookie-utils";
interface GA4ConsentProps {
gaId: string;
hasAnalyticsConsent?: boolean;
}
export function GA4Consent({ gaId }: GA4ConsentProps) {
const [consentState, setConsentState] = useState(false);
useEffect(() => {
setConsentState(shouldShowAnalytics());
}, []);
useEffect(() => {
const handleConsentChange = () => {
setConsentState(shouldShowAnalytics());
};
window.addEventListener('storage', handleConsentChange);
window.addEventListener('consentUpdated', handleConsentChange);
return () => {
window.removeEventListener('storage', handleConsentChange);
window.removeEventListener('consentUpdated', handleConsentChange);
};
}, []);
useEffect(() => {
if (!gaId) return;
window.dataLayer = window.dataLayer || [];
window.gtag = function () {
window.dataLayer.push(arguments);
};
window.gtag("consent", "default", {
analytics_storage: consentState ? "granted" : "denied",
ad_storage: "denied",
});
}, [gaId, consentState]);
if (!gaId) return null;
return (
<>
<Script src={`https://www.googletagmanager.com/gtag/js?id=${gaId}`} strategy="afterInteractive" />
<Script id="ga4-init" strategy="afterInteractive" dangerouslySetInnerHTML={{
__html: `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${gaId}', { send_page_view: false });
`
}} />
</>
);
}
What this does:
- Loads and configures Google Analytics only if consent is present.
- Updates automatically if the user changes their consent (without full reload).
- Uses the
"use client"
directive to work fully browser-side, perfect for Next.js static sites (demystifying-rsc.vercel.app).
Step 5: Add Banner and Analytics to Layout
We now bring everything together in the main app layout. Here, we use only client-side logic so static optimization is preserved.
// app/layout.tsx
import { ReactNode } from "react";
import { BannerController } from "@/components/cookie-consent/banner-controller";
import { GA4Consent } from "@/components/analytics/ga4-consent";
interface RootLayoutProps {
children: ReactNode;
}
export default function RootLayout({ children }: RootLayoutProps) {
// No cookie checks on the server. All logic happens client-side.
return (
<html lang="en">
<body>
{children}
{/* Banner checks consent and renders client-side */}
<BannerController />
{/* Analytics will load on client only if allowed */}
{process.env.NEXT_PUBLIC_GA_ID && (
<GA4Consent gaId={process.env.NEXT_PUBLIC_GA_ID} />
)}
</body>
</html>
);
}
What this does:
- Does not use any server-side calls or checks.
- Everything—banner and tracking—runs client-side for optimal static pages and SEO.
- Keeps your app fully statically generated, as recommended for performance with Next.js react.dev.
Step 6: Optimized Cookie Settings Page
Users still need to be able to review or change their consent. On the client side, we don’t load consent state on the server; instead, we let the settings component handle it in the browser.
// app/cookie-settings/page.tsx
import { Metadata } from "next";
import { CookieSettingsPage } from "@/components/cookie-consent/cookie-settings-page";
export const metadata: Metadata = {
title: "Cookie Settings",
description: "Manage your cookie preferences",
};
export default function Page() {
return (
<div className="container mx-auto py-8">
{/* Consent state is handled entirely in the client component */}
<CookieSettingsPage currentConsent={null} />
</div>
);
}
What this does:
- Provides a page for users to view and update their preferences at any time.
- Leaves all cookie reading/writing to client-side hooks.
- Ensures you never lose static generation for your site.
This completes the client-side flow: fully static, SEO-friendly, and easy to maintain. Each step is optimized for Next.js Client Components and supports modern web best practices (react.dev, tutorialspoint.com).
Let me know if you’d like the Cookie Settings component walkthrough, or more Next.js tips like this for your blog!
Add a Link to Edit Settings
Users need to be able to change their preferences, so add a /cookie-settings
page. Use the CookieSettings component you created earlier.
Summary
- Server-side: Quick to build, checks cookies before rendering, disables static optimization.
- Client-side: Enables static sites, better for SEO, but requires more code.
Both ways let users pick, edit, and save preferences with a clean UI.
Why Not Use a Library?
I wanted to own the UI/UX, keep dependencies low, and avoid another monthly bill. Building your own is just as compliant—and easy to extend if you want to support more cookie types or consent logic.
What Next?
This pattern can be expanded to any Next.js site. If you need to integrate with Google Tag Manager or want more advanced usage (like category-level preferences), check out detailed implementation guides like this one or learn more about integrating tag manager consent mode.
Feel free to grab the code, adapt for your stack, and let me know if you have improvements.
Happy shipping,
Matija