Tutorial13 min read · Updated September 10, 2026

How to Add PostHog to Next.js (2026 Guide)

PostHog is product analytics, session replay, feature flags and experiments behind one JavaScript snippet. Installing it in the App Router has two wrinkles that a plain script tag does not cover: client-side navigations do not reload the page, so pageviews need help, and the hook that reads search params forces a Suspense boundary that can push your SEO markup out of the initial HTML if you mount it carelessly. This guide covers both, plus the parts people wire up wrong later.

What PostHog is, and what you are installing

PostHog bundles several products that are usually bought separately: event analytics with funnels and retention, session replay, feature flags, A/B experiments, surveys and error tracking. They share one identity model, which is the actual reason to use it rather than assembling four tools — an event, a replay, a flag evaluation and an experiment exposure all attach to the same person, so you can watch the session behind a funnel drop-off instead of inferring it.

What you install on the client is `posthog-js`, and it is not a small script. Autocapture, session replay and surveys are separate chunks loaded on demand from the assets host, so the initial cost depends on what you enable. Decide deliberately: autocapture records every click and form interaction without you writing any code, which is genuinely useful for the first month and genuinely noisy after that. Session replay is the heaviest thing in the bundle and the one with the clearest privacy implications.

There is also `posthog-node` for server-side capture, which is a different package with a different lifecycle — it batches and needs an explicit flush before a serverless function exits. This guide covers the browser client, which is where the App Router specifics live. If you have not decided which events matter yet, write out the app's flows first; the steps a user has to complete in order are the funnel you are about to instrument.

Step 1 — Install and initialise

`npm i posthog-js`. Put the project key in `NEXT_PUBLIC_POSTHOG_KEY` and the API host in `NEXT_PUBLIC_POSTHOG_HOST` — both have to be public, because the browser needs them. A PostHog project key is designed to be public and is write-only; it is not a secret, and treating it as one by proxying the whole client is a waste of effort.

Initialisation belongs in a Client Component, guarded so it runs once. `posthog.init(key, { api_host, defaults: "2026-01-30", person_profiles: "identified_only" })`. The `defaults` option is the one to understand before anything else: it is a dated string that opts you into the behaviour changes shipped on that date, so a project pinned to `"unset"` keeps the original defaults forever. Per the shipped type definitions, `"2025-05-24"` makes `capture_pageview` default to `"history_change"`, `"2025-11-30"` adds a strict minimum duration for replays and turns on the rageclick content ignore list, and `"2026-01-30"` injects external scripts into the head, which avoids a class of SSR hydration error.

`person_profiles` is the other early decision and it has real consequences. `"identified_only"` creates a person profile only once you call `identify`, so anonymous traffic is counted as events without a profile each. `"always"` profiles every visitor. For a marketing site with a lot of anonymous traffic and a small number of signups, `"identified_only"` is almost always what you want, and switching later does not retroactively reshape the data you already collected.

Mount the initialiser once, in the root layout, as a Client Component. Where exactly you mount it matters more than it looks — see the next section. If you would rather have this wiring generated than hand-built, describe the app and InBuild generates the Next.js code — a paid plan is required to generate.

Step 2 — Pageviews in the App Router

A traditional site fires one pageview per document load, which is what the default `capture_pageview: true` does. The App Router navigates without reloading the document, so every route change after the first one goes unrecorded unless something notices. There are two ways to fix it and they are mutually exclusive.

The simpler one is `capture_pageview: "history_change"`, which hooks `pushState`, `replaceState` and `popstate` and fires a pageview on each. That is what the `defaults` values from `"2025-05-24"` onwards turn on for you, and for most apps it is the whole answer — no hooks, no effects, nothing to get wrong.

The manual route exists for one reason: control over what the URL looks like. Set `capture_pageview: false` and fire it yourself from a component that reads `usePathname()` and `useSearchParams()`, then calls `posthog.capture("$pageview", { $current_url: ... })` in an effect keyed on both. This is what this site does, because the search string carries campaign parameters that need to be in the event. The cost is that you now own the edge cases — a hash change, a shallow route update, a redirect — that the built-in handler already covers.

If you go manual, `useSearchParams()` forces a Suspense boundary, and the placement of that boundary has an SEO consequence that is easy to miss. Wrapping `{children}` in it pushes the entire page tree through streaming, which can defer JSON-LD and other head markup out of the initial HTML response. Mount the tracker as a sibling of `{children}` rather than a parent, with its own `<Suspense fallback={null}>`, so only the tracker streams.

Step 3 — Identifying people

Before you call `identify`, PostHog tracks an anonymous distinct ID stored in the browser. `posthog.identify(userId, { email, plan })` links that anonymous history to a real person, so the pageviews from before signup stay attached to the account that resulted. Call it right after login and after signup — not on every render, and not in a component that remounts on navigation.

The second argument sets person properties that overwrite on every call; a third argument sets properties only once, which is where `first_seen_at` or the original acquisition source belongs. Getting these the wrong way round is how a first-touch attribution property quietly becomes a last-touch one.

`posthog.reset()` is the other half and the one people forget. On logout, without a reset, the next person to use that browser inherits the previous user's distinct ID and their events merge into the wrong profile. That is a data-integrity bug and a privacy one, and on a shared machine it is a visible one. Wire it into the same handler that clears your session.

`posthog.group("company", companyId, { name })` attaches subsequent events to an organisation as well as a person, which is what makes B2B questions answerable — how many accounts, rather than how many users, reached a step. Set it wherever you already know which workspace the user is acting in.

Step 4 — Custom events that survive a rename

`posthog.capture("plan_selected", { plan: "pro", source: "pricing_page" })` is the whole API, which is why the discipline has to come from a convention rather than the tool. Pick one and write it down: lowercase, snake_case, `object_verb` past tense — `signup_completed`, `project_exported`, `checkout_started`. The alternative is a project with `SignupComplete`, `signup_complete` and `completed signup` all in it, three months of data split across them, and no way to merge retroactively.

Put the variable part in properties, not in the event name. `checkout_started` with `{ plan: "pro" }` lets you break down by plan, filter to one, or ignore it. `checkout_started_pro` is a different event from `checkout_started_team` and you will never chart them together without rebuilding both.

Route every call through one wrapper rather than importing `posthog` at each call site. This site's `track(event, props)` fans a single call out to PostHog, Vercel Analytics and GA4, which means adding a backend later is one file rather than a search-and-replace across the app — and it gives you one place to strip a property you should not have been sending. A wrapper also lets the whole thing no-op cleanly when the key is absent, which is what you want in local development and preview deploys.

Capture on the outcome, not the click. `signup_completed` fired when the account actually exists is a number you can trust; the same event fired in the button's `onClick` counts attempts, including the ones that failed validation, and the difference will not be visible in the chart. When the outcome happens on the server, either capture it there with `posthog-node` or fire the client event in the success branch.

Step 5 — Feature flags and experiments

`posthog-js/react` ships the hooks: `useFeatureFlagEnabled("new-nav")` for a boolean flag, `useFeatureFlagVariantKey("hero-test")` for a multivariate one, `useFeatureFlagPayload` for the JSON attached to a variant, and `usePostHog()` for the client itself. Wrap the tree in `PostHogProvider` — it accepts either an already-initialised `client` or an `apiKey` plus `options` and initialises for you.

All of them return `undefined` on the first render, because flags arrive asynchronously after the initial load. That is not a bug to work around with a loading spinner over your hero; it is a three-state value, and the third state is what the user sees before the network answers. Treat `undefined` as the control experience and render it immediately, or accept a flash when the value arrives. `PostHogFeature` wraps the same logic with a `fallback` prop if you would rather express it declaratively.

An experiment is a multivariate flag plus a goal metric, and the exposure event is recorded when the variant is read. That has a consequence worth internalising: reading the flag for a user who never reaches the tested surface enrols them in the experiment anyway and dilutes the result. Read it where it is rendered, not in a layout that runs everywhere.

The part that decides whether an experiment is worth running is the copy, not the plumbing. Two variants that say roughly the same thing will produce a result indistinguishable from noise however long you leave it. Write variants that differ on one deliberate axis — a different promise, a different objection answered — and score each one before you ship it so you are not spending traffic testing a line with a fixable flaw.

Getting past ad blockers with a reverse proxy

A meaningful share of visitors run a blocker that recognises analytics domains, and those visitors are silently missing from your data. The standard fix is to serve PostHog from your own origin: add a rewrite in `next.config.ts` mapping `/ingest/:path*` to the PostHog ingestion host and a second one for the static assets host, then set `api_host: "/ingest"` and `ui_host` to the real dashboard URL so the toolbar still knows where it lives.

Two details make the difference between this working and half-working. Set `skipTrailingSlashRedirect: true` in the same config, or Next.js will redirect some ingestion paths and drop the request body. And put the assets rewrite before the ingestion one, because `/ingest/static/:path*` and `/ingest/:path*` both match the same requests and rewrites are evaluated in order.

If your site sends a Content-Security-Policy — and it should — the PostHog hosts have to be allowlisted on both `script-src` and `connect-src`. The connect directive is the one people miss, because the failure is silent: the script loads, `capture` returns without complaint, and the request is blocked by the browser with nothing thrown for your error tracking to notice. This site's CSP names both the ingestion host and the assets host explicitly for exactly that reason.

Before you build any of this, check whether you need it. Turn on `debug: true` in development and watch the network tab with your own blocker enabled — if the requests go through, the proxy is complexity you have not earned yet.

Common mistakes

Initialising in a Server Component. `posthog-js` is a browser library and touches `window` at import time. The module needs `"use client"` at the top of the file that imports it.

Calling `init` more than once. A component that remounts on navigation will re-initialise the client and can duplicate events. Guard it with a module-level flag or do it inside `PostHogProvider`, which handles it for you.

Forgetting `reset()` on logout. The next user of that browser inherits the previous person's distinct ID, and the two profiles merge into one that describes neither.

Leaving `capture_pageview` at its old default in the App Router. One pageview on first load and nothing afterwards looks like a traffic collapse, not a missing hook, and it is easy to misread for weeks.

Wrapping `{children}` in the Suspense boundary that `useSearchParams` requires. It works, and it defers your head markup out of the initial HTML. Mount the tracker as a sibling instead.

Putting variable data in event names. `viewed_project_abc123` creates one event per project. The project ID is a property.

Sending personal data as event properties without deciding to. Email addresses, names and full URLs with tokens in them end up in analytics because nobody said not to. `before_send` is the hook that lets you strip or drop events centrally before they leave the browser.

How to do it

  1. 1

    Install the client

    npm i posthog-js. Set NEXT_PUBLIC_POSTHOG_KEY and NEXT_PUBLIC_POSTHOG_HOST — both must be public, and a project key is write-only by design.

  2. 2

    Initialise once in a Client Component

    posthog.init(key, { api_host, defaults: "2026-01-30", person_profiles: "identified_only" }) behind a module-level guard, mounted from the root layout.

  3. 3

    Fix pageviews

    Either keep capture_pageview at "history_change" — which the dated defaults enable — or set it to false and fire $pageview yourself from a component reading usePathname and useSearchParams.

  4. 4

    Mount the tracker as a sibling

    If you go manual, wrap only the tracker in <Suspense fallback={null}> beside {children}, not around it, so JSON-LD and head markup stay in the initial HTML.

  5. 5

    Identify and reset

    posthog.identify(userId, props) after login and signup; posthog.reset() in the same handler that clears the session, so the next user does not inherit the distinct ID.

  6. 6

    Route events through one wrapper

    A single track(event, props) function with a naming convention — lowercase object_verb, variable data in properties — instead of importing posthog at every call site.

  7. 7

    Add flags where they render

    Wrap the tree in PostHogProvider, read useFeatureFlagEnabled or useFeatureFlagVariantKey in the component that renders the variant, and treat the first-render undefined as the control.

Frequently asked questions

Why are my pageviews missing after the first one?

Because App Router navigations do not reload the document, and the original capture_pageview default fires once per document load. Either set capture_pageview to "history_change", which hooks the History API, or opt into a defaults value of "2025-05-24" or later which makes that the default. The manual alternative is capture_pageview: false plus your own $pageview capture keyed on usePathname and useSearchParams.

What does the `defaults` option do?

It pins your project to the set of default behaviours shipped on a given date, so upgrading the package never silently changes how your app is instrumented. Per the shipped type definitions the values are "unset", "2025-05-24", "2025-11-30" and "2026-01-30", each including the ones before it. "unset" keeps the original defaults. New projects should use the most recent value; an existing project should move deliberately, because the behaviour changes are real.

Is it safe to expose the PostHog key in the browser?

Yes — a project API key is write-only and designed to sit in client-side code. It can send events to your project, which is why a reverse proxy is about ad blockers rather than secrecy. The keys that do need protecting are the personal and project API keys used for the management and query APIs, and those belong only in server-side environment variables.

Should I capture events on the client or the server?

Client-side for anything about what the user saw or did in the interface — pageviews, clicks, flag exposures — because that context only exists in the browser. Server-side, with posthog-node, for outcomes that must be accurate: a completed payment, a provisioned account, a job that finished. Client events can be blocked or dropped; server events cannot. Remember that posthog-node batches, so a serverless handler has to flush before it returns.

Why does my feature flag hook return undefined on first render?

Flags are fetched after initialisation, so there is a window before the values arrive. Every flag hook has three states, not two, and undefined is the one your user sees first. Render the control experience for undefined rather than blocking on a spinner. If you need a correct value in the very first paint, evaluate the flag on the server and pass it down as a prop — or bootstrap the client with known values at init.

Does PostHog need a cookie banner?

That depends on your jurisdiction and your configuration, and it is a question for whoever owns your privacy policy rather than one a guide can answer. What the library gives you are the controls: persistence can be set to memory or sessionStorage instead of cookies, opt_out_capturing_by_default starts everyone opted out until you call opt_in_capturing, cookieless_mode avoids browser storage entirely, and before_send lets you drop or rewrite events centrally before they are sent.

Free tools used in this guide

No signup — each runs in the browser or as a single server-side fetch.

Ready to build?

See a real InBuild template rendered live, with the exact exported code, before you pick a plan — no account needed.

Or skip the tutorial: describe it and InBuild generates the Next.js code for you. A paid plan is required to generate.

Build this with InBuild

More Next.js guides