Privacy regulations like GDPR and CCPA require websites to get user consent before loading tracking scripts. Here is how to build a compliant consent banner.
Step 1: Define Cookie Categories
// lib/cookies.ts
export type CookieCategory = "necessary" | "analytics" | "marketing" | "preferences";
export type CookieConsent = Record<CookieCategory, boolean>;
export const defaultConsent: CookieConsent = {
necessary: true, // Always enabled
analytics: false,
marketing: false,
preferences: false,
};
export const cookieCategories = [
{
id: "necessary" as const,
name: "Necessary",
description: "Required for the website to function. Cannot be disabled.",
required: true,
},
{
id: "analytics" as const,
name: "Analytics",
description: "Help us understand how visitors use our website.",
required: false,
},
{
id: "marketing" as const,
name: "Marketing",
description: "Used to deliver relevant advertisements.",
required: false,
},
{
id: "preferences" as const,
name: "Preferences",
description: "Remember your settings and personalization choices.",
required: false,
},
];
Step 2: Create a Consent Storage Utility
// lib/consent.ts
import { CookieConsent, defaultConsent } from "./cookies";
const CONSENT_KEY = "cookie-consent";
export function getStoredConsent(): CookieConsent | null {
if (typeof window === "undefined") return null;
const stored = localStorage.getItem(CONSENT_KEY);
if (!stored) return null;
try {
return JSON.parse(stored) as CookieConsent;
} catch {
return null;
}
}
export function storeConsent(consent: CookieConsent): void {
localStorage.setItem(CONSENT_KEY, JSON.stringify(consent));
// Set a cookie so the server can read it too
document.cookie = `cookie-consent=${encodeURIComponent(JSON.stringify(consent))}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Lax`;
}
export function hasConsentBeenGiven(): boolean {
return getStoredConsent() !== null;
}
Step 3: Build the Consent Banner
"use client";
import { useState, useEffect } from "react";
import {
CookieConsent,
cookieCategories,
defaultConsent,
} from "@/lib/cookies";
import { getStoredConsent, storeConsent, hasConsentBeenGiven } from "@/lib/consent";
export function CookieBanner() {
const [visible, setVisible] = useState(false);
const [showDetails, setShowDetails] = useState(false);
const [consent, setConsent] = useState<CookieConsent>(defaultConsent);
useEffect(() => {
if (!hasConsentBeenGiven()) {
setVisible(true);
}
}, []);
function acceptAll() {
const fullConsent: CookieConsent = {
necessary: true,
analytics: true,
marketing: true,
preferences: true,
};
storeConsent(fullConsent);
setVisible(false);
loadScripts(fullConsent);
}
function rejectAll() {
storeConsent(defaultConsent);
setVisible(false);
}
function savePreferences() {
storeConsent(consent);
setVisible(false);
loadScripts(consent);
}
if (!visible) return null;
return (
<div
className="fixed inset-x-0 bottom-0 z-50 border-t bg-white p-4 shadow-lg dark:border-gray-700 dark:bg-gray-900 sm:p-6"
role="dialog"
aria-label="Cookie consent"
>
<div className="mx-auto max-w-5xl">
{!showDetails ? (
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-gray-600 dark:text-gray-300">
We use cookies to enhance your experience. By continuing to visit
this site, you agree to our use of cookies.{" "}
<button
onClick={() => setShowDetails(true)}
className="underline hover:text-gray-900 dark:hover:text-white"
>
Manage preferences
</button>
</p>
<div className="flex gap-3">
<button
onClick={rejectAll}
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-800"
>
Reject All
</button>
<button
onClick={acceptAll}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Accept All
</button>
</div>
</div>
) : (
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Cookie Preferences
</h3>
<div className="mt-4 space-y-4">
{cookieCategories.map((category) => (
<div
key={category.id}
className="flex items-start justify-between gap-4"
>
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">
{category.name}
</p>
<p className="text-xs text-gray-500">
{category.description}
</p>
</div>
<label className="relative inline-flex cursor-pointer items-center">
<input
type="checkbox"
checked={consent[category.id]}
disabled={category.required}
onChange={(e) =>
setConsent((prev) => ({
...prev,
[category.id]: e.target.checked,
}))
}
className="peer sr-only"
/>
<div className="peer h-5 w-9 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:bg-white after:transition-all peer-checked:bg-blue-600 peer-checked:after:translate-x-full peer-disabled:opacity-50 dark:bg-gray-700" />
</label>
</div>
))}
</div>
<div className="mt-6 flex justify-end gap-3">
<button
onClick={() => setShowDetails(false)}
className="text-sm text-gray-500 hover:text-gray-700"
>
Back
</button>
<button
onClick={savePreferences}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Save Preferences
</button>
</div>
</div>
)}
</div>
</div>
);
}
Step 4: Conditionally Load Scripts
// lib/scripts.ts
import { CookieConsent } from "./cookies";
export function loadScripts(consent: CookieConsent) {
if (consent.analytics) {
loadGoogleAnalytics();
}
if (consent.marketing) {
loadMarketingScripts();
}
}
function loadGoogleAnalytics() {
const script = document.createElement("script");
script.src = `https://www.googletagmanager.com/gtag/js?id=${process.env.NEXT_PUBLIC_GA_ID}`;
script.async = true;
document.head.appendChild(script);
script.onload = () => {
window.dataLayer = window.dataLayer || [];
function gtag(...args: unknown[]) {
window.dataLayer.push(args);
}
gtag("js", new Date());
gtag("config", process.env.NEXT_PUBLIC_GA_ID);
};
}
function loadMarketingScripts() {
// Load Facebook Pixel, LinkedIn Insight, etc.
}
Step 5: Add to Layout
// app/layout.tsx
import { CookieBanner } from "@/components/CookieBanner";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
{children}
<CookieBanner />
</body>
</html>
);
}
Step 6: Load Previously Consented Scripts on Return Visits
"use client";
import { useEffect } from "react";
import { getStoredConsent } from "@/lib/consent";
import { loadScripts } from "@/lib/scripts";
export function ConsentLoader() {
useEffect(() => {
const consent = getStoredConsent();
if (consent) {
loadScripts(consent);
}
}, []);
return null;
}
Add <ConsentLoader /> to your layout alongside the banner.
Compliance Notes
- Always allow users to change preferences later (add a link in the footer)
- Do not load non-essential scripts before consent
- Log consent timestamps for audit purposes
- Honor Do Not Track browser settings when applicable
- Review cookie categories regularly as you add new tools
Need Privacy-Compliant Web Development?
We build GDPR and CCPA-compliant websites with proper consent management. Contact us to discuss your requirements.