Skip to main content
Back to Blog
Tutorials
3 min read
November 27, 2024

How to Add a Cookie Consent Banner to Your Website

Build a GDPR-compliant cookie consent banner in Next.js. Manage user preferences, block scripts until consent, and store choices.

Ryel Banfield

Founder & Lead Developer

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.

cookiesGDPRprivacyNext.jstutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles