A compliant cookie banner isn't really about the banner. Showing a popup with an Accept button is the easy 10% of the work. The part that actually matters, legally, is that nothing non-essential runs before the visitor makes a choice, and that a Reject click is honored just as reliably as an Accept click. Here's how to build that in a Next.js App Router site, from a bare client component through gating an analytics script behind the decision.

What the implementation actually needs to do

Before writing any code, it's worth being precise about the requirement, because it's easy to build a banner that looks right and still fails it:

  1. Non-essential scripts, analytics, ad pixels, anything that isn't required for the page to function, must not load until the visitor has consented.
  2. The visitor's choice has to persist across page loads and visits, not just for the current session.
  3. Reject has to be a real option, not a smaller or hidden version of Accept.
  4. The visitor needs a way to change their mind later, not just on first visit.

Everything below is built around satisfying those four points, not around making a banner appear on screen. Here's the shape of it before the code:

The banner has to be a client component, since it reads from localStorage and manages interactive state, both of which only exist in the browser. Create components/CookieConsent.tsx:

"use client";

import { useEffect, useState } from "react";

type ConsentState = "unknown" | "accepted" | "rejected";

const STORAGE_KEY = "cookie-consent";

export function CookieConsent() {
  const [consent, setConsent] = useState<ConsentState>("unknown");
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    const stored = window.localStorage.getItem(STORAGE_KEY);
    if (stored === "accepted" || stored === "rejected") {
      setConsent(stored);
    }
    setMounted(true);
  }, []);

  function decide(next: "accepted" | "rejected") {
    window.localStorage.setItem(STORAGE_KEY, next);
    setConsent(next);
  }

  if (!mounted || consent !== "unknown") return null;

  return (
    <div className="cookie-banner" role="dialog" aria-label="Cookie consent">
      <p>
        We use cookies for analytics. You can accept or reject them, and
        change your choice anytime from the link in the footer.
      </p>
      <div className="cookie-banner-actions">
        <button onClick={() => decide("rejected")}>Reject</button>
        <button onClick={() => decide("accepted")}>Accept</button>
      </div>
    </div>
  );
}

The mounted check matters more than it looks like it should. Reading localStorage during server rendering isn't possible, so the component has to render nothing until it's actually running in the browser and has checked for a stored choice, otherwise you get a hydration mismatch between what the server rendered and what the client renders on first paint, which shows up as a console warning and, in the worst case, a flash of the banner for visitors who already made a choice.

Mount <CookieConsent /> once, in app/layout.tsx, outside the page content so it renders on every route.

Step 2: Read the choice somewhere scripts can check it

A component-local useState is enough for the banner itself, but anything that needs to gate on the consent decision, most importantly the analytics script, needs to read the same value. The simplest approach is a small shared hook that both the banner and any consent-gated component can call:

// lib/use-cookie-consent.ts
"use client";

import { useEffect, useState } from "react";

export function useCookieConsent() {
  const [consent, setConsent] = useState<"unknown" | "accepted" | "rejected">(
    "unknown"
  );

  useEffect(() => {
    const stored = window.localStorage.getItem("cookie-consent");
    if (stored === "accepted" || stored === "rejected") setConsent(stored);

    function onStorage(e: StorageEvent) {
      if (e.key === "cookie-consent" && e.newValue) {
        setConsent(e.newValue as "accepted" | "rejected");
      }
    }
    window.addEventListener("storage", onStorage);
    return () => window.removeEventListener("storage", onStorage);
  }, []);

  return consent;
}

The storage event listener is what keeps multiple components in sync without a global state library: when the banner writes a new value to localStorage, any other component using this hook picks it up, including in other open tabs.

Step 3: Gate the actual analytics script

This is the step that gets skipped most often, because it's tempting to treat the banner as the whole solution. It isn't. If your analytics script loads unconditionally in app/layout.tsx regardless of what the banner shows, you've built a banner that has no effect on anything, which is worse than not having one, since it actively tells visitors a choice is being honored when it isn't.

Using next/script, only render the script tag once consent is "accepted":

"use client";

import Script from "next/script";
import { useCookieConsent } from "@/lib/use-cookie-consent";

export function Analytics() {
  const consent = useCookieConsent();

  if (consent !== "accepted") return null;

  return (
    <Script
      src="https://www.googletagmanager.com/gtag/js?id=YOUR-ID"
      strategy="afterInteractive"
    />
  );
}

Mount <Analytics /> in app/layout.tsx alongside <CookieConsent />. Because it reads the same hook, rejecting the banner means this component simply never renders the script tag, and accepting later (including after a page reload, since the hook reads localStorage on mount) renders it immediately. Repeat the same pattern for any other non-essential script: ad pixels, session replay tools, embedded widgets that set their own cookies.

Step 4: Let visitors change their mind

A banner that only ever shows once, on first visit, fails the "reject has to be real" test in a subtle way: a visitor who rejected by mistake, or whose preferences changed, has no way back in. Add a small persistent link, typically in the footer, that resets the stored choice and re-shows the banner:

"use client";

export function CookiePreferencesLink() {
  return (
    <button
      className="cookie-preferences-link"
      onClick={() => {
        window.localStorage.removeItem("cookie-consent");
        window.location.reload();
      }}
    >
      Cookie preferences
    </button>
  );
}

The reload is a deliberately simple approach: it forces every consent-gated component, the banner included, to re-evaluate from a clean state, without needing to wire up a global event bus for something a visitor does rarely.

Building the banner yourself, as above, isn't the only path. A hosted consent management platform (CMP) handles the same gating with a drop-in script instead of hand-rolled components, at the cost of an external dependency and, usually, a monthly fee.

Hand-built banner vs a hosted CMP

Hand-built (this guide)Hosted CMP
SetupWrite and own the componentsDrop in a script tag
Script gatingYou wire it to consent stateHandled automatically
Ongoing costNone beyond your timeUsually a monthly fee
Multi-region rules (GDPR, CPRA)You implement per regionOften built in
Best forA single site, full controlMany sites or a small team

What this covers, and what it doesn't

This gets the mechanics right: nothing non-essential runs before a choice, the choice persists and can be changed, and Reject is a real, equally weighted option next to Accept. It doesn't, on its own, make the banner's copy or your underlying disclosures accurate. The banner text above is deliberately generic; a real one needs to describe your actual cookies and, ideally, link to a full breakdown for visitors who want more detail than a two-sentence popup can hold. If your site doesn't set any non-essential cookies in the first place, the banner may not be required at all, which is worth confirming before building any of the above; see our guide on whether you need a cookie banner if you don't use cookies.

For real examples of banner copy, button hierarchy, and layout that hold up under both GDPR and CPRA review, see our 30 cookie consent banner examples, and our Cookie Policy Generator for the disclosure document this banner's link should actually point to.

The information in this article is for informational purposes only and should not be construed as legal advice on any matter, and does not create a lawyer-client relationship.