Methodology. This tutorial synthesizes the PostHog Next.js documentation as of May 2026. Code patterns mirror the canonical PostHog Next.js examples published at posthog.com/docs/libraries/next-js and the PostHog Node.js docs at posthog.com/docs/libraries/node. Pricing and quotas come from posthog.com/pricing; reconfirm before relying on the numbers in production.

Product analytics is the layer between “the app works” and “we know which parts of the app actually get used.” For a solo SaaS founder, the question is rarely whether to add analytics — it is which tool, how much PII to send, and how to wire it without slowing the page load or losing a third of the events to ad blockers. PostHog is the answer most solo founders converge on because it bundles autocapture, custom events, session recording, and feature flags under one free tier, and because it self-hosts cleanly when the audit team gets nervous about US data.

This guide walks the canonical Next.js App Router setup from the official docs, adds the production-grade hardening (server-side capture, the reverse proxy, identify on login), and ends with the five events almost every B2B SaaS ends up wishing it had captured from day one.

1 Sign up for PostHog Cloud (US vs EU)

PostHog Cloud runs in two regions: us.i.posthog.com and eu.i.posthog.com. The choice is permanent at project creation time — data does not migrate between regions, so pick before you start sending events. The right answer depends on where your customers’ data has to live, not where your team sits.

  • US instance. Default for North American B2C and most US-based B2B SaaS. Lower latency for North American users. Subject to US disclosure laws.
  • EU instance. Required if any of your customers’ contracts include “data must remain in the EU” (GDPR Article 44 transfer concerns, Schrems II caution, public-sector procurement). Even if your company is in the US, if you sell into Germany or France, the EU instance is the safer default.

The free tier is identical between regions. As of May 2026 the published numbers are 1 million events per month, 5,000 session recordings per month, 1 million feature flag requests per month, and unlimited team members. That is enough to run a real product for a long time — most solo SaaS founders do not pay for PostHog until they are well past product-market fit and have a few thousand active users sending events.

The signup flow asks for a project name and the region, then drops you on a page with a project API key (starts with phc_) and the host URL. Copy both; they go into your env in Step 3.

2 Install posthog-js and posthog-node

PostHog ships two SDKs you will both want. The browser SDK (posthog-js) handles autocapture, page views, identify, session recording, and feature-flag evaluation. The server SDK (posthog-node) handles server-side events from server actions, API routes, and webhooks — the events that ad blockers cannot touch and that map directly to revenue.

npm install posthog-js posthog-node
# or
pnpm add posthog-js posthog-node

The two packages are independent and can be loaded separately. posthog-js ships only into client components; posthog-node only into server code. Mixing them is a runtime error — if you import posthog-js from a server component, the build will complain about window being undefined.

3 Configure environment variables

PostHog needs two variables in .env.local (and in your Vercel project settings):

# .env.local
NEXT_PUBLIC_POSTHOG_KEY=phc_your_project_key_here
NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
# or https://eu.i.posthog.com if you picked the EU region

The NEXT_PUBLIC_ prefix is required for the browser SDK to access the values at runtime — Next.js only exposes variables with that prefix to client bundles. The project key is safe to ship to the client because it is write-only; PostHog refuses any read or admin operations from a client-side request.

For server-side capture in Step 7 you can reuse the same project key. Some teams add a separate POSTHOG_PERSONAL_API_KEY for batch backfills or admin scripts — that key is read-write and must not appear in client bundles.

4 Wrap the app with a provider

In the App Router, the canonical pattern is a small providers.tsx client component that initializes PostHog once and exposes a context. The root layout imports it server-side and wraps children; the actual posthog.init call only runs on the client.

// app/providers.tsx
'use client';

import posthog from 'posthog-js';
import { PostHogProvider } from 'posthog-js/react';
import { useEffect } from 'react';

if (typeof window !== 'undefined') {
  posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
    api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
    person_profiles: 'identified_only',
    capture_pageview: false, // we capture manually below
    session_recording: {
      maskAllInputs: true,
      maskTextSelector: '[data-sensitive]'
    },
    loaded: (ph) => {
      if (process.env.NODE_ENV === 'development') ph.debug();
    }
  });
}

export function Providers({ children }: { children: React.ReactNode }) {
  return <PostHogProvider client={posthog}>{children}</PostHogProvider>;
}
// app/layout.tsx
import { Providers } from './providers';
import { PageviewTracker } from '@/components/pageview-tracker';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <Providers>
          <PageviewTracker />
          {children}
        </Providers>
      </body>
    </html>
  );
}

The capture_pageview: false option is intentional. The App Router does not trigger a hard navigation between pages, so the default pageview behavior in posthog-js — which listens for full page loads — misses route transitions. The fix is a small client component that listens for path changes and captures explicitly:

// components/pageview-tracker.tsx
'use client';

import { usePathname, useSearchParams } from 'next/navigation';
import { useEffect } from 'react';
import posthog from 'posthog-js';

export function PageviewTracker() {
  const pathname = usePathname();
  const searchParams = useSearchParams();

  useEffect(() => {
    if (!pathname) return;
    const url = searchParams?.toString()
      ? `${pathname}?${searchParams.toString()}`
      : pathname;
    posthog.capture('$pageview', { $current_url: window.location.origin + url });
  }, [pathname, searchParams]);

  return null;
}

The person_profiles: 'identified_only' setting deserves a callout. PostHog by default creates an anonymous person profile for every visitor; that consumes the “monthly tracked persons” portion of your quota. Setting it to identified_only means PostHog only counts a person when you call identify — anonymous traffic still sends events, but they roll up against a single anonymous bucket. For a typical SaaS where most pageviews are anonymous and you only care about identified user behavior, this halves your billable usage.

5 Identify users on login

Anonymous events tie to a session id. The moment a user signs in, you want every event from that point onward (and ideally every event from before, retroactively) tied to their stable user id. The identify call does that:

// lib/posthog-identify.ts
'use client';

import posthog from 'posthog-js';

export function identifyUser(user: {
  id: string;
  email: string;
  plan?: 'free' | 'pro' | 'business';
  createdAt?: string;
}) {
  posthog.identify(user.id, {
    email: user.email,
    plan: user.plan ?? 'free',
    createdAt: user.createdAt
  });
}

export function resetUser() {
  posthog.reset();
}

Call identifyUser right after a successful login — in the Clerk useUser effect, the Supabase Auth callback, or wherever your auth library lands the user object. PostHog automatically aliases the previous anonymous id to the new identified id, so events from before login still belong to the right person.

Call resetUser on logout. This is the single most-missed step. Without posthog.reset(), the next user who signs in on the same browser keeps the previous user’s person id — their events bleed into the previous account’s history. The fix is one line:

// inside your sign-out handler
import { resetUser } from '@/lib/posthog-identify';

async function handleSignOut() {
  await signOut(); // your auth library
  resetUser();
  router.push('/');
}

6 Capture the five events that matter

Autocapture (clicks, form submissions, pageviews) gives you a baseline for free. Custom events give you the funnel. The official PostHog docs recommend a small set of named events with stable property keys; the canonical pattern is a thin wrapper so you do not sprinkle posthog.capture strings across your codebase:

// lib/analytics.ts
'use client';

import posthog from 'posthog-js';

type EventMap = {
  signup_completed: { method: 'email' | 'google' | 'github' };
  subscription_started: { plan: 'pro' | 'business'; mrr: number };
  subscription_canceled: { plan: string; reason?: string };
  feature_used: { feature: string; success: boolean };
  invite_sent: { count: number };
};

export function track<K extends keyof EventMap>(
  event: K,
  properties: EventMap[K]
) {
  posthog.capture(event, properties);
}

The five events almost every B2B SaaS should track from day one:

  • signup_completed — the “activation moment” that anchors every funnel. Capture the signup method so you can split conversion by channel.
  • subscription_started — the moment money becomes real. Capture plan and MRR so revenue rolls up correctly. Also capture this server-side from your Stripe webhook in Step 7.
  • subscription_canceled — the churn signal. Capture a cancellation reason if you have an exit survey.
  • feature_used — the breadth-of-use signal. One event with a feature property scales better than fifty unique event names.
  • invite_sent — the proxy for organic growth. The strongest leading indicator of expansion revenue in most B2B products.

The pattern of one wrapper file with a typed event map keeps event names consistent across the codebase, makes refactors safe, and gives you a single place to add server-side mirroring later. Call track('subscription_started', { plan: 'pro', mrr: 49 }) from the relevant client handler and PostHog gets the event.

Autocapture vs explicit events

PostHog ships autocapture on by default — it records every click, form submission, and rage click without you instrumenting anything. Autocapture is great for exploratory analysis (“what did users actually click on the new pricing page?”) and bad for funnels (“what fraction of free-tier users converted to Pro this month?”). The right rule of thumb: keep autocapture on, add explicit events for the funnel steps that drive your business decisions, and never rely on autocapture-only data for board metrics.

7 Server-side capture with posthog-node

Client-side events are the wrong layer for anything that touches money or trust. The Stripe webhook fires on the server. The user-creation event fires from the server-side auth callback. The API rate-limit event fires from a backend route. Capturing these client-side either misses them entirely (the user closed the tab before the webhook completed) or duplicates them (the webhook fires once, the client fires once on the success page). The fix is server-side capture:

// lib/posthog-server.ts
import { PostHog } from 'posthog-node';

let client: PostHog | null = null;

export function getPostHogServer(): PostHog {
  if (!client) {
    client = new PostHog(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
      host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
      flushAt: 1, // flush immediately in serverless
      flushInterval: 0
    });
  }
  return client;
}

export async function trackServer(
  distinctId: string,
  event: string,
  properties: Record<string, unknown> = {}
) {
  const ph = getPostHogServer();
  ph.capture({ distinctId, event, properties });
  await ph.shutdown(); // ensure delivery before the function returns
}

Use it from a Stripe webhook:

// app/api/webhooks/stripe/route.ts (excerpt)
import { trackServer } from '@/lib/posthog-server';

export async function POST(req: Request) {
  const event = await verifyStripeSignature(req);

  if (event.type === 'customer.subscription.created') {
    const sub = event.data.object;
    await trackServer(sub.metadata.userId, 'subscription_started', {
      plan: sub.items.data[0].price.lookup_key,
      mrr: sub.items.data[0].price.unit_amount! / 100,
      stripeSubscriptionId: sub.id
    });
  }

  return new Response('ok');
}

The flushAt: 1 and explicit shutdown() are non-optional in serverless environments. Vercel kills the function the instant the response is returned; if PostHog is still buffering events, they are dropped. Forcing a flush per call costs a tiny bit of latency but guarantees delivery.

The distinctId on the server side must match the client-side identified id — usually the user’s database id. PostHog merges events with the same distinct id automatically; the result is one timeline per user that includes both client and server events.

8 Reverse proxy via Next.js rewrites

This is the step founders skip and regret. The PostHog domain (us.i.posthog.com) is on every major ad-blocker list — uBlock Origin, Brave Shields, Pi-hole, the iOS content blockers. A naive client-side install loses 30–50% of events depending on the audience (developer audiences trend higher; consumer audiences lower). The fix is a reverse proxy: route requests to a path on your own domain, rewrite them to PostHog server-side. Ad blockers see yoursaas.com/ingest and let it through.

Next.js makes this a one-file change in next.config.js:

// next.config.js
const nextConfig = {
  async rewrites() {
    return [
      {
        source: '/ingest/static/:path*',
        destination: 'https://us-assets.i.posthog.com/static/:path*'
      },
      {
        source: '/ingest/:path*',
        destination: 'https://us.i.posthog.com/:path*'
      },
      {
        source: '/ingest/decide',
        destination: 'https://us.i.posthog.com/decide'
      }
    ];
  },
  skipTrailingSlashRedirect: true // important: PostHog endpoints are case-sensitive
};

module.exports = nextConfig;

Then point the SDK at your own domain instead of PostHog’s:

// .env.local
NEXT_PUBLIC_POSTHOG_HOST=https://yoursaas.com/ingest
// app/providers.tsx (excerpt)
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
  api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST, // now /ingest on your domain
  ui_host: 'https://us.posthog.com' // keep this pointed at PostHog itself for the toolbar
});

For the EU instance, swap us.i.posthog.com for eu.i.posthog.com and us-assets.i.posthog.com for eu-assets.i.posthog.com.

The privacy implication is real and worth being honest about. The reverse proxy specifically defeats user-side ad blockers. Users who installed an ad blocker did so to avoid being tracked; routing analytics through your own domain takes that choice away. The defensible position is that product analytics on your own product is a legitimate operational need (debugging, abuse prevention, feature usage), not advertising tracking, and that your privacy policy discloses what is captured. The indefensible position is using the proxy to send identifiable data to a third-party ad network. Stay on the right side of that line.

Feature flags as A/B tests (the volume gotcha)

PostHog ships feature flags in the same SDK. The temptation is to A/B test everything: pricing copy, button colors, onboarding flow, paywall placement. The volume math is what kills you at solo scale.

An A/B test needs roughly 1,000–5,000 conversion events per arm to detect a 10% lift with 80% power and 95% confidence. If your conversion event is “subscription started” and you have 50 conversions a month, the test runs for years. Most solo-stage A/B tests “finish” before they have power.

A more honest pattern at solo scale:

  • Use feature flags as kill switches and gradual rollouts — ship to 10% of users, watch for errors, ramp to 100%. PostHog is excellent at this.
  • Use feature flags for B2B beta access — turn on a feature for one customer at a time. Free tier covers this comfortably.
  • Run A/B tests only on top-of-funnel events with thousands of weekly samples (homepage CTA, signup form, pricing-page click-through).
  • Skip A/B testing the in-product flow until you have enough conversion volume that a test can finish in a month.

The PostHog pricing breakdown covers what feature-flag requests cost past the free tier (1M requests/month is the line) and the PostHog review covers the rest of the platform — surveys, error tracking, data warehouse — that ships in the same product.

Session recording: turn it on, scrub PII

The 5K session recordings on the free tier go further than you think because most sessions are short and most users do not need replays watched. The right default is “on, with input masking, with text masking on sensitive selectors.” The provider config in Step 4 already shows the pattern:

session_recording: {
  maskAllInputs: true,
  maskTextSelector: '[data-sensitive]'
}

Add data-sensitive to any DOM element rendering personal data — user emails in a settings page, organization names in a team admin view, order amounts in a checkout. The recording still captures the layout and clicks but the sensitive text is replaced with asterisks. For deeper PII protection, set mask_all_text: true — the entire DOM is masked and you only see the structure of the page, not the content. Useful for healthcare or finance products; overkill for most B2B SaaS.

Common mistakes

Forgetting posthog.reset() on logout

Without it, the next user’s events bleed into the previous user’s person profile. The fix is one line in the sign-out handler.

Picking the wrong region at signup

Data does not migrate between US and EU instances. If you start on US and later land an enterprise EU contract that requires EU residency, you have to start over and lose history. Pick the safer region up front.

Capturing PII in event properties

Email addresses are fine on the person profile (they are deliberately part of identify). Email addresses inside event properties are not — they get indexed, exported, and seen in dashboards. Pass user ids in event properties and let the person profile resolve the email.

Skipping the reverse proxy

Naive client-side capture loses 30–50% of events to ad blockers in developer-heavy audiences. The Next.js rewrite in Step 8 is the canonical fix.

Trusting client-side capture for revenue events

The Stripe webhook is the source of truth for subscription_started, not the client-side success page. Capture revenue events server-side from posthog-node; let the client mirror them only as a UX nicety.

Running underpowered A/B tests

Most solo-stage A/B tests finish before they have power. Use feature flags as rollout switches, not as A/B tests, until your conversion volume justifies the test math.

Summary
SDKs → provider → identify → events → server-side → reverse proxy

The canonical PostHog Next.js setup is small but unforgiving in the details: the App Router needs manual pageview capture, ad blockers require the reverse proxy, revenue events need server-side capture, and logout needs posthog.reset(). Get those four right and the rest of PostHog — funnels, retention, session recording, feature flags — just works on top of a clean event stream.

Related guides

Get one SaaS build breakdown every week

The stack, prompts, pricing, and mistakes to avoid — for solo founders building with AI.