Skip to content
Chapter 81Lesson 5

The cookie consent gate

Build a GDPR and ePrivacy consent gate that keeps every non-essential cookie and tracker silent until the user says yes.

Later you’ll wire up PostHog for analytics and session replay. That tooling can’t run until one piece exists first, the piece most teams build backwards.

The backwards version: a cookie banner appears, the user clicks “Accept,” a boolean flips to true, and the analytics SDK boots. But by the time the banner rendered, the page had already loaded the analytics script, opened a connection, and likely fired a pageview. That early pageview is the violation. GDPR and the ePrivacy Directive require prior consent : it has to come before anything non-essential runs.

The banner is the easy part. The rule behind it is the hard part: nothing non-essential fires before the user has chosen. Treat the banner as an engineering gate with one source of truth. Exactly one place knows whether tracking is allowed, every third party reads from it, and “allowed” cannot be observed until the user says yes.

You already have the pieces. The root <Providers> Client Component from the TanStack Query chapter holds one more provider for this gate. await cookies() reads consent on the server and hands it to a Client Component, so the banner never flashes on load. State changes go through Server Actions that return a Result, and logAudit(tx, event) records the decision. The new piece is one hook, useConsent(), the single place that knows the answer.

Section titled “Essential or consent-required: the legal test”

One decision sits upstream of all the code: sort every cookie and tracker your app sets into two buckets, the ones that need consent and the ones that don’t. A single legal test draws the line:

Two phrases carry the weight. Strictly necessary means the service breaks without it, not that it’s helpful or that the business wants it. The service the user explicitly asked for means the user’s request, not the business’s interests. That second phrase is the trap: analytics serves the business, not the service the user requested, so analytics is never essential.

Apply the test and your app’s cookies sort cleanly. Essential: the Better Auth session cookie that keeps the user logged in, the CSRF token, and the active-org cookie that tracks which organization they’re working in. Without these the user can’t stay signed in or do their work. One essential cookie almost everyone misses:

Consent-required: analytics (PostHog), session replay , marketing pixels , and support-chat widgets, anything that profiles the user across sessions or sites. None are necessary for the service, so all need a yes first.

When you genuinely can’t decide, the law sets the default: if in doubt, treat it as non-essential. The burden is on you to justify calling something essential; a regulator does not accept “we assumed it was fine.”

The gate ships exactly two non-essential categories, analytics and marketing, both off by default.

Run the one test — strictly necessary for the service the user explicitly asked for? — and sort each cookie or tracker. Drag each item into the bucket it belongs to, then press Check.

Essential — no consent needed Strictly necessary for the requested service
Consent required Anything that isn't strictly necessary
The Better Auth session cookie
The CSRF token
The cookie that records the user’s consent choice
The active-org cookie
PostHog product analytics
Session replay
A marketing/ad pixel
An embedded support-chat widget

The broken banner from the opening models consent as one boolean, accepted true or false, which can’t represent the choices the law requires you to offer. A user may accept analytics but refuse marketing, then later withdraw. A boolean has no room for that, so you end up bolting on flags and special cases until the logic is unreadable.

Model it the way it behaves: as granular consent , two independent boolean flags, analytics and marketing, each defaulting to off. The two flags give four combinations worth naming as states, because the transitions between them are where the behavior lives:

  • unset: no decision recorded, both flags off, banner showing.
  • analytics: analytics on, marketing off. The common “accept analytics, skip marketing” choice.
  • marketing: marketing on, analytics off. Rare, but granular means any combination, so the model must allow it.
  • all: both on. The full “Accept all.”

This is two flags, not a four-valued enum, and the difference compounds. Add a support category later and the two-flag model grows one boolean; a four-valued enum would have to be redesigned into eight values.

Keep this state in a cookie named consent_choice, not in localStorage. The banner is server-rendered, so the server must read the choice during the request, with await cookies(), to decide whether to render it. A cookie rides every request; localStorage lives only in the browser, so a localStorage-based banner renders blind on the server, shows the banner, then has JavaScript yank it away a beat later on every page load. One more constraint from ePrivacy: cap the cookie’s lifetime at 13 months, then re-ask.

Writing the choice is a Server Action, so the cookie is set server-side and server reads stay authoritative.

Walk the machine one state at a time, watching two things at each stop: what useConsent() returns, and which SDKs are loaded. Which SDKs are loaded is the whole compliance question.

Walk the consent machine — watch the flags and which SDKs are live
stateDiagram-v2
  direction LR
  [*] --> unset
  unset --> all : Accept all
  unset --> analytics : Manage → analytics only
  unset --> marketing : Manage → marketing only
  unset --> unset : Reject all
  analytics --> all : Enable marketing too
  analytics --> unset : Withdraw
  marketing --> all : Enable analytics too
  marketing --> unset : Withdraw
  all --> unset : Withdraw
Two independent flags, four states. Every Withdraw edge returns to unset and re-shows the banner: the revocation cycle most banners forget.

The state to leave with is unset, the start state and the one every Withdraw returns to. A user who never chose is in the same tracking posture as one who explicitly rejected: nothing fires. Default-off and reject converge by design, because the absence of a “yes” is a “no.”

One hook every tracker reads: useConsent()

Section titled “One hook every tracker reads: useConsent()”

One place in the app knows whether tracking is allowed: a single React Context that every third party reads from. That rigidity is what you audit against.

The shape:

  • A ConsentProvider mounts inside your existing root <Providers> Client Component, the same one that holds your query client. One provider tree per app, and this slots into it.
  • The provider takes the initial choice as a prop from a Server Component that read the consent_choice cookie with await cookies(). Because the server already knows the choice at render time, the provider starts in the right state on the first paint, with no flash.
  • It exposes useConsent(), which returns the two flags plus the actions to change them: { analytics, marketing, open(), accept(level), reject() }.

The rule that makes this a gate and not just a context: every third party imports useConsent() and short-circuits the moment its category flag is false. If analytics is false, the analytics code does not run, full stop. An analytics or marketing init not behind useConsent() is a bug, and that grep is your audit pass.

You won’t write the provider; it ships complete in the next chapter’s starter. Read it to recognize the shape and what it guarantees. Slow down on the handoff in the second step.

'use client';
const ConsentContext = createContext<ConsentValue | null>(null);
export const ConsentProvider = ({ initial, children }: ConsentProviderProps) => {
const [choice, setChoice] = useState(initial);
const [, setBannerOpen] = useState(false);
const open = () => setBannerOpen(true); // reopen the preferences banner
const accept = async (level: ConsentLevel) => {
const result = await saveConsent(level);
if (result.ok) setChoice(result.data);
};
const reject = () => accept('none');
return (
<ConsentContext value={{ ...choice, accept, reject, open }}>
{children}
</ConsentContext>
);
};
export const useConsent = () => {
const value = use(ConsentContext);
if (!value) throw new Error('useConsent must be used within ConsentProvider');
return value;
};

A Client Component, because consent state is interactive and read by client-side trackers. createContext holds the single value the whole app shares.

'use client';
const ConsentContext = createContext<ConsentValue | null>(null);
export const ConsentProvider = ({ initial, children }: ConsentProviderProps) => {
const [choice, setChoice] = useState(initial);
const [, setBannerOpen] = useState(false);
const open = () => setBannerOpen(true); // reopen the preferences banner
const accept = async (level: ConsentLevel) => {
const result = await saveConsent(level);
if (result.ok) setChoice(result.data);
};
const reject = () => accept('none');
return (
<ConsentContext value={{ ...choice, accept, reject, open }}>
{children}
</ConsentContext>
);
};
export const useConsent = () => {
const value = use(ConsentContext);
if (!value) throw new Error('useConsent must be used within ConsentProvider');
return value;
};

The no-flash handoff. initial is the choice the server read from the consent_choice cookie and passed down, so the provider renders in the right state from the first frame. That is why the choice lives in a cookie, not localStorage.

'use client';
const ConsentContext = createContext<ConsentValue | null>(null);
export const ConsentProvider = ({ initial, children }: ConsentProviderProps) => {
const [choice, setChoice] = useState(initial);
const [, setBannerOpen] = useState(false);
const open = () => setBannerOpen(true); // reopen the preferences banner
const accept = async (level: ConsentLevel) => {
const result = await saveConsent(level);
if (result.ok) setChoice(result.data);
};
const reject = () => accept('none');
return (
<ConsentContext value={{ ...choice, accept, reject, open }}>
{children}
</ConsentContext>
);
};
export const useConsent = () => {
const value = use(ConsentContext);
if (!value) throw new Error('useConsent must be used within ConsentProvider');
return value;
};

The three controls. accept and reject both route through one Server Action, saveConsent, which writes the cookie server-side and returns the new choice in a Result; reject is just accept('none'). Routing the write through the server keeps server reads authoritative. open reopens the preferences banner, the “reconsider” path a footer link calls.

'use client';
const ConsentContext = createContext<ConsentValue | null>(null);
export const ConsentProvider = ({ initial, children }: ConsentProviderProps) => {
const [choice, setChoice] = useState(initial);
const [, setBannerOpen] = useState(false);
const open = () => setBannerOpen(true); // reopen the preferences banner
const accept = async (level: ConsentLevel) => {
const result = await saveConsent(level);
if (result.ok) setChoice(result.data);
};
const reject = () => accept('none');
return (
<ConsentContext value={{ ...choice, accept, reject, open }}>
{children}
</ConsentContext>
);
};
export const useConsent = () => {
const value = use(ConsentContext);
if (!value) throw new Error('useConsent must be used within ConsentProvider');
return value;
};

useConsent() is the single accessor every tracker calls. The throw guarantees nobody reads consent from outside the provider tree.

1 / 1

What useConsent() hands back is two booleans plus the controls. Hover the return to see the full { analytics: boolean; marketing: boolean; ... } contract that every third party depends on.

const { analytics, marketing } = useConsent();
Section titled “Stopping analytics before consent: two defenses”

The pre-consent boundary is a timing problem. The question is never “is the SDK configured correctly,” it’s “could anything non-essential run before the user clicked,” and answering it takes two separate defenses, because either alone leaves a gap. PostHog shows their shape below; wiring it up for real is a later chapter.

Belt one: tell the SDK to do nothing until consent. Initialize PostHog with opt_out_capturing_by_default: true so it captures nothing, disable_session_recording: true so replay is off, and cookieless_mode: 'on_reject' so it writes no cookie or storage. Only when the analytics flag flips on do you call posthog.opt_in_capturing() and enable recording. This stops capture and storage, and it’s necessary.

The catch: belt one configures a module that has already loaded. For PostHog to be told “don’t capture,” its code is already in the browser, downloaded and perhaps already connected to the ingestion endpoint. You’ve told a guest who is already in your living room not to take notes. Opting out by default is not enough.

Belt two: gate the module load itself. The consent provider dynamically imports the analytics module only after the analytics flag turns on. Before consent, import('./analytics') is never called, so the SDK never reaches the browser: no script, no connection, nothing to opt out of. This is the belt beginners skip and the one that actually closes the boundary, because it controls whether the code exists in the page at all.

Why both, if belt two is the strong one? They protect different moments. Belt two keeps the SDK out of the page until consent; belt one governs what it does once loaded, both in the instant after consent before you’ve wired up the user’s granular choices, and in any edge where the module ends up present for another reason.

The network tab shows the violation. With only belt one, loading the page downloads the PostHog bundle and fires an init request before the user touches the banner. Add belt two and the network tab stays clean until the click.

useConsent() analytics: false marketing: false unset
Network Name Type Status
No requests — the wire is clean.
Clean — the analytics module was never even imported.
Page loads. useConsent() is unset — both flags false. The network tab is clean: no analytics bundle, no requests, because the module was never imported.
useConsent() analytics: false marketing: false unset
Network Name Type Status
No requests — the wire is clean.
Still clean. The server read the cookie, so the banner renders with no flash.
Banner shown. The server already read the consent_choice cookie and saw unset, so it rendered the banner with no flash. Still nothing analytics-shaped on the wire.
useConsent() analytics: false marketing: false unset
Network Name Type Status
No requests — the wire is clean.
Up to this instant — still clean. This click is the first non-essential action.
User clicks “Accept all”. The first non-essential action in the whole timeline. Up to this exact instant, the network tab is still clean.
useConsent() analytics: true marketing: true all
Network Name Type Status
saveConsent fetch 200
Only the Server Action call so far — no analytics yet.
Server Action runs. saveConsent writes the consent_choice cookie and the consent.recorded audit row, then returns the new choice. The provider flips to all — but the only request on the wire is the action itself.
useConsent() analytics: true marketing: true all
Network Name Type Status
saveConsent fetch 200
analytics.[hash].js script 200
Now — and only now — the analytics bundle appears, after the click.
Provider dynamically imports the analytics module (import('./analytics')) and calls opt_in_capturing(). Now the SDK bundle appears in the network tab — for the first time, and after the click.
useConsent() analytics: true marketing: true all
Network Name Type Status
saveConsent fetch 200
analytics.[hash].js script 200
e (pageview) xhr 200
The pageview captures — after consent, never before.
First event fires. The pageview captures now — after consent, never before. The order is the entire compliance story: load → choose → import → fire.

The consent decision sits between “page loaded” and “anything analytics happened”: load, choose, import, fire. No arrangement of the frames lets a tracker run before the choice.

A team sets opt_out_capturing_by_default: true on PostHog, but loads the PostHog <script> in the root layout <head> so it’s present on every page. Before the user clicks anything in the banner, is the app compliant?

Yes — capturing is opted out by default, so nothing is being recorded.
Yes — it only becomes a violation once an actual event is captured.
No — the SDK reached the browser before the choice, and the module load itself is the breach; the fix is to gate the import.
No — but only because disable_session_recording wasn’t set alongside the opt-out flag.

Accept is the happy path. Reject is where compliance is won or lost, and it has two halves that both have to hold: the button must be offered fairly, and the rejection must actually do something.

Fairness is an invariant you can verify, not a matter of taste. The banner carries three buttons, “Accept all,” “Reject all,” and “Manage preferences,” and the rule is blunt: Reject must be exactly as easy and as visible as Accept — same size, same prominence, same one click. The instant “Accept all” is a big colorful button and “Reject all” is a faint grey link in a corner, you have built a dark pattern , and the CNIL and EDPB have levied real fines over exactly this asymmetry. Treat equal weight as a hard requirement, like a passing test, not something conversion can override.

“Manage preferences” opens a small modal with two category toggles, analytics and marketing, both default-off. The banner itself is a modest, non-modal sticky footer, never a full-page wall that holds the content hostage; the wall is its own kind of coercion.

sticky footer

We use cookies to measure and improve the product. You choose what's on.

Equal weight — same size, same prominence, same one click for “Reject all” and “Accept all”. Asymmetry here is the dark pattern regulators fine.
Manage preferences opens a modal with the two category toggles (analytics, marketing) — both default-off.
Non-modal sticky footer — a modest bar, never a full-page wall that holds the content hostage.
Three buttons, equal weight. The one thing that gets teams fined is making Accept easier than Reject.

Here are the two banners side by side, the asymmetric one regulators fine and the symmetric one that passes.

We use cookies to improve your experience. By continuing you agree to our use of cookies.

Accept is loud, Reject is hidden — this asymmetry is what the CNIL and EDPB fine.

Now the second half, invisible until you check: reject has to be functional. A perfectly symmetric banner whose “Reject all” still lets PostHog phone home is worse than no banner, because now you’re lying. After “Reject all,” there must be no PostHog request, no marketing pixel, and no replay socket — and the two-belt model from the last section already guarantees it: reject lands the state in unset with both flags off, belt two never imports the analytics module, and belt one keeps it silent if it is imported anyway. Verify it in thirty seconds: open an incognito window, click “Reject all,” and watch the network tab stay clean. Anything analytics-shaped means the gate is broken — a check you can run by hand now and in CI later.

One path remains: the user must be able to change their mind in both directions. A persistent footer or settings link reopens the preferences modal, which is open() on the hook. Withdrawing is the interesting direction, because turning a tracker off takes more than flipping a flag. When analytics goes from true to false, call posthog.opt_out_capturing() to stop further capture and posthog.reset() to discard events already queued. Skip the reset() and PostHog keeps draining its buffer for about thirty seconds after the user revoked, sending events from someone who just said stop.

GDPR makes consent a data concern, not just a UI one: the data controller must be able to demonstrate that consent was given. A flag in a cookie isn’t proof; the user could clear it. So every consent decision earns a permanent record, and you already have the right place to put one.

Run it through the inclusion test from the audit-log lesson: a consent decision is attributable to a person, trust-relevant, and a state change rather than a read. Three for three, so it earns an audit row, written by the same Server Action that sets the consent_choice cookie, in the same transaction:

src/app/actions/consent.ts
await logAudit(tx, {
action: 'consent.recorded',
subjectType: 'user',
subjectId: userId,
payload: { analytics, marketing, policyVersion: CONSENT_POLICY_VERSION },
});

The action name follows the entity.verb-pasttense convention from the audit-log lesson: one dot, a hyphenated past-tense verb. Past tense, because the row records a choice the user made at a moment in time. Not consent.updated: “updated” describes a mutable thing being edited, and an audit row is neither.

The payload carries the choice, { analytics, marketing }, and the policy version. Notice what it doesn’t carry: the actor, IP, user agent, and timestamp. The caller hands logAudit only { action, subjectType, subjectId, payload }; the helper derives the actor, IP, and server timestamp itself. That’s an integrity property: a caller can’t forge who consented or when, because a caller never supplies it. The choice is the user’s own, so there’s no third-party PII to worry about.

policyVersion handles a problem you will hit: your privacy policy will change. When you add a tracker or change what you collect, consent given under the old policy no longer covers the new reality. Storing the version lets you treat any consent recorded under a lower version as stale, which re-shows the banner and asks again. Consent is to a specific policy at a specific time, and the version is how you pin it.

Everything above is about cookies and trackers, not whether you can email someone. These are two separate opt-ins, governed by different mechanisms.

Marketing-email consent lives on your sign-up form, as a “send me product updates” checkbox that must be default-unchecked. A pre-checked box fails GDPR, because consent must be an affirmative act: the user opts in, rather than failing to undo a default. The checkbox flips a marketingEmailConsent column on the users table (defaulting to false), set only here or by a later toggle in account settings.

The carve-out: transactional email needs no consent at all. A password reset, a receipt, a security alert: these are the service the user asked for. It’s the same “strictly necessary for the requested service” test, applied to email instead of cookies. Only marketing broadcasts gate on the marketingEmailConsent flag; a user with it off still gets the password reset they requested by clicking “forgot password.”

Run the boundary through a round.

Each claim is about marketing-email consent versus cookie consent versus transactional email. Mark each statement True or False.

A password-reset email can only be sent if the user gave marketing-email consent.

A password reset is transactional — it’s the service the user asked for by clicking “forgot password.” Transactional email needs no consent. Only marketing broadcasts gate on the marketingEmailConsent flag.

The “send me product updates” checkbox may be pre-checked as long as there’s an unsubscribe link.

Consent must be an affirmative act, so the box must be default-unchecked. A pre-ticked box fails GDPR no matter what unsubscribe options exist later — and an unsubscribe link doesn’t retroactively make pre-checking valid.

An invoice receipt is transactional and can be sent without any marketing consent.

A receipt is part of the service the user requested. It’s transactional, so the marketingEmailConsent flag doesn’t apply.

Accepting analytics cookies in the banner also opts the user into marketing emails.

They’re two separate, unrelated opt-ins. The cookie banner governs trackers; the marketingEmailConsent column governs email. Accepting one says nothing about the other.

The gate you built is the app-side, EU-shaped core of consent. A few related concerns sit just outside it; knowing they exist, and that they’re separate, keeps you from over-building or missing them later.

  • The marketing site is a different surface. Your example.com pages are a separate app from app.example.com, often running cookieless analytics like Plausible that need no banner. The app-side gate is load-bearing because the app holds the personal data and the logged-in profiling.
  • CCPA “Do Not Sell or Share” is a different right. US-California law adds a footer link with that specific wording: a separate control, not a rename of the EU gate.
  • Off-the-shelf consent platforms are the build-versus-buy option. OneTrust, Cookiebot, and Osano are what you reach for once the cookie inventory sprawls past a hand-rolled gate.

One conflation deserves its own line: revoking consent is not deleting data. Withdrawing consent stops future processing, so the trackers go quiet from here on; erasing data already collected is the separate right to be forgotten. Don’t let a “withdraw” button imply a deletion it doesn’t perform.

Run this against the seeded app. Every unticked box is a finding.

Every cookie and tracker is sorted into essential vs. consent-required by the “strictly necessary for the requested service” test; in doubt defaults to consent-required.
untested
The two non-essential categories (analytics, marketing) both default to off; unset behaves identically to a full reject.
untested
The consent choice lives in the consent_choice cookie (≤ 13 months), read server-side so the banner never flashes — not in localStorage.
untested
Every tracker reads useConsent() and short-circuits when its flag is false; no analytics/marketing init sits outside the hook.
untested
Non-essential SDKs are dynamically imported only after their flag flips on (belt two), and initialized opted-out by default (belt one).
untested
The banner offers Accept / Reject / Manage at equal weight; Reject is one click and as prominent as Accept.
untested
Clicking Reject in incognito leaves the network tab clean — no analytics request, no pixel, no replay socket.
untested
Withdrawing consent calls opt_out_capturing() and reset() so queued events stop, not just future ones.
untested
Every consent decision writes a consent.recorded audit row carrying { analytics, marketing } and the policy version.
untested
Marketing-email consent is a separate default-unchecked checkbox writing marketingEmailConsent; transactional email never gates on it.
untested

A cookie banner is not a legal checkbox; it’s an engineering gate built so that nothing fires pre-consent, because there’s nothing there to fire.