PostHog with Next.js: Product Analytics, Feature Flags, and Session Replays (2026 Guide)

PostHog has quietly become the most complete product platform for SaaS teams in 2026. What used to require Mixpanel for analytics, LaunchDarkly for feature flags, FullStory for session replay, Optimizely for A/B testing, and Sentry for errors now lives inside one open-source platform with a generous free tier and a self-hostable option.
In this tutorial you'll wire PostHog into a Next.js 15 App Router project end-to-end. By the end you'll be tracking events, gating features with flags, recording session replays, running an experiment, and capturing exceptions — using the modern posthog-js/react provider and proper SSR/client patterns.
Prerequisites
Before starting, ensure you have:
- Node.js 20 or newer
- Basic familiarity with Next.js App Router and React Server Components
- A PostHog Cloud account (free tier is fine) — sign up at posthog.com, or run a self-hosted instance
- A code editor (VS Code recommended)
You should also be comfortable with environment variables and the difference between server and client components in Next.js.
What You'll Build
You'll build a small SaaS-style dashboard that:
- Captures pageviews and custom events on both client and server
- Identifies authenticated users and attaches properties to them
- Conditionally renders a "new dashboard" UI behind a feature flag
- Runs an A/B experiment on a pricing page CTA
- Records session replays with input masking
- Reports unhandled exceptions to PostHog Error Tracking
The final repo structure will look like this:
app/
layout.tsx
providers.tsx
page.tsx
pricing/page.tsx
api/track/route.ts
lib/
posthog-server.ts
.env.local
Step 1: Create the Project and Install Dependencies
Start with a clean Next.js 15 project (skip this if you already have one):
npx create-next-app@latest posthog-demo --typescript --app --tailwind --eslint
cd posthog-demoInstall the PostHog SDKs. You need two packages: posthog-js for the browser, and posthog-node for server-side capture.
npm install posthog-js posthog-nodeIn 2026, the official Next.js integration is documented around the posthog-js/react provider, which gives you a hook-based API and avoids manual useEffect capture calls.
Step 2: Configure Environment Variables
Create a .env.local file in the project root:
NEXT_PUBLIC_POSTHOG_KEY=phc_your_project_api_key
NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com
POSTHOG_PERSONAL_API_KEY=phx_your_personal_api_keyA few important details:
NEXT_PUBLIC_*variables are exposed to the browser — that's intentional for the project key, which is a public token meant for client capture.- Use
eu.i.posthog.comif your PostHog project lives in the EU, otherwiseus.i.posthog.com. Picking the wrong region silently drops events. - The personal API key is only used for server admin tasks (like reading flag definitions); it is never sent to the browser.
Step 3: Set Up the Browser Provider
Create app/providers.tsx. This file is a client component that initializes PostHog once and exposes it to the rest of your tree via PostHogProvider.
// app/providers.tsx
"use client";
import posthog from "posthog-js";
import { PostHogProvider as PHProvider } from "posthog-js/react";
import { useEffect } from "react";
export function PostHogProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
capture_pageview: false, // we handle this manually for App Router
capture_pageleave: true,
person_profiles: "identified_only",
session_recording: {
maskAllInputs: true,
},
});
}, []);
return <PHProvider client={posthog}>{children}</PHProvider>;
}Two choices worth understanding:
capture_pageview: false— Next.js App Router does soft navigation, so the SDK's automatic pageview only fires once per hard load. We'll capture pageviews ourselves on every route change.person_profiles: "identified_only"— anonymous visitors don't create person profiles, which keeps your MAU bill lower until you actually identify a user.
Step 4: Track Pageviews on Route Changes
Add a small client component that listens to App Router navigation and fires a $pageview event each time the path changes.
// app/posthog-pageview.tsx
"use client";
import { usePathname, useSearchParams } from "next/navigation";
import { usePostHog } from "posthog-js/react";
import { useEffect } from "react";
export function PostHogPageview() {
const pathname = usePathname();
const searchParams = useSearchParams();
const posthog = usePostHog();
useEffect(() => {
if (!pathname || !posthog) return;
let url = window.origin + pathname;
const search = searchParams?.toString();
if (search) url += `?${search}`;
posthog.capture("$pageview", { $current_url: url });
}, [pathname, searchParams, posthog]);
return null;
}Now wire both into the root layout:
// app/layout.tsx
import { Suspense } from "react";
import { PostHogProvider } from "./providers";
import { PostHogPageview } from "./posthog-pageview";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<PostHogProvider>
<Suspense fallback={null}>
<PostHogPageview />
</Suspense>
{children}
</PostHogProvider>
</body>
</html>
);
}The Suspense wrap is required because useSearchParams triggers a CSR bailout — without it, the entire route deopts to client-side rendering.
Step 5: Capture Custom Events from Components
You can now capture events from any client component using the usePostHog hook:
// app/page.tsx
"use client";
import { usePostHog } from "posthog-js/react";
export default function Home() {
const posthog = usePostHog();
return (
<main className="p-12">
<h1 className="text-3xl font-bold">Welcome</h1>
<button
className="mt-6 rounded bg-black px-4 py-2 text-white"
onClick={() =>
posthog.capture("cta_clicked", {
location: "homepage_hero",
variant: "primary",
})
}
>
Get Started
</button>
</main>
);
}Two rules of thumb for event design:
- Event names use
snake_caseand a verb in past tense:signup_completed,invoice_downloaded,chat_message_sent. Avoid generic names likeclickorsubmit. - Properties are flat, typed, and bounded. Don't dump full objects; pick the 3 to 8 fields you'll actually filter on later.
Step 6: Identify Authenticated Users
Anonymous events are useful for funnels, but the real value comes from connecting events to a known user. Call identify once after login:
"use client";
import { usePostHog } from "posthog-js/react";
import { useEffect } from "react";
type User = { id: string; email: string; plan: "free" | "pro" | "team" };
export function IdentifyUser({ user }: { user: User | null }) {
const posthog = usePostHog();
useEffect(() => {
if (!user || !posthog) return;
posthog.identify(user.id, {
email: user.email,
plan: user.plan,
});
posthog.group("plan", user.plan);
}, [user, posthog]);
return null;
}When the user signs out, call posthog.reset() to detach the distinct ID and start a fresh anonymous session.
Step 7: Capture Server-Side Events
Some events should never live in the browser — payment success, webhook receipts, AI inference completed. For those, use posthog-node.
Create lib/posthog-server.ts:
// lib/posthog-server.ts
import { PostHog } from "posthog-node";
let client: PostHog | null = null;
export function getPostHogServer() {
if (!client) {
client = new PostHog(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
flushAt: 1,
flushInterval: 0,
});
}
return client;
}The aggressive flush settings are important on serverless: with the default batching, events captured inside a Vercel function would be lost when the lambda freezes before flush.
Use it in a route handler:
// app/api/track/route.ts
import { NextResponse } from "next/server";
import { getPostHogServer } from "@/lib/posthog-server";
export async function POST(req: Request) {
const { userId, plan } = await req.json();
const posthog = getPostHogServer();
posthog.capture({
distinctId: userId,
event: "subscription_started",
properties: { plan, source: "stripe_webhook" },
});
await posthog.shutdown();
return NextResponse.json({ ok: true });
}Always await posthog.shutdown() (or at least posthog.flush()) before returning from a serverless handler. Otherwise the event sits in an in-memory queue that disappears with the lambda.
Step 8: Ship a Feature Behind a Flag
Create a boolean feature flag in the PostHog UI under Feature Flags called new-dashboard. Set the rollout to "100% of users with property plan = pro" so only paying users see it.
On the client, the useFeatureFlagEnabled hook reads the cached flag:
// app/dashboard/page.tsx
"use client";
import { useFeatureFlagEnabled } from "posthog-js/react";
export default function Dashboard() {
const newDashboard = useFeatureFlagEnabled("new-dashboard");
if (newDashboard) {
return <NewDashboard />;
}
return <LegacyDashboard />;
}There's a subtle UX problem here: on the very first render the flag is undefined, then flips to true or false, causing a visible flicker. Fix it by evaluating the flag on the server and passing the resolved value down.
// app/dashboard/page.tsx
import { getPostHogServer } from "@/lib/posthog-server";
import { cookies } from "next/headers";
export default async function DashboardPage() {
const posthog = getPostHogServer();
const distinctId = (await cookies()).get("ph_distinct_id")?.value ?? "anonymous";
const newDashboard = await posthog.isFeatureEnabled("new-dashboard", distinctId);
return newDashboard ? <NewDashboard /> : <LegacyDashboard />;
}For server-side evaluation to make sense, your distinct ID must be stable across requests. The simplest approach is to read it from a cookie set after identify, or to use the user's ID from your auth provider.
Step 9: Run an A/B Experiment
Multivariate flags power experiments. In the PostHog UI, create an experiment called pricing_cta_test with two variants: control (text "Start free trial") and bold (text "Get started for free, no credit card").
// app/pricing/page.tsx
"use client";
import { useFeatureFlagVariantKey, usePostHog } from "posthog-js/react";
export default function PricingPage() {
const variant = useFeatureFlagVariantKey("pricing_cta_test");
const posthog = usePostHog();
const ctaText = variant === "bold"
? "Get started for free, no credit card"
: "Start free trial";
return (
<button
onClick={() => {
posthog.capture("pricing_cta_clicked", { variant });
}}
>
{ctaText}
</button>
);
}Always include the variant value as a property on the conversion event. PostHog can then compute lift, statistical significance, and credible intervals automatically — and you'll have the raw data in the warehouse view if you want to slice it yourself.
Step 10: Enable Session Replay with Input Masking
Session replay was already turned on in Step 3 with maskAllInputs: true. That covers the basics, but you should be more deliberate about what gets recorded. Add CSS classes to elements you never want captured:
<input
type="text"
className="ph-no-capture"
placeholder="Tax ID"
/>
<div className="ph-mask">
<p>Sensitive contract terms here.</p>
</div>Three classes are recognized by the recorder:
ph-no-capture— element is treated as if it doesn't exist in the DOM snapshotph-mask— element is replaced with a black rectangle of the same sizeph-mask-text— text inside is replaced with asterisks but layout is preserved
Combine these with maskAllInputs: true to ship replay safely, even in regulated industries. For full GDPR compliance, also gate replay capture on user consent before calling posthog.startSessionRecording().
Step 11: Capture Exceptions
PostHog's Error Tracking product hooks into the same SDK. Turn it on by enabling autocapture for exceptions in the init call:
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
// ... existing config
capture_exceptions: true,
});You can also report errors manually with stack traces from a Next.js error boundary:
// app/error.tsx
"use client";
import { useEffect } from "react";
import { usePostHog } from "posthog-js/react";
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
const posthog = usePostHog();
useEffect(() => {
posthog.captureException(error, {
digest: (error as Error & { digest?: string }).digest,
});
}, [error, posthog]);
return (
<div>
<h2>Something went wrong</h2>
<button onClick={reset}>Try again</button>
</div>
);
}This populates the Errors tab in PostHog with a stack trace, breadcrumbs from prior events, and a link to the session replay where the error occurred — typically the killer feature that justifies the migration from Sentry.
Step 12: Block Ad Blockers with a Reverse Proxy
In production, around 30 percent of visitors run an ad blocker that blocks *.posthog.com. The fix is a reverse proxy through your own domain. In Next.js 15, add this to next.config.ts:
const nextConfig = {
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*",
},
];
},
skipTrailingSlashRedirect: true,
};Then change the SDK init to use your own origin:
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
api_host: "/ingest",
ui_host: "https://eu.posthog.com",
});Recovered events from blocked traffic typically lift conversion data by 10 to 30 percent — well worth the five minutes of config.
Testing Your Implementation
Verify everything works before declaring victory:
- Open the PostHog Activity tab and load your homepage in an incognito window. You should see a
$pageviewevent within a few seconds. - Click the "Get Started" button and confirm
cta_clickedarrives with thelocationandvariantproperties. - Sign in to the app and check that the next event has the user's email and plan attached as person properties.
- Toggle the
new-dashboardflag in the UI and reload the dashboard route — the rendered component should change without a redeploy. - Open Session Replay and confirm input fields are masked.
- Trigger an unhandled exception and verify it appears in the Errors tab with a stack trace.
If events take more than a minute to appear, the most common cause is a region mismatch in NEXT_PUBLIC_POSTHOG_HOST.
Troubleshooting
Events fire on localhost but not in production. Almost always a content security policy issue or an ad blocker. Use the reverse proxy from Step 12 and check the Network tab for blocked /ingest requests.
Flag returns undefined forever. The SDK has not finished bootstrapping. Either await posthog.onFeatureFlags() or do server-side evaluation as shown in Step 8.
Server events never arrive. Forgot to await posthog.shutdown() at the end of the route handler. Serverless freezes the function, the in-memory batch is lost.
MAU bill explodes after launch. You forgot person_profiles: "identified_only" and PostHog is creating a profile for every anonymous visitor. Switch the setting and contact support to reset profile counts.
Next Steps
- Pipe PostHog events into your data warehouse with the BigQuery, Snowflake, or Postgres destinations
- Combine with Resend transactional emails to trigger emails from PostHog cohorts
- Add Better Auth and identify users right after login
- Layer in Sentry replacement for full-stack tracing on the backend
Conclusion
PostHog turns five separate SaaS subscriptions into a single open-source platform you can self-host if you ever need to. With the steps above you have analytics, feature flags, experiments, session replay, and error tracking running in a Next.js 15 App Router app — properly server-rendered, ad-block resilient, and privacy-aware.
The real leverage shows up over the next few weeks: every product decision now has a number behind it, every feature ships behind a flag you can flip in seconds, and every bug report comes with a replay attached. That is the workflow shift worth the half-day of setup.
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.
Related Articles

Build a SaaS Starter Kit with Next.js 15, Stripe Subscriptions, and Auth.js v5
Learn how to build a production-ready SaaS application with Next.js 15, Stripe for subscription billing, and Auth.js v5 for authentication. This step-by-step tutorial covers project setup, OAuth login, pricing plans, webhook handling, and protected routes.

Building an Autonomous AI Agent with Agentic RAG and Next.js
Learn how to build an AI agent that autonomously decides when and how to retrieve information from vector databases. A comprehensive hands-on guide using Vercel AI SDK and Next.js with executable examples.

Build a Semantic Search Engine with Next.js 15, OpenAI, and Pinecone
Learn how to build a production-ready semantic search engine using Next.js 15, OpenAI Embeddings, and Pinecone vector database. This comprehensive tutorial covers setup, indexing, querying, and deployment.