Skip to content
Chapter 93Lesson 3

Wiring PostHog through the consent gate

Install the PostHog product-analytics SDK so it loads only after a user grants consent, relayed through your own domain past ad-blockers.

When you built the consent gate, you wired up a hook called useConsent() and proved that nothing non-essential could fire before the user agreed. The worked example in that lesson was PostHog, but you never actually installed it. The lesson said, in so many words, that wiring PostHog for real was a later chapter’s job.

This is that chapter. You are not opening a new topic; you are finishing the work you started when you built the gate. By the end of this lesson, a deliberate test pageview will reach PostHog after the user accepts analytics: sent to the EU region, 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. That second half is the part that matters, because the gate is a compliance promise, and a promise you can’t verify isn’t worth much.

You already have everything you need to build on. The useConsent() hook returns { analytics, marketing, open, accept, reject }, and the only piece this lesson cares about is the analytics boolean. You have a root <Providers> Client Component from the TanStack Query chapter, the single place where app-wide client context gets mounted. And you have the typed env boundary that decides which secrets are allowed to reach the browser. PostHog slots into all three.

There is one new idea to hold onto, and the rest of the lesson follows from it: 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.

You met the two-belt model in the consent-gate lesson, so this is a reminder, not a re-teach. Belt one is opt_out_capturing_by_default: true, an init option that loads the SDK in a disabled state, so even a fully-present module captures nothing until you explicitly opt it in. Belt two is the stronger guarantee: a dynamic import('posthog-js') that only runs after the consent flag flips, so the SDK code never enters the page at all while consent is absent. Belt one governs a module that’s already loaded; belt two keeps it from loading in the first place. In the consent-gate lesson you reasoned about these conceptually. This lesson builds belt two for real and adds belt one underneath it as the structural floor, so that if the gate were ever bypassed, the module that slipped through still stays silent.

Before any code, get the map straight. PostHog’s App Router setup involves three package names, and knowing which one does what makes the rest of the lesson easier to follow.

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.

There’s a judgment call here worth making explicit. A 2026 wrapper, @posthog/next, folds these pieces together, and it’s tempting to reach for it as the obvious modern default. As of this writing it’s published but still marked beta, and PostHog’s own Next.js documentation still walks the manual posthog-js plus posthog-node wire as the main path. So that manual wire is what you’ll build. It’s the shape you actually own and understand, and it won’t break under you if the wrapper’s API shifts before it stabilizes. Treat @posthog/next as the convenience layer you graduate to once it leaves beta. It will fold in the /ingest proxy, keep the browser and server distinct IDs in sync, and bootstrap feature flags. The first two you’ll wire by hand in this lesson. The flag bootstrap is a later lesson’s job, so leave it alone for now.

One piece of vocabulary turns up in the env section. PostHog gives each project a project key . It’s safe in the browser by design, and that design decision is what the next section is about.

The env boundary: three keys, one of which must never reach the client

Section titled “The env boundary: three keys, one of which must never reach the client”

The provider you’re about to write reads its configuration from your typed env, so wire the variables first. There are three, and the point of this section is the firewall between them. Adding them is three entries in a file you already understand. This isn’t a re-tour of how the env boundary works, only an application of its client/server split to one new credential.

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. The NEXT_PUBLIC_ prefix is deliberate, because these are designed to ship to the browser. The project key is write-only at the ingest endpoint, so a reader who pulls it out of 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, and later lessons use it for server-side flag evaluation and admin reads. 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’s browser.

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 is mapped to its process.env source. This is the wiring the schema needs to read the actual values at runtime.

1 / 1

The host is https://eu.i.posthog.com, the EU Cloud region you settled on when you chose PostHog. The key idea is the prefix itself: NEXT_PUBLIC_ is the firewall. The two public keys belong in the browser bundle; the personal key never does. Because the env schema splits server from client, a misnamed NEXT_PUBLIC_POSTHOG_PERSONAL_KEY becomes a build error instead of a silent leak. “Ship a key to the client” reads like a bug on reflex, but here, for the two public keys, it’s correct by design. The schema makes that distinction enforceable rather than a comment you hope someone reads.

This is the component the whole lesson builds toward. You’re going to write a PostHogProvider, a 'use client' component that is belt two. Read it as a starter piece you own and can extend, the same way you treated the consent provider.

Build the shape in your head before you read the code. The provider:

  • Mounts inside your existing root <Providers>, and crucially inside the ConsentProvider. Order matters: ConsentProvider has to be an ancestor, because PostHogProvider calls useConsent(), and that hook throws if it’s used outside its provider. This isn’t a competing root; it slots into the one provider tree your app already has.
  • Reads const { analytics } = useConsent(). The gate is that boolean: not a string state, not a four-valued enum, just the analytics flag the consent contract hands you.
  • When analytics is false, the provider renders its children and the dynamic import is never reached. The default, a reject, and a not-yet-decided user all collapse to “off” here, so none of them load anything. No posthog-js in the executed path, no network requests, nothing.
  • When analytics flips to true, an effect dynamically imports the SDK, initializes it with belt one baked into the config, and then opts capturing in.
  • When analytics flips back to false, which is a withdrawal, the effect tears everything down: it opts out and resets, stopping any queued events. This mirrors the revocation discipline the consent gate already taught.

One note before the code, because this can read as an anti-pattern if you’ve absorbed the “you probably don’t need an effect” rule. Loading a third-party SDK is a sanctioned use of useEffect: it synchronizes your component with an external system, which is exactly what effects are for. This isn’t deriving state or handling an event; it’s wiring your component up to a widget that lives outside React.

'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. useConsent() is the single source of truth, and you read exactly one thing from it: the analytics boolean. Everything below hangs off this value.

'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 is ever reached. This is belt two: the SDK code on the next line does not enter the page. The default, a reject, and an undecided user 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, only now, at the first moment analytics is true. Before this line runs, 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. Even now that the module exists, it captures nothing until told otherwise, so if the gate above were ever bypassed, the module that slipped through 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 is the line that 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 capturing out and calls reset() to clear the queue and the stored identity. Withdrawal is not “stop sending future events”; it’s “stop, and forget.” The identity side of reset() gets its full treatment in the next lesson.

1 / 1

Notice what the analytics dependency does: the effect re-runs whenever consent changes, 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 async import resolving and the effect being torn down, a small correctness detail rather than the point of the lesson.

Now the nesting, which is a separate decision from the provider’s internals and deserves its own focused look. 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}. The ancestor relationship is the contract: PostHogProvider can only call useConsent() because ConsentProvider sits above it in the tree.

To make sure the ordering landed, since this is the exact misconception that ships to production, 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

That ordering is the rule. 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. But a top-level import means the SDK code is in the page on first load, consent or not. Belt one alone doesn’t save you there: 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 knobs in that init config deserve their own look, because they’re where “it works in dev but the numbers are wrong” bugs live.

The first is defaults. PostHog bundles its recommended settings, covering autocapture behavior, pageview handling, and exception capture, behind a single dated snapshot. Pinning a specific date is deliberate: it means a future change to PostHog’s defaults can’t silently change your app’s behavior out from under you. The value above is '2026-01-30', the current snapshot at the time of writing. The discipline is pinning; the date itself is a value to confirm against PostHog’s docs and revisit when they ship a new default set.

The second knob is more interesting, and it holds regardless of which exact option is current. The App Router navigates with history.pushState, which is how next/link and useRouter().push() move you between routes without a full page reload. That client-side navigation is invisible to PostHog’s automatic pageview tracking, so a naive setup either misses every in-app navigation or double-counts the first one. The fix is to turn the 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') whenever either changes, which is every client-side navigation, counted exactly once. The Suspense wrap isn’t optional: useSearchParams() opts its subtree into client rendering, and the App Router makes a missing boundary a build error. You render <PostHogPageView /> once, high in the tree, inside the provider or alongside it. Note that it imports posthog directly: by the time a navigation fires, the user has consented and the SDK is initialized, so the singleton is live.

The /ingest proxy: a same-origin relay past ad-blockers

Section titled “The /ingest proxy: a same-origin relay past ad-blockers”

Here’s a failure mode that stays invisible until you measure it. Ad-blockers heuristically block requests to i.posthog.com and the EU host, and a real, non-trivial slice of your users runs one. Without a workaround, those users’ events vanish silently. Nothing throws, nothing logs; your numbers are just quietly wrong, biased against exactly the privacy-conscious users you’d most want to count correctly.

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. This isn’t an optional nicety in 2026; it’s the difference between trustworthy data and data with a hole in it.

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. The first relays the SDK’s own static assets; the second relays the ingest traffic. Both forward /ingest/* on your domain to PostHog’s EU hosts. skipTrailingSlashRedirect stops Next.js from appending a slash that would break PostHog’s API paths. Order matters: the more specific /static rule comes first.

Two things to get exactly right, because both are documented gotchas. First, 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. Second, don’t drop the static-assets rewrite, because without it the SDK bundle itself fails to load through the proxy. If you ever need request-time logic in the relay, the alternative is a catch-all route handler at app/ingest/[...path]/route.ts. Note the [...path], not [path], or it forwards only the first path segment. The rewrites approach is the canonical one; reach for the route handler only when you need to touch the request in flight. When you eventually adopt @posthog/next, this whole proxy is one of the things it folds in for you.

The server-side moment: capturing without a browser

Section titled “The server-side moment: capturing without a browser”

There’s one case where 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 job running overnight. There’s no posthog-js here and no consent flag to read, because there’s no client; the event is born server-side. That’s what posthog-node is for.

This is a preview of server capture, not the full story. The mechanics of how the server knows which user an event belongs to are 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. Plain capture() is fire-and-async: it queues the event and returns, and the function can freeze before the HTTP send lands. captureImmediate() awaits the send. The distinctId is glossed over for now; the next lesson covers how the server obtains it.

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 any pending events after the response is sent, so it doesn’t delay the user. Skip this and un-flushed events die when the Vercel function terminates, which is the headline server-side gotcha. after runs the flush off the critical path; an inline await shutdown() is the fallback.

1 / 1

The distinctId above is PostHog’s per-user identifier; read it as “which user this event is about” for now, and the next lesson covers how a server request learns it. The config in that snippet is not simplified: flushAt: 1, flushInterval: 0, and the shutdown() flush are all load-bearing for correctness on Vercel, and you should ship them as-is.

One nuance trips people up. Server-side capture has no technical consent gate, because it isn’t running in the user’s browser, so 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. Don’t server-fire behavioral events for a user who rejected just because the code path technically can. This is a discipline you carry in your head, not a mechanism the code enforces for you.

“Wired” is not “trusted.” You’ve typed the gate; now prove it. You already know the shape of this audit from the consent-gate lesson; this is the same routine, run now against the real SDK instead of the conceptual one. Three checks, in order.

Check one: the reject path. In an incognito window, click Reject in the consent banner, or just leave it undecided. Open DevTools and the Network tab. Click a <Link> to navigate. You should see no requests to /ingest or to posthog.com, and no posthog-js chunk in the loaded scripts. This is belt two, proven: the module is not in the page. This is the compliance-critical check. If anything PostHog-shaped appears before Accept, the gate is broken, and this is exactly the test that later becomes a CI assertion.

Check two: the accept path. Accept analytics. Click a <Link>. Now you should see exactly one pageview request, and it goes 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. The event made it all the way through.

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 you’ve done what the consent-gate lesson promised: PostHog is wired, and you can prove it doesn’t leak. The SDK is a module that doesn’t exist before consent, and is opted out even once it does. Those are two independent guarantees, each one 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.