Skip to content
Chapter 93Lesson 3

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.

PostHog’s App Router setup spans three package names; here’s which does what.

Terminal window
pnpm add posthog-js posthog-node
posthog-js

The browser SDK. Events, the feature-flag client, session-replay capture.

runs in the browser
posthog-node

The server SDK. Capturing an event when there is no browser in the room — a Stripe webhook, a scheduled job.

runs on the server
@posthog/next beta

The App Router convenience layer. Folds the proxy route, the distinct-ID cookie, and flag bootstrap into one wrapper.

wraps both
Three packages, three surfaces.

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.

1 / 1

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.

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 inside ConsentProvider. Order matters: ConsentProvider must be an ancestor, because PostHogProvider calls useConsent(), which throws if used outside its provider.
  • Reads const { analytics } = useConsent(). The gate is that one boolean.
  • When analytics is false, 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 analytics flips to true, an effect dynamically imports the SDK, inits it with belt one baked in, then opts capturing in.
  • When analytics flips back to false (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.

1 / 1

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.

app/_components/providers.tsx
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.

The page renders with analytics still false
The user clicks Accept in the consent banner
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 first event is captured and sent

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.

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.

app/_components/posthog-pageview.tsx
'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.

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.

1 / 1

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.

“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.

Reject (or undecided): the Network tab shows zero requests to /ingest or posthog.com, and no posthog-js chunk loads.
untested
Accept: clicking a link fires exactly one /ingest pageview request, and the posthog-js chunk is now present.
untested
The pageview lands in PostHog’s Live Events tab within a few seconds.
untested
api_host is /ingest and ui_host is https://eu.posthog.com: the UI host has no .i., only the ingest host does.
untested
The personal API key is absent from the client bundle: search the Network scripts and built bundle for it and find nothing.
untested
opt_out_capturing_by_default: true is present in the init config.
untested

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.

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.