In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.
If you want real product analytics and session replay in a Next.js App Router app — not just pageviews, and not in a way that breaks GDPR the moment you ship it — the pattern is: install posthog-js directly (not the snippet loader, not @posthog/next), initialize it opted out by default, gate capture behind your existing cookie consent, mask sensitive input before session replay ever records it, and route every event through one typed function instead of scattering posthog.capture() calls through your components. That's the shape of it. The part nobody's tutorial warns you about is what happens after you deploy it — and that part cost me a few hours I didn't expect to spend.
I just finished wiring this up for a client's Next.js 16 marketplace app, and shipped it all the way through to a real production deployment behind Docker and a self-hosted CI pipeline. The SDK setup took twenty minutes. Getting it to actually fire in production took a lot longer, because of a failure mode that doesn't show up in local development, doesn't show up in a "successful" deploy log, and doesn't show up unless you specifically go check the network tab. This guide covers both halves: the integration itself, and the deployment trap that will silently disable it if you don't know to look for it.
Why posthog-js directly, not the snippet or @posthog/next
PostHog gives you three ways to get their SDK into a page, and they're not interchangeable once session replay and privacy are in scope.
The classic <script> snippet — the one PostHog's own onboarding hands you by default — loads the SDK from a CDN and self-assigns to window.posthog. It works, but it's a black box you can't type-check, and it initializes unconditionally the moment the script tag executes, which makes consent-gating clumsy: you either avoid injecting the tag until consent is granted (delaying the CDN fetch on every page load) or you load it and then try to bolt opt-out behavior on afterward.
@posthog/next wraps the SDK for Next.js specifically, but at the time of writing PostHog itself labels it pre-release. I don't put pre-release packages in front of a paying client's production traffic, and you probably shouldn't either.
posthog-js installed as a normal npm dependency gives you the same SDK, minus the CDN round trip, with full TypeScript types, and — critically for this guide — direct control over exactly when .init() runs. That control is what makes consent-gating and privacy masking actually reliable instead of best-effort.
bash
pnpm add posthog-js
Client init in instrumentation-client.ts
Next.js has a dedicated convention for client-side instrumentation: a file named instrumentation-client.ts, sitting next to your app directory (not inside it), that runs once in the browser before your app hydrates. It's the right place for anything that needs to exist before React takes over — error tracking, and analytics init both belong here.
Three things in that config matter more than the rest. opt_out_capturing_by_default: true means the SDK loads but captures nothing until something explicitly opts it in — this is what makes consent-gating actually safe, versus loading the SDK and hoping you remembered to guard every capture call. The analyticsEnabled check means a missing or false NEXT_PUBLIC_ANALYTICS_ENABLED skips initialization entirely — local development and any environment that hasn't been given real PostHog credentials stays completely inert, with zero network calls to PostHog, which is exactly what you want on a laptop that isn't your production traffic. And capture_pageview: false is deliberate: App Router navigation doesn't match the classic multi-page-load model PostHog's autocapture assumes, so pageviews get fired explicitly later instead of relying on the default heuristic.
Wiring into your existing cookie consent
If your site already has a cookie consent banner — and if you've read the GDPR-compliant Vercel Analytics guide, you already know why it should — the mistake to avoid here is building PostHog a second, parallel consent store. It should hook into the one you already have.
Call applyAnalyticsConsent from the exact same accept/decline handlers your consent banner already calls for whatever else it gates — Google Tag Manager, a marketing pixel, whatever. Don't introduce a second cookie key or a second localStorage flag just for PostHog; one source of truth for consent means one place to audit when you need to prove GDPR compliance later, and one place to fix if the logic is ever wrong. On mount, also check whatever your existing consent storage already holds — if the user accepted on a previous visit, apply that immediately rather than waiting for them to click the banner again, since it won't show a second time.
Session replay privacy: masking inputs and sensitive text
Session replay is the single biggest reason to reach for PostHog over Vercel Analytics or GA4 — watching an actual recording of where someone hesitated in your checkout flow tells you things a funnel chart never will. It's also the single easiest way to accidentally record someone's password, email, or payment details if you don't configure masking before you ship it, not after.
maskAllInputs: true in the config above is the blanket default — every <input>, <textarea>, and <select> gets its value replaced with asterisks in the recording, full stop. That covers form fields. It doesn't cover rendered text: if your app ever prints a customer's email address or name back onto the page — a confirmation screen, an account settings page — that text is visible in the recording unless you mark it explicitly.
For an entire section that should never appear in a recording at all — a payment form, an identity verification step, anything with real financial or personal data — go further than masking and exclude it from capture entirely:
tsx
// File: src/components/checkout/PaymentDetails.tsx
<section data-ph-capture-attribute-exclude="true" className="ph-no-capture">
{/* payment fields never enter the recording, not even masked */}
</section>
Don't reach for .ph-mask on every element as a defensive habit, though — public prices, vehicle names, navigation labels, anything that isn't personal to the specific visitor should stay visible, or the recording becomes useless for the thing you built it for in the first place.
A typed trackEvent() wrapper
Once the SDK is initialized and consent is wired up, resist the urge to sprinkle posthog.capture('some_event', {...}) directly into every component that needs to fire something. Centralize it, and get compile-time safety on event names and their properties while you're at it.
The AnalyticsEventMap type means a typo in an event name or a missing required property fails at build time instead of silently vanishing in production — which matters more than it sounds like it should, because a dashboard built around an event that stopped firing three deploys ago is a very quiet kind of broken. The dataLayer.push is there for teams running Google Tag Manager alongside PostHog rather than instead of it; if you've already got GTM wired up per the Next.js GTM guide, this lets the same event reach both without duplicating the capture call at every site.
CSP additions
If your site runs a Content-Security-Policy — and it should — PostHog needs explicit allowances or its script, its session recording payloads, and its API calls will get silently blocked by the browser with nothing but a CSP violation buried in the console to explain why nothing's showing up.
The worker-src line is the one people forget: PostHog's session recorder runs part of its work in a web worker, and without blob: and data: allowed there, recording fails quietly rather than throwing anything you'd notice in normal testing.
The deployment gotcha: env vars that disappear at build time
Here's the part that actually cost time. Everything above worked flawlessly in local development the first time I ran it. It worked in a browser-based check against the compiled dev bundle. Then it shipped to production, the deploy pipeline reported success, the health check passed — and PostHog never received a single event.
The root cause has nothing to do with PostHog and everything to do with how Next.js handles NEXT_PUBLIC_* environment variables. They aren't read at runtime by the server process the way DATABASE_URL or a secret key would be. They're inlined as literal string values into the client JavaScript bundle at next build time. If a NEXT_PUBLIC_* variable isn't present in the environment when next build actually runs, Next.js compiles the reference to undefined — permanently, into that build's output — and no amount of setting the variable correctly in your runtime container afterward will change it. You'd have to rebuild.
That distinction is easy to miss if your deploy pipeline separates "build" from "run" into different steps with different environment sources — which is exactly what a Docker-based pipeline does by design. In my case, the build stage sourced its environment from a secret file assembled by a small shell script, and that script filtered which variables got forwarded into the Docker build through a hardcoded allowlist — a list that had never been updated to include the new PostHog variables. The runtime .env files on the server had the right values. The application code was correct. The deploy succeeded, the container passed its health check, and the client bundle it served had literally never seen the PostHog token at all.
bash
# The gotcha, made concrete: what actually gets read
next build # NEXT_PUBLIC_* vars get inlined into the client bundle HERE
docker run <image> # setting NEXT_PUBLIC_* vars here does nothing — too late
If you're on a similar split-stage pipeline — anything where docker build and docker run (or their Kubernetes/Nixpacks/whatever equivalents) read from different environment sources — go check whatever mechanism forwards environment variables into your build step specifically, not just your runtime container definition. A hardcoded allowlist, a .env file that only gets copied at deploy time, a CI secret that's scoped to "deploy" but not "build" — all of these produce the identical symptom: correct code, correct runtime config, a deploy that reports success, and a feature that's permanently, silently off until someone rebuilds with the variable actually present at build time.
Verifying it end-to-end
The lesson from the section above is really about verification method, so make it explicit: a green checkmark on your deploy pipeline proves the container started. It does not prove a NEXT_PUBLIC_*-dependent feature actually works. The only real proof is checking what the browser actually sent.
Open your production site in a real browser, open dev tools, and before accepting the cookie consent banner, confirm the network tab shows zero requests to your PostHog host — that's opt_out_capturing_by_default doing its job. Accept consent, and confirm you now see a POST request to <your-host>/e/ (event capture) with a 200 response, and — if session recording is enabled — a POST to <your-host>/s/ as well. If your PostHog host is on the EU cluster, that's typically eu.i.posthog.com; use whatever host your project settings actually show you, not a guessed value. Finally, open PostHog's Live Events view and confirm the same events are landing there in real time, with no personal data sitting in the event properties.
That sequence — no requests before consent, real 200s after consent, events visible in PostHog's own dashboard — is the only check that actually proves the integration works. Everything before that step is necessary but not sufficient.
Frequently asked questions
Does PostHog work with Google Tag Manager, or do I have to choose one?
You don't have to choose. Run PostHog directly via posthog-js for its own product analytics and session replay, and separately keep GTM loading whatever it already loads for GA4 or ad platforms. The trackEvent() wrapper above pushes to both posthog.capture() and window.dataLayer from one call site, so GTM can pick up any event you want it to see without a second, duplicate capture call anywhere in your components.
Is PostHog GDPR compliant out of the box?
PostHog's EU Cloud region keeps data in EU infrastructure and PostHog itself offers the contractual pieces (a DPA, documented subprocessors) that GDPR compliance requires. But compliance isn't something a vendor hands you by default — it's the combination of choosing the EU region, gating capture behind real consent rather than assuming it, masking or excluding personal data from session replay, and not sending PII in event properties. The setup in this guide is what makes that combination actually true for your specific implementation, not something PostHog does automatically regardless of how you wire it up.
Why initialize PostHog in instrumentation-client.ts instead of a React component or provider?
Because PostHog is a singleton once .init() has run — there's nothing a React component tree gives you that a plain module-level init doesn't, and instrumentation-client.ts runs before hydration, which means the SDK exists and is ready before your app's first render rather than racing a useEffect in some top-level component. A dedicated React context for PostHog is unnecessary complexity for something that's fundamentally a global, one-time setup step.
What happens to session replay if maskAllInputs is on but I forget to mask rendered text?
Form field values stay masked correctly, but any personal data your app renders back onto the page as plain text — a name, an email, an address — will appear unmasked in the recording, because maskAllInputs only touches actual <input>/<textarea>/<select> elements, not arbitrary DOM text. That's exactly the gap .ph-mask and data-private close, and it's worth an explicit pass through your app looking for anywhere customer data gets echoed back before you turn recording on in production.
How do I know if my deploy pipeline has the same env-var problem described above?
Check whichever step actually invokes next build — a Dockerfile RUN instruction, a CI build job, a Nixpacks/Vercel build step — and confirm the NEXT_PUBLIC_* variables your code references are genuinely present in that step's environment, not just in whatever .env file your container reads when it starts running afterward. If your build step gets its environment from a script, a secrets manager scope, or an allowlist rather than directly from your full .env file, that's the exact place a newly added variable can go missing without anything in your pipeline telling you.
Wrapping up
PostHog gets you real product analytics and session replay without the black-box feel of a CDN snippet, but the SDK install is the easy twenty percent of the work. The parts that actually matter for shipping it responsibly are wiring it into consent you already have instead of building a second system, masking session replay before real user data ever touches it, and — the one most guides skip entirely — understanding that NEXT_PUBLIC_* variables live and die at build time, not runtime, which means "the deploy succeeded" and "the feature actually works" are two different claims that need two different checks.
If you're setting this up and want a second pair of eyes on your deploy pipeline specifically — the build-vs-runtime env var split is the kind of thing that's obvious once you've been bitten by it once and invisible until then — that's exactly the kind of implementation work I take on for clients. Let me know in the comments if you run into something this guide didn't cover, and subscribe if you want more practical, real-implementation guides like this one.
Thanks,
Matija
Tested with Next.js 16 (App Router) and posthog-js 1.409.x, deployed to a Docker-based production pipeline. Last updated 2026-08-02.