Gate PostHog behind consent
Right now the app ships a consent banner’s worth of good intentions and none of its enforcement. Open the marketing page in a fresh incognito window with DevTools on the Network panel, and before you have clicked anything, PostHog has already loaded its SDK and fired a request at the analytics backend. The visitor never said yes. This lesson closes that gap: by the end, product analytics stays completely silent until the visitor explicitly accepts, and that choice is recorded in a cookie so it survives a reload without asking again.
The finished feature is a cookie-consent banner pinned to the bottom of the page with two equal-weight buttons, Accept and Reject. A fresh visit shows zero /ingest requests on the Network panel. Clicking Accept turns capture on, and the next navigation produces a $pageview that lands in your PostHog dashboard within about thirty seconds. Clicking Reject leaves capture off. And a reload after Accept resumes capture with no second click. The point worth holding onto before you write a line of code: a consent banner is theatre unless it actually gates the tracker. You are building the gate, not the curtain.
Your mission
Section titled “Your mission”This is the same consent finding the chapter 82 audit raised, seen from the other side. Back then the lesson caught that a banner’s privacy promise has to actually stop analytics from running; here you build that gate from scratch and close the half where PostHog captures with no consent at all. The seeded providers.tsx imports posthog-js at module scope and inits with opt_out_capturing_by_default: false, so events fire the instant the page paints — and nothing anywhere in the tree gates it. There is no consent provider, no banner, no seam.
Two independent belts have to hold, and the reason both exist is that each one alone has a hole. Belt one is capture-off-by-default at init: pass opt_out_capturing_by_default: true so even a loaded SDK records nothing until something explicitly opts it in. Belt two is a consent-gated dynamic import('posthog-js') that keeps the SDK chunk out of the page entirely until consent is given. Default-out alone still ships the SDK to every visitor and, worse, never captures even after they accept unless you remember to flip it back on. The gated import alone protects the chunk but leaves you exposed the moment that init flag is wrong. Together they fail safe in both directions.
Every grant and every revoke routes through one seam — lib/analytics/consent.ts — so that no feature engineer six months from now reaches for opt_in_capturing() inline in some unrelated component. When the next privacy review greps the codebase for the place analytics gets turned on, there must be exactly one place to read. That is the same single-seam-to-lint discipline the rest of this audit leans on: the redactor has one home, the correlation-ID scope has one home, and consent gets one too.
The case that bites people is session continuity. After a reload, init ran again with capture off by default, so a returning visitor whose consent cookie is already present is silently not being captured even though they accepted last week. You have to opt them back in on mount when the cookie says they consented. And there must be a single source of truth for the decision that every tracker reads — with the undecided state and an explicit reject both collapsing to “off,” so nothing fires before the click.
Session replay is out of scope here; it ships in a later chapter, and because you are building the gate as the one place consent is read, replay inherits it for free when it arrives. You are not enabling replay today.
The tests for this lesson run in Node with no DOM, so they read the shape of what you produce — the init flag, the source of truth, the seam, the finding file. The runtime behaviours you confirm by hand in the browser.
/ingest requests.$pageview, and the event lands in the PostHog dashboard within about thirty seconds.lib/analytics/consent.ts seam.findings/004-posthog-consent-gate.md carries all four sections, its rule cites the consent-gated-init pattern and the cookie-consent discipline, its location names the provider and the Network surface, and its fix names the opt-out/opt-in pair and the seam.Coding time
Section titled “Coding time”Build the gate against the brief and the lesson tests first. The /ingest reverse proxy and the PostHog env keys already ship in next.config.ts and src/env.ts, so you are wiring the consent layer on top of plumbing that is already there. Open the walkthrough below once you have an attempt running, or once you are stuck.
Reference solution and walkthrough
Four files, built in dependency order: the seam first, then the source of truth that calls it, then the banner that reads the source of truth, then the rewritten providers that put it all together.
The single seam — src/lib/analytics/consent.ts
Section titled “The single seam — src/lib/analytics/consent.ts”This is the one place capture is turned on and off. Both exported functions write the cookie, then dynamically import posthog-js and call the opt-in/opt-out pair — so the SDK enters the page only on a grant branch or a teardown branch, never speculatively.
export const ANALYTICS_CONSENT_COOKIE = 'consent_analytics';
const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 400;
const writeConsentCookie = (granted: boolean) => { const maxAge = granted ? COOKIE_MAX_AGE_SECONDS : 0; document.cookie = `${ANALYTICS_CONSENT_COOKIE}=${granted ? '1' : '0'}; path=/; max-age=${maxAge}; SameSite=Lax`;};
export const hasAnalyticsConsentCookie = () => typeof document !== 'undefined' && document.cookie .split('; ') .some((entry) => entry === `${ANALYTICS_CONSENT_COOKIE}=1`);
export const grantAnalyticsConsent = async () => { writeConsentCookie(true); const { default: posthog } = await import('posthog-js'); posthog.opt_in_capturing(); posthog.capture('analytics_consent_granted');};
export const revokeAnalyticsConsent = async () => { writeConsentCookie(false); const { default: posthog } = await import('posthog-js'); posthog.opt_out_capturing(); posthog.reset();};The cookie is the record of the choice. max-age is 400 days (the ePrivacy 13-month cap), SameSite=Lax, and deliberately not HttpOnly — the client has to read it on mount to decide whether to re-opt-in. The record of consent is itself essential, so it needs no consent of its own.
export const ANALYTICS_CONSENT_COOKIE = 'consent_analytics';
const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 400;
const writeConsentCookie = (granted: boolean) => { const maxAge = granted ? COOKIE_MAX_AGE_SECONDS : 0; document.cookie = `${ANALYTICS_CONSENT_COOKIE}=${granted ? '1' : '0'}; path=/; max-age=${maxAge}; SameSite=Lax`;};
export const hasAnalyticsConsentCookie = () => typeof document !== 'undefined' && document.cookie .split('; ') .some((entry) => entry === `${ANALYTICS_CONSENT_COOKIE}=1`);
export const grantAnalyticsConsent = async () => { writeConsentCookie(true); const { default: posthog } = await import('posthog-js'); posthog.opt_in_capturing(); posthog.capture('analytics_consent_granted');};
export const revokeAnalyticsConsent = async () => { writeConsentCookie(false); const { default: posthog } = await import('posthog-js'); posthog.opt_out_capturing(); posthog.reset();};grantAnalyticsConsent writes the cookie, dynamic-imports posthog, calls opt_in_capturing() — this is where belt one is lifted — then captures the one-off analytics_consent_granted event. The await import('posthog-js') inside the function is belt two preserved here: the SDK enters the page only on this consented branch.
export const ANALYTICS_CONSENT_COOKIE = 'consent_analytics';
const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 400;
const writeConsentCookie = (granted: boolean) => { const maxAge = granted ? COOKIE_MAX_AGE_SECONDS : 0; document.cookie = `${ANALYTICS_CONSENT_COOKIE}=${granted ? '1' : '0'}; path=/; max-age=${maxAge}; SameSite=Lax`;};
export const hasAnalyticsConsentCookie = () => typeof document !== 'undefined' && document.cookie .split('; ') .some((entry) => entry === `${ANALYTICS_CONSENT_COOKIE}=1`);
export const grantAnalyticsConsent = async () => { writeConsentCookie(true); const { default: posthog } = await import('posthog-js'); posthog.opt_in_capturing(); posthog.capture('analytics_consent_granted');};
export const revokeAnalyticsConsent = async () => { writeConsentCookie(false); const { default: posthog } = await import('posthog-js'); posthog.opt_out_capturing(); posthog.reset();};revokeAnalyticsConsent writes the cookie off, then opt_out_capturing() and reset(). Withdraw means “stop and forget” — drop the queued events and the stored identity — not merely “stop future”.
The cookie is set client-side and read on mount, which is why it cannot be HttpOnly. That is a deliberate exception to the usual “cookies should be HttpOnly” reflex: this one carries no secret, only a yes/no, and the client genuinely needs to read it. It is the record of an essential decision, so by the cookie-consent rule you learned earlier in the course, it needs no consent of its own.
The source of truth — src/app/_components/consent-provider.tsx
Section titled “The source of truth — src/app/_components/consent-provider.tsx”One context holds the decision; every tracker reads it through useConsent. The subtlety here is the difference between two boolean flags and why both start false.
'use client';
import { createContext, type ReactNode, use, useEffect, useState } from 'react';
import { grantAnalyticsConsent, hasAnalyticsConsentCookie, revokeAnalyticsConsent,} from '@/lib/analytics/consent';
type ConsentValue = { analytics: boolean; decided: boolean; accept: () => Promise<void>; reject: () => Promise<void>;};
const ConsentContext = createContext<ConsentValue | null>(null);
export const ConsentProvider = ({ children }: { children: ReactNode }) => { const [analytics, setAnalytics] = useState(false); const [decided, setDecided] = useState(false);
useEffect(() => { if (hasAnalyticsConsentCookie()) { setAnalytics(true); setDecided(true); } }, []);
const accept = async () => { await grantAnalyticsConsent(); setAnalytics(true); setDecided(true); };
const reject = async () => { await revokeAnalyticsConsent(); setAnalytics(false); setDecided(true); };
return ( <ConsentContext value={{ analytics, decided, accept, reject }}> {children} </ConsentContext> );};
export const useConsent = () => { const value = use(ConsentContext); if (!value) { throw new Error('useConsent must be used within a ConsentProvider'); } return value;};analytics is “is capture on”, decided is “has the visitor chosen yet”. decided separates “undecided, show the banner” from “rejected, banner gone, flag still off”. Both the undecided and rejected states collapse to analytics: false, so nothing fires before the click.
'use client';
import { createContext, type ReactNode, use, useEffect, useState } from 'react';
import { grantAnalyticsConsent, hasAnalyticsConsentCookie, revokeAnalyticsConsent,} from '@/lib/analytics/consent';
type ConsentValue = { analytics: boolean; decided: boolean; accept: () => Promise<void>; reject: () => Promise<void>;};
const ConsentContext = createContext<ConsentValue | null>(null);
export const ConsentProvider = ({ children }: { children: ReactNode }) => { const [analytics, setAnalytics] = useState(false); const [decided, setDecided] = useState(false);
useEffect(() => { if (hasAnalyticsConsentCookie()) { setAnalytics(true); setDecided(true); } }, []);
const accept = async () => { await grantAnalyticsConsent(); setAnalytics(true); setDecided(true); };
const reject = async () => { await revokeAnalyticsConsent(); setAnalytics(false); setDecided(true); };
return ( <ConsentContext value={{ analytics, decided, accept, reject }}> {children} </ConsentContext> );};
export const useConsent = () => { const value = use(ConsentContext); if (!value) { throw new Error('useConsent must be used within a ConsentProvider'); } return value;};Both flags start false so the server render and the first client render agree. document.cookie is unreadable on the server, so reading it during render would produce a hydration mismatch; the mount effect runs only on the client, after hydration, and flips the flags for a returning visitor.
'use client';
import { createContext, type ReactNode, use, useEffect, useState } from 'react';
import { grantAnalyticsConsent, hasAnalyticsConsentCookie, revokeAnalyticsConsent,} from '@/lib/analytics/consent';
type ConsentValue = { analytics: boolean; decided: boolean; accept: () => Promise<void>; reject: () => Promise<void>;};
const ConsentContext = createContext<ConsentValue | null>(null);
export const ConsentProvider = ({ children }: { children: ReactNode }) => { const [analytics, setAnalytics] = useState(false); const [decided, setDecided] = useState(false);
useEffect(() => { if (hasAnalyticsConsentCookie()) { setAnalytics(true); setDecided(true); } }, []);
const accept = async () => { await grantAnalyticsConsent(); setAnalytics(true); setDecided(true); };
const reject = async () => { await revokeAnalyticsConsent(); setAnalytics(false); setDecided(true); };
return ( <ConsentContext value={{ analytics, decided, accept, reject }}> {children} </ConsentContext> );};
export const useConsent = () => { const value = use(ConsentContext); if (!value) { throw new Error('useConsent must be used within a ConsentProvider'); } return value;};accept and reject each call the seam, then set state. The provider defers to consent.ts; it does not write the cookie or call PostHog itself.
'use client';
import { createContext, type ReactNode, use, useEffect, useState } from 'react';
import { grantAnalyticsConsent, hasAnalyticsConsentCookie, revokeAnalyticsConsent,} from '@/lib/analytics/consent';
type ConsentValue = { analytics: boolean; decided: boolean; accept: () => Promise<void>; reject: () => Promise<void>;};
const ConsentContext = createContext<ConsentValue | null>(null);
export const ConsentProvider = ({ children }: { children: ReactNode }) => { const [analytics, setAnalytics] = useState(false); const [decided, setDecided] = useState(false);
useEffect(() => { if (hasAnalyticsConsentCookie()) { setAnalytics(true); setDecided(true); } }, []);
const accept = async () => { await grantAnalyticsConsent(); setAnalytics(true); setDecided(true); };
const reject = async () => { await revokeAnalyticsConsent(); setAnalytics(false); setDecided(true); };
return ( <ConsentContext value={{ analytics, decided, accept, reject }}> {children} </ConsentContext> );};
export const useConsent = () => { const value = use(ConsentContext); if (!value) { throw new Error('useConsent must be used within a ConsentProvider'); } return value;};useConsent throws outside the provider — a missing provider fails loudly instead of silently handing back an undefined decision that would read as “no consent” and quietly disable analytics everywhere.
The hydration point is the one that trips people. You might be tempted to read the cookie during render so a returning visitor never sees the banner flash. But the server has no access to document.cookie, so it would render analytics: false while the client rendered true, and React would throw a hydration mismatch. Reading the cookie in a mount effect — after hydration — is the correct shape: the first paint matches the server, and the effect quietly upgrades a returning visitor a tick later.
The banner — src/app/_components/consent-banner.tsx
Section titled “The banner — src/app/_components/consent-banner.tsx”Nothing clever here, and that is the point. The banner shows only while the choice is undecided, and both buttons route through the hook — never an inline cookie write, never an inline opt_in_capturing().
'use client';
import { useConsent } from '@/app/_components/consent-provider';import { Button } from '@/components/ui/button';
export const ConsentBanner = () => { const { decided, accept, reject } = useConsent();
if (decided) { return null; }
return ( <div data-testid="consent-banner" className="fixed inset-x-0 bottom-0 z-50 border-t bg-background p-4 shadow-lg" > <div className="mx-auto flex max-w-3xl flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <p className="text-sm text-muted-foreground"> We use product analytics to understand which features earn their weight. Nothing non-essential runs until you choose. </p> <div className="flex shrink-0 gap-2"> <Button variant="outline" onClick={reject}> Reject </Button> <Button onClick={accept}>Accept</Button> </div> </div> </div> );};Accept and Reject carry equal visual weight — one is solid, one is outline, both are one click. A consent UI that buries Reject behind a second screen, or styles it as a faint link next to a bright Accept, is a dark pattern that regulators treat as no consent at all. Equal weight is a compliance requirement, not a design preference.
The rewrite — src/app/_components/providers.tsx
Section titled “The rewrite — src/app/_components/providers.tsx”This is where the two belts come together. The seeded version imports posthog-js at module scope and inits eagerly with the flag flipped the wrong way. The rewrite wraps everything in ConsentProvider, then a PostHogGate that reads the analytics flag and only loads and inits the SDK on the consented branch.
'use client';
import { ThemeProvider } from 'next-themes';import posthog, { type PostHogConfig } from 'posthog-js';import { PostHogProvider } from 'posthog-js/react';import { type ReactNode, useEffect } from 'react';
import { env } from '@/env';
export const Providers = ({ children }: { children: ReactNode }) => { useEffect(() => { const config: Partial<PostHogConfig> & { opt_out_capturing_by_default: boolean; } = { api_host: env.NEXT_PUBLIC_POSTHOG_HOST, opt_out_capturing_by_default: false, }; posthog.init(env.NEXT_PUBLIC_POSTHOG_KEY, config as Partial<PostHogConfig>); }, []);
return ( <PostHogProvider client={posthog}> <ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange > {children} </ThemeProvider> </PostHogProvider> );};The seeded defect, both belts absent. posthog-js is imported at module scope, so the chunk ships on first paint, and init runs unconditionally in a useEffect with opt_out_capturing_by_default: false — capture is on before the visitor consents.
'use client';
import { ThemeProvider } from 'next-themes';import type { PostHogConfig } from 'posthog-js';import { type ReactNode, useEffect } from 'react';
import { ConsentBanner } from '@/app/_components/consent-banner';import { ConsentProvider, useConsent,} from '@/app/_components/consent-provider';import { env } from '@/env';import { hasAnalyticsConsentCookie } from '@/lib/analytics/consent';
type ConsentGatedConfig = Partial<PostHogConfig> & { opt_out_capturing_by_default: boolean;};
const PostHogGate = ({ children }: { children: ReactNode }) => { const { analytics } = useConsent();
useEffect(() => { if (!analytics) { return; }
let cancelled = false; void import('posthog-js').then(({ default: posthog }) => { if (cancelled) { return; } const config: ConsentGatedConfig = { api_host: '/ingest', ui_host: 'https://eu.posthog.com', defaults: '2026-01-30', capture_pageview: false, opt_out_capturing_by_default: true, }; posthog.init( env.NEXT_PUBLIC_POSTHOG_KEY, config as Partial<PostHogConfig>, ); if (hasAnalyticsConsentCookie()) { posthog.opt_in_capturing(); } });
return () => { cancelled = true; }; }, [analytics]);
return <>{children}</>;};
export const Providers = ({ children }: { children: ReactNode }) => ( <ConsentProvider> <PostHogGate> <ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange > {children} <ConsentBanner /> </ThemeProvider> </PostHogGate> </ConsentProvider>);Both belts in place. ConsentProvider sits at the top as the source of truth. PostHogGate reads analytics and short-circuits with if (!analytics) return before the dynamic import('posthog-js') is ever reached (belt two); when it does run, init carries opt_out_capturing_by_default: true (belt one) and api_host: '/ingest'. The cancelled guard drops a late resolution, and the hasAnalyticsConsentCookie() check is the session-continuity re-call.
A few decisions in that rewrite are worth calling out, because they are the ones an inexperienced engineer gets wrong.
Why both belts, and why they are independent. Belt one is the init flag; belt two is the gated import. Drop belt two and keep only opt_out_capturing_by_default: true, and you still ship the entire SDK chunk to every visitor on first paint — the gate is on the capturing, not on the loading, so a privacy-conscious visitor still sees a third-party script land before they consent. Drop belt one and keep only the gated import, and the day someone fat-fingers the init config or PostHog changes a default, capture turns on the instant the SDK loads with no second line of defense. Each belt covers the other’s failure mode, which is exactly why you keep both rather than picking the “cleaner” single one.
Why grant and revoke go through one seam. It would be shorter to call opt_in_capturing() straight from the banner’s Accept handler. Resist it. The moment opt-in lives in two places, the audit grep stops being authoritative — there is now somewhere capture can turn on that the privacy review will not find. Routing everything through grantAnalyticsConsent / revokeAnalyticsConsent keeps the discipline grep-able. The provider’s accept calls the seam; the banner calls the provider’s accept; nobody touches PostHog directly except the gate.
Why the on-mount opt-in re-call exists. After a reload, PostHogGate re-runs init, and init always lands with capture off because belt one says so. PostHog persists its own opt-in state across sessions, but relying on that leaves the wiring opaque — a reader cannot tell from the code why capture resumes. The explicit if (hasAnalyticsConsentCookie()) posthog.opt_in_capturing() makes session continuity legible: a returning visitor whose cookie is set gets opted back in, on purpose, in code you can point at.
The finding — findings/004-posthog-consent-gate.md
Section titled “The finding — findings/004-posthog-consent-gate.md”The audit template carries through unchanged: rule, location, consequence, fix, all four filled. The Rule cites the consent-gated PostHog init pattern from chapter 93 and the cookie-consent discipline from chapter 81 — the “consent before processing” rule and the “essential cookies need no consent” carve-out both come from there. The Location names providers.tsx as the ungated init and the pre-consent /ingest request on the Network panel as the user-visible fingerprint. The Consequence is processing without prior consent — the SDK opens a connection and the first event leaves the browser before the banner even renders. The Fix is a paragraph naming the seam, the init flag, the runtime opt-in/opt-out pair, and the session-continuity re-call.
Because this is a fixed finding, not a documented-only one, the finding file describes a gate that now exists rather than a defect that survives. Write it as the record of what you changed and why.
If you want the senior reach, there is an optional bonus finding sitting one file away: src/app/(marketing)/layout.tsx loads a font with a raw <link> tag instead of next/font, which is the same LCP-path discipline you will document in the next lesson. You can file findings/009-missing-next-font.md here if you spot it.
PostHog's own walkthrough of the exact feature in the exact stack — banner plus opt-in/opt-out wiring.
API reference for opt_out_capturing_by_default, opt_in_capturing, and opt_out_capturing — the calls your seam owns.
The consent-before-processing rule your finding cites, from the source.
Max-Age and SameSite=Lax semantics behind the consent cookie writeConsentCookie sets.
Moment of truth
Section titled “Moment of truth”Run the lesson’s test suite:
pnpm test:lesson 5A clean pass confirms the shape: PostHog inits with opt_out_capturing_by_default: true and loads through a dynamic import rather than module scope, the ConsentProvider/useConsent source of truth exists and the gate reads from it, the grantAnalyticsConsent/revokeAnalyticsConsent seam owns the opt-in/opt-out pair while the banner stays out of PostHog’s internals, and the finding file carries all four sections with the right citations.
The tests run in Node with no browser, so they cannot watch a request fire or a banner disappear. Those are yours to confirm by hand, with the DevTools Network panel open alongside your PostHog dashboard. Tick each one off as you go.
/ingest requests (filter the Network panel to ingest)./ingest capture, and the next navigation fires a $pageview./ingest request fires.