Wiring PostHog through the consent gate
Install the PostHog analytics SDK so it loads only after a user consents, relayed through your own domain past ad-blockers.
When you built the consent gate, you wrote the useConsent() hook and proved that nothing non-essential fired before the user agreed.
PostHog was the worked example, but you never installed it; that was left for a later chapter.
This lesson installs it. By the end, a deliberate test pageview will reach PostHog only after the user accepts analytics, sent to the EU region and relayed through your own domain so ad-blockers can’t eat it. Your DevTools Network tab will confirm that no PostHog traffic fires before that acceptance.
The hook already exposes an analytics boolean, and that is all this lesson needs from it, because everything rests on one idea: the PostHog SDK is a module that does not exist in the page until analytics is true.
Not loaded but disabled. Not present but quiet. Absent.
The two belts: SDK loaded but disabled, or never loaded
Section titled “The two belts: SDK loaded but disabled, or never loaded”You met the two-belt model in the consent-gate lesson.
Belt one is the init option opt_out_capturing_by_default: true: the SDK loads but stays disabled, capturing nothing until you opt it in.
Belt two is stronger: a dynamic import('posthog-js') that runs only after the consent flag flips, so the SDK code never enters the page while consent is absent.
This lesson builds belt two and puts belt one underneath as the floor, so a module that ever slipped past the gate would still stay silent.
The three packages and what each owns
Section titled “The three packages and what each owns”PostHog’s App Router setup spans three package names; here’s which does what.
pnpm add posthog-js posthog-nodeposthog-js The browser SDK. Events, the feature-flag client, session-replay capture.
posthog-node The server SDK. Capturing an event when there is no browser in the room — a Stripe webhook, a scheduled job.
@posthog/next beta The App Router convenience layer. Folds the proxy route, the distinct-ID cookie, and flag bootstrap into one wrapper.
A 2026 wrapper, @posthog/next, folds these pieces together, but it’s still marked beta and PostHog’s own Next.js docs still walk the manual posthog-js plus posthog-node path.
So you’ll build that manual wire and adopt the wrapper once it leaves beta.
It will fold in the /ingest proxy and keep the browser and server’s distinct IDs in sync, both of which you wire by hand in this lesson.
One piece of vocabulary leads into the next section. PostHog gives each project a project key , safe in the browser by design.
The three keys and the NEXT_PUBLIC_ firewall
Section titled “The three keys and the NEXT_PUBLIC_ firewall”The provider you’re about to write reads its configuration from your typed env, so wire the variables first.
There are three, and the firewall between them is the point.
export const env = createEnv({ server: { POSTHOG_PERSONAL_API_KEY: z.string().min(1), }, client: { NEXT_PUBLIC_POSTHOG_KEY: z.string().min(1), NEXT_PUBLIC_POSTHOG_HOST: z.url(), }, runtimeEnv: { POSTHOG_PERSONAL_API_KEY: process.env.POSTHOG_PERSONAL_API_KEY, NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY, NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST, },});The project key and the EU host carry the NEXT_PUBLIC_ prefix because they’re meant to ship to the browser. The project key is write-only at the ingest endpoint, so a reader who pulls it from the bundle can send events but can’t read your data back.
export const env = createEnv({ server: { POSTHOG_PERSONAL_API_KEY: z.string().min(1), }, client: { NEXT_PUBLIC_POSTHOG_KEY: z.string().min(1), NEXT_PUBLIC_POSTHOG_HOST: z.url(), }, runtimeEnv: { POSTHOG_PERSONAL_API_KEY: process.env.POSTHOG_PERSONAL_API_KEY, NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY, NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST, },});The personal API key is read-capable, so it lives in the server block, never the client one. That split is the firewall: name it NEXT_PUBLIC_POSTHOG_PERSONAL_KEY by mistake and you’ve shipped a read credential to every visitor.
export const env = createEnv({ server: { POSTHOG_PERSONAL_API_KEY: z.string().min(1), }, client: { NEXT_PUBLIC_POSTHOG_KEY: z.string().min(1), NEXT_PUBLIC_POSTHOG_HOST: z.url(), }, runtimeEnv: { POSTHOG_PERSONAL_API_KEY: process.env.POSTHOG_PERSONAL_API_KEY, NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY, NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST, },});Each variable maps to its process.env source so the schema can read the actual values at runtime.
The host is https://eu.i.posthog.com, the EU Cloud region you chose with PostHog.
The NEXT_PUBLIC_ prefix is the firewall: the two public keys belong in the browser bundle, the personal key never does.
Because the schema splits server from client, a misnamed NEXT_PUBLIC_POSTHOG_PERSONAL_KEY becomes a build error instead of a silent leak.
The provider is belt two
Section titled “The provider is belt two”This is the component the whole lesson builds toward: a 'use client' PostHogProvider that is belt two.
The provider:
- Mounts inside your root
<Providers>and insideConsentProvider. Order matters:ConsentProvidermust be an ancestor, becausePostHogProvidercallsuseConsent(), which throws if used outside its provider. - Reads
const { analytics } = useConsent(). The gate is that one boolean. - When
analyticsisfalse, renders its children and never reaches the import. A default, a reject, and a not-yet-decided user all collapse to “off” here, so nothing loads. - When
analyticsflips totrue, an effect dynamically imports the SDK, inits it with belt one baked in, then opts capturing in. - When
analyticsflips back tofalse(a withdrawal), the effect opts out and resets, stopping any queued events.
Loading a third-party SDK is a sanctioned use of useEffect: you’re synchronizing your component with an external system, which is what effects are for.
'use client';
import type { ReactNode } from 'react';import { useEffect } from 'react';import { useConsent } from '@/app/_components/consent-provider';import { env } from '@/env';
export const PostHogProvider = ({ children }: { children: ReactNode }) => { const { analytics } = useConsent();
useEffect(() => { if (!analytics) return;
let cancelled = false; import('posthog-js').then(({ default: posthog }) => { if (cancelled) return; posthog.init(env.NEXT_PUBLIC_POSTHOG_KEY, { api_host: '/ingest', ui_host: 'https://eu.posthog.com', defaults: '2026-01-30', capture_pageview: false, opt_out_capturing_by_default: true, }); posthog.opt_in_capturing(); });
return () => { cancelled = true; void import('posthog-js').then(({ default: posthog }) => { posthog.opt_out_capturing(); posthog.reset(); }); }; }, [analytics]);
return <>{children}</>;};'use client' because this reads consent state and runs an effect. You read exactly one thing from useConsent(): the analytics boolean. Everything below hangs off it.
'use client';
import type { ReactNode } from 'react';import { useEffect } from 'react';import { useConsent } from '@/app/_components/consent-provider';import { env } from '@/env';
export const PostHogProvider = ({ children }: { children: ReactNode }) => { const { analytics } = useConsent();
useEffect(() => { if (!analytics) return;
let cancelled = false; import('posthog-js').then(({ default: posthog }) => { if (cancelled) return; posthog.init(env.NEXT_PUBLIC_POSTHOG_KEY, { api_host: '/ingest', ui_host: 'https://eu.posthog.com', defaults: '2026-01-30', capture_pageview: false, opt_out_capturing_by_default: true, }); posthog.opt_in_capturing(); });
return () => { cancelled = true; void import('posthog-js').then(({ default: posthog }) => { posthog.opt_out_capturing(); posthog.reset(); }); }; }, [analytics]);
return <>{children}</>;};The short-circuit. If analytics is false, the effect returns before the import line runs. This is belt two: the SDK code on the next line never enters the page. Default, reject, and undecided users all land here.
'use client';
import type { ReactNode } from 'react';import { useEffect } from 'react';import { useConsent } from '@/app/_components/consent-provider';import { env } from '@/env';
export const PostHogProvider = ({ children }: { children: ReactNode }) => { const { analytics } = useConsent();
useEffect(() => { if (!analytics) return;
let cancelled = false; import('posthog-js').then(({ default: posthog }) => { if (cancelled) return; posthog.init(env.NEXT_PUBLIC_POSTHOG_KEY, { api_host: '/ingest', ui_host: 'https://eu.posthog.com', defaults: '2026-01-30', capture_pageview: false, opt_out_capturing_by_default: true, }); posthog.opt_in_capturing(); });
return () => { cancelled = true; void import('posthog-js').then(({ default: posthog }) => { posthog.opt_out_capturing(); posthog.reset(); }); }; }, [analytics]);
return <>{children}</>;};The dynamic import. The browser fetches and runs the posthog-js chunk only here, at the first moment analytics is true. Before this line, the SDK does not exist in the page.
'use client';
import type { ReactNode } from 'react';import { useEffect } from 'react';import { useConsent } from '@/app/_components/consent-provider';import { env } from '@/env';
export const PostHogProvider = ({ children }: { children: ReactNode }) => { const { analytics } = useConsent();
useEffect(() => { if (!analytics) return;
let cancelled = false; import('posthog-js').then(({ default: posthog }) => { if (cancelled) return; posthog.init(env.NEXT_PUBLIC_POSTHOG_KEY, { api_host: '/ingest', ui_host: 'https://eu.posthog.com', defaults: '2026-01-30', capture_pageview: false, opt_out_capturing_by_default: true, }); posthog.opt_in_capturing(); });
return () => { cancelled = true; void import('posthog-js').then(({ default: posthog }) => { posthog.opt_out_capturing(); posthog.reset(); }); }; }, [analytics]);
return <>{children}</>;};Belt one, inside init. The SDK loads disabled and captures nothing until told otherwise, so a module that ever slipped past the gate stays silent.
'use client';
import type { ReactNode } from 'react';import { useEffect } from 'react';import { useConsent } from '@/app/_components/consent-provider';import { env } from '@/env';
export const PostHogProvider = ({ children }: { children: ReactNode }) => { const { analytics } = useConsent();
useEffect(() => { if (!analytics) return;
let cancelled = false; import('posthog-js').then(({ default: posthog }) => { if (cancelled) return; posthog.init(env.NEXT_PUBLIC_POSTHOG_KEY, { api_host: '/ingest', ui_host: 'https://eu.posthog.com', defaults: '2026-01-30', capture_pageview: false, opt_out_capturing_by_default: true, }); posthog.opt_in_capturing(); });
return () => { cancelled = true; void import('posthog-js').then(({ default: posthog }) => { posthog.opt_out_capturing(); posthog.reset(); }); }; }, [analytics]);
return <>{children}</>;};The explicit opt-in. This line lifts belt one, and it only runs on the consented path. Capture is on from here.
'use client';
import type { ReactNode } from 'react';import { useEffect } from 'react';import { useConsent } from '@/app/_components/consent-provider';import { env } from '@/env';
export const PostHogProvider = ({ children }: { children: ReactNode }) => { const { analytics } = useConsent();
useEffect(() => { if (!analytics) return;
let cancelled = false; import('posthog-js').then(({ default: posthog }) => { if (cancelled) return; posthog.init(env.NEXT_PUBLIC_POSTHOG_KEY, { api_host: '/ingest', ui_host: 'https://eu.posthog.com', defaults: '2026-01-30', capture_pageview: false, opt_out_capturing_by_default: true, }); posthog.opt_in_capturing(); });
return () => { cancelled = true; void import('posthog-js').then(({ default: posthog }) => { posthog.opt_out_capturing(); posthog.reset(); }); }; }, [analytics]);
return <>{children}</>;};The cleanup branch, which runs when analytics flips back to false. It opts out and calls reset() to clear the queue and stored identity. Withdrawal isn’t “stop sending future events”; it’s “stop, and forget.” The identity side of reset() gets its full treatment in the next lesson.
The analytics dependency re-runs the effect on every consent change, so an accept runs the init path and a later withdrawal runs the cleanup path with no extra plumbing.
The cancelled flag guards the gap between the import resolving and the effect being torn down.
Now the nesting, a separate decision from the provider’s internals.
Get the order wrong and useConsent() throws, because the hook can’t find its provider above it in the tree.
export const Providers = ({ children }: { children: ReactNode }) => ( <QueryClientProvider client={getQueryClient()}> <ConsentProvider> <PostHogProvider>{children}</PostHogProvider> </ConsentProvider> </QueryClientProvider>);ConsentProvider wraps PostHogProvider, which wraps {children}.
That ancestor relationship is the contract: PostHogProvider can only call useConsent() because ConsentProvider sits above it.
Put the steps in order, from a fresh page load to the first captured event:
A user lands on the page, then clicks Accept. Order the steps from page load to the first captured event. Drag the items into the correct order, then press Check.
analytics still false useConsent() returns analytics: true, re-running the effect import('posthog-js') resolves and the SDK enters the page posthog.init(...) runs with opt_out_capturing_by_default: true posthog.opt_in_capturing() lifts the opt-out The classic production bug is putting the import at the top of the module and trusting opt_out_capturing_by_default to hold the line.
A top-level import means the SDK is in the page on first load, consent or not.
Belt one alone doesn’t save you: the module is present, just quiet, and “present” is already more than the gate promised.
Belt two is the import living inside the consented branch.
Pinned defaults and App Router pageviews
Section titled “Pinned defaults and App Router pageviews”Two options in that init config cause the classic “works in dev, wrong numbers in production” bug, so they’re worth a closer look.
The first is defaults.
PostHog bundles its recommended settings for autocapture , pageview handling, and exception capture behind a single dated snapshot.
Pinning a date means a future change to those defaults can’t silently change your app’s behavior.
The value above, '2026-01-30', is the current snapshot; pin a date of your own and confirm it against PostHog’s docs.
The second is pageview capture.
The App Router navigates with history.pushState, which is how next/link and useRouter().push() move between routes without a full reload.
That client-side navigation is invisible to PostHog’s automatic pageview tracking, so a naive setup misses every in-app navigation or double-counts the first.
The fix is to turn automatic capture off (capture_pageview: false) and fire pageviews yourself on route change.
'use client';
import { usePathname, useSearchParams } from 'next/navigation';import posthog from 'posthog-js';import { Suspense, useEffect } from 'react';
const PageViewTracker = () => { const pathname = usePathname(); const searchParams = useSearchParams();
useEffect(() => { if (!pathname) return; posthog.capture('$pageview'); }, [pathname, searchParams]);
return null;};
export const PostHogPageView = () => ( <Suspense fallback={null}> <PageViewTracker /> </Suspense>);The component reads usePathname() and useSearchParams() and fires posthog.capture('$pageview') on every client-side navigation, counted once.
The Suspense wrap is required: useSearchParams() opts its subtree into client rendering, and the App Router makes a missing boundary a build error.
Render <PostHogPageView /> once, high in the tree.
It imports posthog directly because by the time a navigation fires the user has consented and the SDK is initialized.
The /ingest proxy: a same-origin relay past ad-blockers
Section titled “The /ingest proxy: a same-origin relay past ad-blockers”Ad-blockers heuristically block requests to i.posthog.com and the EU host, and a real slice of your users run one.
Without a workaround, their events vanish silently: nothing throws, nothing logs, and your numbers end up biased against exactly the privacy-conscious users you’d most want to count.
The fix is a reverse proxy : a route on your own domain that relays PostHog traffic.
Requests go to /ingest/..., a same-origin first-party path the ad-blocker has no reason to block, and your server forwards them on to PostHog.
The wire has two halves: the relay itself, and two config changes so the SDK uses it.
const nextConfig: NextConfig = { skipTrailingSlashRedirect: true, async rewrites() { return [ { source: '/ingest/static/:path*', destination: 'https://eu-assets.i.posthog.com/static/:path*', }, { source: '/ingest/:path*', destination: 'https://eu.i.posthog.com/:path*', }, ]; },};The relay. Two rewrite rules forward /ingest/* on your domain to PostHog’s EU hosts: the first relays the SDK’s static assets, the second relays ingest traffic. The /static rule must come first, since it’s the more specific match. skipTrailingSlashRedirect stops Next.js from appending a slash that would break PostHog’s API paths.
posthog.init(env.NEXT_PUBLIC_POSTHOG_KEY, { api_host: '/ingest', ui_host: 'https://eu.posthog.com', defaults: '2026-01-30', capture_pageview: false, opt_out_capturing_by_default: true,});The config changes. api_host: '/ingest' sends events same-origin, through the rewrite. ui_host: 'https://eu.posthog.com' points the links the SDK generates, like the toolbar and replay viewer, at the real PostHog UI. Mind the dot: the ingest host is eu.i.posthog.com (with .i.), the UI host is eu.posthog.com (no .i.). Set api_host without ui_host and those links break.
Two details to get exactly right.
The hosts are not interchangeable: eu.i.posthog.com (with the .i.) is the ingest endpoint your events go to, and eu.posthog.com (no .i.) is the UI host the SDK builds links against.
And don’t drop the static-assets rewrite, or the SDK bundle fails to load through the proxy.
If you ever need request-time logic, the alternative is a catch-all route handler at app/ingest/[...path]/route.ts (note [...path], not [path], which would forward only the first segment); the rewrites are canonical, so reach for it only when you must touch the request in flight.
Capturing server-side events with posthog-node
Section titled “Capturing server-side events with posthog-node”Sometimes the consent gate and the browser SDK are both irrelevant: an event that originates on the server, with no browser involved.
A Stripe webhook firing when a checkout completes, or a scheduled overnight job.
There’s no posthog-js and no consent flag to read, because there’s no client.
That’s what posthog-node is for.
This is a preview. How the server knows which user an event belongs to is the next lesson’s territory; here, the distinct ID is just a parameter the call needs.
import 'server-only';import { PostHog } from 'posthog-node';import { after } from 'next/server';import { env } from '@/env';
export const posthog = new PostHog(env.NEXT_PUBLIC_POSTHOG_KEY, { host: 'https://eu.i.posthog.com', flushAt: 1, flushInterval: 0,});
export const POST = async (req: Request) => { const event = await verifyStripeEvent(req);
posthog.captureImmediate({ distinctId: event.data.object.customer, event: 'subscription_started', properties: { plan: 'pro' }, });
after(() => posthog.shutdown()); return new Response(null, { status: 200 });};import 'server-only' makes a leaked import a build error, since this module carries a key and must never reach the browser. The client is constructed once at module scope. flushAt: 1, flushInterval: 0 turns off batching: a serverless function won’t live long enough to fill a batch, so every event sends on its own.
import 'server-only';import { PostHog } from 'posthog-node';import { after } from 'next/server';import { env } from '@/env';
export const posthog = new PostHog(env.NEXT_PUBLIC_POSTHOG_KEY, { host: 'https://eu.i.posthog.com', flushAt: 1, flushInterval: 0,});
export const POST = async (req: Request) => { const event = await verifyStripeEvent(req);
posthog.captureImmediate({ distinctId: event.data.object.customer, event: 'subscription_started', properties: { plan: 'pro' }, });
after(() => posthog.shutdown()); return new Response(null, { status: 200 });};Use captureImmediate, not capture, in serverless. capture() queues the event and returns, and the function can freeze before the send lands. captureImmediate() awaits the send.
import 'server-only';import { PostHog } from 'posthog-node';import { after } from 'next/server';import { env } from '@/env';
export const posthog = new PostHog(env.NEXT_PUBLIC_POSTHOG_KEY, { host: 'https://eu.i.posthog.com', flushAt: 1, flushInterval: 0,});
export const POST = async (req: Request) => { const event = await verifyStripeEvent(req);
posthog.captureImmediate({ distinctId: event.data.object.customer, event: 'subscription_started', properties: { plan: 'pro' }, });
after(() => posthog.shutdown()); return new Response(null, { status: 200 });};after(() => posthog.shutdown()) flushes pending events after the response is sent, so it doesn’t delay the user. Skip it and un-flushed events die when the Vercel function terminates.
distinctId is PostHog’s per-user identifier: read it as “which user this event is about”, and the next lesson covers how the server learns it.
The config (flushAt: 1, flushInterval: 0, and the shutdown() flush) is load-bearing for correctness on Vercel; ship it as-is.
Server-side capture has no technical consent gate, because it isn’t in the user’s browser and there’s nothing for useConsent() to gate.
But the moral gate still holds.
Only fire server-side behavioral events for users who accepted client-side, or for genuinely session-less events keyed by an upstream provider’s ID, such as an anonymous webhook.
It’s a discipline you carry in your head, not a mechanism the code enforces.
Proving the gate holds
Section titled “Proving the gate holds”“Wired” is not “trusted.” You’ve typed the gate; now prove it, with the same audit from the consent-gate lesson run against the real SDK. Three checks, in order.
Check one: the reject path.
In an incognito window, click Reject in the consent banner, or leave it undecided.
Open DevTools, the Network tab, and click a <Link> to navigate.
You should see no requests to /ingest or posthog.com, and no posthog-js chunk in the loaded scripts.
This is belt two proven, and the compliance-critical check: if anything PostHog-shaped appears before Accept, the gate is broken.
This is the test that later becomes a CI assertion.
Check two: the accept path.
Accept analytics, then click a <Link>.
Now you should see exactly one pageview request, going to /ingest same-origin, which proves the proxy is doing its job.
The posthog-js chunk is now present in the loaded scripts.
Check three: server confirmation. Open PostHog’s Live Events tab. Within a few seconds, your pageview lands, attributed to the project’s distinct ID.
Here’s the deliverable: the checklist you run against a real codebase, not just this exercise.
/ingest or posthog.com, and no posthog-js chunk loads./ingest pageview request, and the posthog-js chunk is now present.api_host is /ingest and ui_host is https://eu.posthog.com: the UI host has no .i., only the ingest host does.opt_out_capturing_by_default: true is present in the init config.Pass all six and PostHog is wired and provably non-leaking: the SDK is a module that doesn’t exist before consent, and is opted out even once it does. Two independent guarantees, each auditable on its own.
External resources
Section titled “External resources”These are the live references for the exact APIs you wired. The first is the canonical manual walkthrough, the settled default this lesson taught, not the beta wrapper.