Product Analytics with PostHog

22. November, 2025 12 min read Develop

One vendor instead of five

The analytics stack for a small product tends to accrete rather than get designed. Something for events, something else for session recordings, a third thing for feature flags, a fourth when somebody wants an A/B test, and by then nobody can answer "did the users who saw variant B actually convert" without exporting two CSVs and joining them in a spreadsheet.

PostHog’s pitch is that all of it belongs in one database. Analytics, replays, flags, experiments and surveys share the same event stream, which means the join is a filter rather than an export. It is open source, roughly 30,000 stars on GitHub, and the free tier is generous enough that a side project will never see an invoice.

This is about wiring it into a Next.js App Router project, which has a few sharp edges that the marketing pages do not mention.

Why this one

Count the vendors in a typical setup: Google Analytics or Mixpanel for product analytics, Hotjar for recordings, LaunchDarkly for flags, Optimizely for experiments, Typeform for surveys. Five contracts, five scripts on the page, five dashboards, and five different ideas about what a “user” is.

The open source part matters to me more than it probably should. The core repository is MIT licensed, with the ee/ directory under a separate commercial licence, so you can read exactly what the SDK collects rather than trusting a minified bundle. And there is an EU region if your client’s legal department asks where the data lives, which in Switzerland they generally do.

The free tier at the time of writing covers a million analytics events, 5,000 session recordings, a million feature flag requests and 1,500 survey responses a month. That is enough to run a small product on indefinitely, which is a rare thing to be able to say about a free tier.

Getting it in

Two packages, and they do different jobs. posthog-js runs in the browser, posthog-node runs on the server, and (this is the bit worth knowing up front) they do not automatically know about each other.

npm install posthog-js posthog-node

Environment variables:

# .env.local
NEXT_PUBLIC_POSTHOG_KEY=phc_your_project_api_key
NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com

Since Next.js 15.3 there is an instrumentation-client.ts file that runs before your application code hydrates, which is exactly where SDK initialisation belongs:

// instrumentation-client.ts
import posthog from 'posthog-js';

posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
  api_host: '/ingest',
  ui_host: 'https://us.posthog.com',
  defaults: '2025-05-24',
});

That defaults value deserves an explanation, because it looks like a version string somebody forgot to remove. PostHog uses dated defaults so they can change behaviour without breaking existing installs: you opt into the behaviour as of a given date. '2025-05-24' switches pageview capture to 'history_change', which means client-side navigations in the App Router are captured automatically.

That matters because most PostHog tutorials for Next.js still show a hand-written PostHogPageView component that watches usePathname() and useSearchParams() and fires a $pageview on every change. You needed that once. With dated defaults you do not, and the hand-rolled version has a subtle bug anyway. useSearchParams() forces the component into client-side rendering unless you wrap it in a Suspense boundary, which is easy to forget.

For the React hooks you still need a provider, in a client component:

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

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

export function Providers({ children }: { children: React.ReactNode }) {
  return <PostHogProvider client={posthog}>{children}</PostHogProvider>;
}

Wrap {children} in your root layout with that, and you are collecting.

A note on imports, because this changed recently and will bite somebody: PostHog has published the React bindings as a standalone @posthog/react package, and switched their documentation over to it in the last couple of weeks. posthog-js/react still works. Check which one the docs page you are reading assumes before you spend twenty minutes on an import error.

Autocapture, and its limits

Without writing a line of tracking code, PostHog records clicks on interactive elements, form submissions, pageviews and page leaves, scroll depth, and the four Core Web Vitals: LCP, CLS, FCP and INP. (If you see an older guide mentioning FID, it predates INP replacing it in early 2024.)

The value of autocapture is not that it replaces deliberate instrumentation. It is that when someone asks in three months “how many people ever clicked that button”, the answer exists retroactively, because you were capturing it before anyone thought to ask. That is genuinely useful and I have stopped arguing about it.

The limit is that autocaptured events are described by DOM structure. Rename a CSS class, restructure a component, and your “clicks on .btn-primary in the pricing page” series quietly becomes two series. Anything you plan to report on for more than a quarter should be a named custom event.

You can narrow what gets captured:

posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
  api_host: '/ingest',
  defaults: '2025-05-24',
  autocapture: {
    dom_event_allowlist: ['click', 'submit'],
    url_allowlist: ['https://yourdomain.com/app/*'],
    css_selector_allowlist: ['[data-track]'],
  },
});

And anything carrying a ph-no-capture class is excluded outright. Put it on payment fields and anything showing another person’s data.

Custom events

'use client';

import posthog from 'posthog-js';

export function PricingCard({ plan }: { plan: string }) {
  const handleUpgrade = () => {
    posthog.capture('upgrade_clicked', {
      plan,
      source: 'pricing_page',
    });
  };

  return <button onClick={handleUpgrade}>Upgrade to {plan}</button>;
}

Properties are arbitrary. Spend a few minutes deciding on naming before you have two hundred of them: snake_case, verb in the past tense, and the same property key meaning the same thing everywhere. Nobody enforces this and everybody regrets not doing it.

Identifying people

'use client';

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

export function IdentifyUser({
  user,
}: {
  user: { id: string; email: string } | null;
}) {
  useEffect(() => {
    if (user) {
      posthog.identify(user.id, { email: user.email });
    }
  }, [user]);

  return null;
}

identify merges the anonymous session into the known person, so the pages they read before signing up stay attached to them. Call posthog.reset() on logout, or the next person to use that browser inherits the identity, which on a shared machine is a data protection problem rather than just a messy chart.

Feature flags

Create a flag in the dashboard, read it in a component:

'use client';

import { useFeatureFlagEnabled } from 'posthog-js/react';

export function Dashboard() {
  const showNewDashboard = useFeatureFlagEnabled('new-dashboard');

  if (showNewDashboard) return <NewDashboard />;
  return <OldDashboard />;
}

Three kinds exist: boolean flags for on/off, multivariate flags for several named variants with rollout percentages, and remote config for values you want to change without a deploy. Targeting works on person properties, cohorts, percentage rollouts, geography, and dependencies on other flags.

On the server you use posthog-node:

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

export const posthogServer = new PostHog(
  process.env.NEXT_PUBLIC_POSTHOG_KEY!,
  { host: 'https://us.i.posthog.com' }
);
import { posthogServer } from '@/lib/posthog-server';
import { getSession } from '@/lib/auth';

export default async function Page() {
  const session = await getSession();
  const flags = await posthogServer.getAllFlags(session.user.id);

  return <div>{flags['new-header'] ? <NewHeader /> : <OldHeader />}</div>;
}

The hook returns undefined on first render while flags load, which produces a visible flicker: the old header paints, then swaps. The fix is bootstrapping: evaluate the flags on the server and hand them to posthog.init as bootstrap: { featureFlags }, so the client has values before it renders anything. It is more wiring than it should be, and it is the difference between a flag you can use above the fold and one you cannot.

Session replay

Replays are DOM snapshots, not video, so they are small and the text in them is selectable.

posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
  api_host: '/ingest',
  defaults: '2025-05-24',
  session_recording: {
    maskAllInputs: true,
    maskTextSelector: '.sensitive-data',
  },
});

Masking happens in the browser before anything is sent, which is the only design that survives a conversation with a privacy officer. You can also limit when recording happens: only on certain URLs, only after an error event, only for users in a given flag, or a percentage sample to keep within quota.

The reason replay is worth having in the same tool as everything else is the cross-referencing. Watch the recordings of the twelve people who dropped out of your signup funnel at step three. That is one filter, not an export. Twelve recordings will tell you more than a month of staring at the funnel chart.

Experiments

Experiments sit on top of multivariate flags:

'use client';

import { useFeatureFlagVariantKey } from 'posthog-js/react';

export function CheckoutButton() {
  const variant = useFeatureFlagVariantKey('checkout-experiment');

  if (variant === 'test') {
    return <button className="btn-green">Complete Purchase</button>;
  }

  return <button className="btn-blue">Checkout</button>;
}

You define the success metric in the dashboard and PostHog does the statistics. It defaults to a Bayesian analysis, so the result reads as “a 96% probability that variant B is better” rather than a p-value that half the people in the room will interpret incorrectly. A frequentist mode is available if your organisation prefers it.

You can run one control against up to nine variants, test at group level rather than per person (useful when the unit of decision is a company account, not an individual), and set aside a holdout group to check that the accumulated wins are real.

Surveys

Most surveys need no code at all: build a popover in the dashboard, target it at a URL pattern or a person property, and it appears. When you want to trigger one yourself:

'use client';

import posthog from 'posthog-js';

export function FeedbackButton() {
  return (
    <button
      onClick={() =>
        posthog.displaySurvey('survey-id', { displayType: 'popover' })
      }
    >
      Give Feedback
    </button>
  );
}

Lowercase 'popover'; 'inline' is the other option and needs a selector telling it where to render. renderSurvey still exists and still works, but it was deprecated a couple of months ago in favour of displaySurvey, so start with the new one.

Question types cover free text, ratings on emoji or numeric scales, and single or multiple choice. Targeting can key off a flag, a URL, a device type, person properties, or an event that happened earlier in the session, which is how you ask “what went wrong?” only of people who actually hit the error.

The proxy is not optional

A meaningful share of visitors run an ad blocker, and every one of them blocks requests to i.posthog.com. Route the traffic through your own domain instead:

// next.config.js
const nextConfig = {
  async rewrites() {
    return [
      {
        source: '/ingest/static/:path*',
        destination: 'https://us-assets.i.posthog.com/static/:path*',
      },
      {
        source: '/ingest/array/:path*',
        destination: 'https://us-assets.i.posthog.com/array/:path*',
      },
      {
        source: '/ingest/:path*',
        destination: 'https://us.i.posthog.com/:path*',
      },
    ];
  },
};

Three rules, and the order is load-bearing: the catch-all must come last or it swallows the other two. The /array/ one serves remote config and is the one people leave out, then wonder why their flags never arrive. Set api_host: '/ingest' (as above) and ui_host to the real PostHog URL so the toolbar links still work. Swap us for eu on the EU region.

Tracking from the server

Anything that happens without a browser (a webhook, a Stripe callback, a cron job) needs posthog-node:

'use server';

import { posthogServer } from '@/lib/posthog-server';

export async function purchaseAction(formData: FormData) {
  const session = await getSession();
  const plan = formData.get('plan') as string;

  await processPurchase(plan);

  posthogServer.capture({
    distinctId: session.user.id,
    event: 'purchase_completed',
    properties: { plan, source: 'upgrade_page' },
  });
}

Note the explicit distinctId. Nothing links the browser’s anonymous ID to your server events for you: if you pass a different identifier on the server than the one you passed to posthog.identify on the client, you get two person records for the same human, and every funnel that crosses the boundary breaks. Pick the identifier once, put it in a helper, and use it everywhere.

The other server-side trap is flushing. posthog-node batches and sends on an interval, which is fine on a long-lived process and useless in a serverless function that gets frozen the moment it returns. On Vercel, wrap the capture in the after() API and call flush() there, or shutdown() if the process really is ending. Events you never flushed do not show up as an error anywhere. They just are not there.

What the data is for

Once events are flowing, the dashboard gives you trends over time, funnels through a multi-step process, retention cohorts, path analysis, lifecycle segmentation into new/returning/resurrecting/dormant, and stickiness. All of it filters on person properties, flag state, geography and anything you attached to an event. When the built-in charts run out, HogQL lets you write SQL directly against the event table.

The part that took me longest to appreciate is that you should build fewer of these than you think. A dashboard with thirty charts is a dashboard nobody reads. Three numbers somebody checks on a Monday will change more decisions.

Self-hosting, honestly

You can run the whole thing yourself:

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/posthog/posthog/HEAD/bin/deploy-hobby)"

Note the form: the script prompts you interactively, so piping it into bash lets the pipe swallow its own input and it sits there looking hung.

It wants something like 4 vCPUs, 16GB of RAM and more than 30GB of disk. And PostHog are unusually direct that self-hosting is unsupported: no help if it breaks, none of the paid features, and the data is your problem. Given that it is ClickHouse, Kafka, Redis and Postgres in a trench coat, I would take them at their word unless data residency leaves you no choice, and the EU cloud region probably already solves that.

Where I’d stop

PostHog’s breadth is the reason to use it and the reason to be a little sceptical of it. LaunchDarkly’s flag targeting is more sophisticated. Hotjar’s heatmaps are more mature. Mixpanel’s cohort analysis goes deeper. If one of those is genuinely core to how your team works, PostHog will feel like the second-best tool for that job.

For everyone else, having one event stream that flags, replays and experiments all agree on is worth more than any individual feature being best in class. That is the actual argument, and the vendor consolidation is a side effect.

What I would still like is a decent story for identity across the client/server boundary that does not involve me threading a distinct ID through by hand. PostHog have been circling something for Next.js specifically; when it lands I will happily delete a helper file

‘Till next time!