PostHog is the best open-source analytics platform for SaaS products — funnel analysis, session recordings, feature flags, and A/B tests all in one place. But wiring it up correctly in Next.js App Router, with server-side events and GDPR cookie consent, takes more than running npm install posthog-js. This guide covers the complete setup used in GetLaunchpad.
Install the packages
You need two PostHog packages: posthog-js for the browser provider and posthog-node for server-side event tracking from route handlers and Server Components.
npm install posthog-js posthog-node
Add your PostHog host and public API key to .env.local. The public key is safe to expose to the browser — it is read-only and cannot ingest data on behalf of other projects.
NEXT_PUBLIC_POSTHOG_KEY=phc_xxxxxxxxxxxxxxxxxxxx
NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
Wrap the app in PostHogProvider
PostHog's React provider needs to live in a Client Component because it initializes the browser SDK. Create a thin wrapper and drop it into your root layout.
// app/providers/PostHogProvider.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 fire these manually via usePostHog
persistence: "localStorage",
});
}, []);
return <PHProvider client={posthog}>{children}</PHProvider>;
}
// app/layout.tsx (root layout — server component)
import { PostHogProvider } from "./providers/PostHogProvider";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<PostHogProvider>{children}</PostHogProvider>
</body>
</html>
);
}
Tracking server-side events with the Node.js client
The browser SDK only fires events when a user is sitting at a tab. Key SaaS moments — subscription activation, cancellation, webhook-triggered emails — happen in route handlers with no browser context at all. For these, create a server-side PostHog client using posthog-node.
// lib/posthog.ts
import "server-only";
import { PostHog } from "posthog-node";
const posthogClient = new PostHog(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
flushAt: 1, // send immediately — route handlers are short-lived
flushInterval: 0,
});
export async function trackEvent(
distinctId: string,
event: string,
properties?: Record<string, unknown>,
) {
posthogClient.capture({ distinctId, event, properties });
await posthogClient.flush();
}
Setting flushAt: 1 and flushInterval: 0 ensures the event is sent before the serverless function exits. The default batching settings assume a long-lived server process — in a Next.js route handler, the process may terminate before the batch flushes.
Implementing GDPR cookie consent gating
Under GDPR and ePrivacy, you cannot drop analytics cookies or fingerprint users without explicit consent. The correct pattern is to defer PostHog initialization until the user accepts — not to initialize it and then call posthog.opt_out_capturing() afterward. Initializing first and then opting out still runs the initialization code, which itself may constitute processing.
Store the consent decision in localStorage and check it before calling posthog.init:
// app/providers/PostHogProvider.tsx (consent-gated version)
"use client";
import posthog from "posthog-js";
import { PostHogProvider as PHProvider } from "posthog-js/react";
import { useEffect } from "react";
function initPostHog() {
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
capture_pageview: false,
persistence: "localStorage",
});
}
export function PostHogProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
const consent = localStorage.getItem("cookie_consent");
if (consent === "accepted") initPostHog();
}, []);
return <PHProvider client={posthog}>{children}</PHProvider>;
}
// Call this from your cookie banner's "Accept" button
export function acceptCookies() {
localStorage.setItem("cookie_consent", "accepted");
initPostHog();
}
Your cookie banner component imports acceptCookies and calls it when the user clicks Accept. Declined users never have PostHog initialized — no cookies, no fingerprinting, no GDPR exposure.
Tracking key SaaS events
For a SaaS product, four events cover the most important moments in the user lifecycle. Track them with consistent naming so your PostHog funnels line up:
// Fired in POST /api/user (first dashboard load, server-side)
await trackEvent(clerkId, "sign_up", { email });
// Fired in POST /api/stripe/checkout (server-side)
await trackEvent(clerkId, "checkout_started", { plan });
// Fired in POST /api/stripe/webhook on checkout.session.completed
await trackEvent(customerId, "subscription_activated", { plan, status: "active" });
// Fired in POST /api/stripe/webhook on customer.subscription.deleted
await trackEvent(customerId, "subscription_canceled", { plan });
All four fire from server-side route handlers using the Node.js client, so they are captured regardless of whether the user has an active browser tab or has blocked JavaScript. Webhook-sourced events (activation, cancellation) are especially important to capture server-side because the user is often not present at all when they fire.
Custom events with usePostHog
For client-side events — button clicks, feature interactions, form submissions — use the usePostHog hook from posthog-js/react. It returns the initialized PostHog instance (or undefined if consent has not been given yet, which is safe to call conditionally):
"use client";
import { usePostHog } from "posthog-js/react";
export function UpgradeButton() {
const posthog = usePostHog();
function handleClick() {
posthog?.capture("upgrade_clicked", { location: "dashboard_banner" });
// ... open checkout
}
return <button onClick={handleClick}>Upgrade to Pro</button>;
}
The optional chaining on posthog?. means the component works correctly even before the user has accepted cookies — the capture call is a no-op rather than a crash.
All of this — the PostHog provider, server-side event tracking for the four key SaaS lifecycle events, and cookie consent gating — is pre-wired in GetLaunchpad, a Next.js 16 SaaS boilerplate. Get private repo access and ship analytics-ready from day one.