Skip to content

Analytics

Next.js boilerplate with PostHog

Product analytics, session replay, feature flags and experiments behind one key.

Product analytics with a typed event catalogue. The browser client captures page views on App Router navigations. The server client flushes with after(), so events go out before the serverless function exits. Identity helpers refuse to send PII. An /ingest reverse proxy sends events through your own domain, so fewer get blocked.

What PostHog adds to the agent layer: 3 rules · 2 skills · 1 subagent · 5 solution docs · 1 MCP server

Maintained by @raviMITNext.js on Vercel

From the manifest

Should you pick PostHog?

Pick it if

Product teams who want funnels, retention, replay, flags, experiments and surveys on one event stream. One stream means one definition of "active user", not four. Good for early-stage teams, and SQL access answers the questions the dashboards cannot.

Watch out for

  • The client bundle is not free. posthog-js with autocapture, replay and flags is a large third-party script, loaded on every page. Lazy loading and turning autocapture off are the two levers that matter.
  • Ad blockers block it by default. PostHog says a reverse proxy typically lifts event capture by 10 to 30%, which is why this battery installs one.
Show 3 more
  • Autocapture cuts both ways. It gives you data before you instrument anything, and it fills your project with $autocapture events nobody can explain six months later. This battery captures named events on purpose and keeps autocapture as a backstop.
  • Anonymous events are cheap, identified events are not. Person profiles drive the analytics price, which is why the client runs with person_profiles: "identified_only".
  • It is a warehouse of behaviour, not a source of truth for money. Revenue, entitlements and anything you would argue with a customer about belong in your database. PostHog is where you ask questions about them.

What it costs

Free every month, card or not: 1M analytics events, 5,000 session recordings and 1M feature flag requests. Past that, each product bills by usage, and you can set a billing limit per product. Self-hosting exists, but PostHog does not support it.

Prices change. Check with PostHog before you commit.

registry/tested.yaml

Tested with PostHog

Each pair was installed, typechecked, linted, built and booted together.

Database
NeonSupabase
Admin panel
Admin panel
Error tracking
Sentry
Customer support
Crisp

What it adds

What PostHog adds to the repo

Read straight from the posthog manifest, so it is exactly what lands in your repo.

Environment variables

  • NEXT_PUBLIC_POSTHOG_KEYRequiredPublic, reaches the browser

    Project API key. It is write-only (it can send events and read flags, it cannot read your data) which is why it is safe in the browser bundle. Rotating it means re-deploying every client.

    Where to get it
    PostHog -> Settings -> Project -> Project API key
    Placeholder
    phc_exampleprojectapikeydonotuse000000000
  • NEXT_PUBLIC_POSTHOG_HOSTRequiredPublic, reaches the browser

    Ingestion host for your region. US cloud is https://us.i.posthog.com, EU cloud is https://eu.i.posthog.com, self-hosted is your own domain. The browser never talks to it directly (it posts to /ingest and the route handler forwards here) but the proxy and the server client both read it.

    Where to get it
    PostHog -> Settings -> Project -> Project API key (the host is shown beside it)
    Placeholder
    https://us.i.posthog.com
  • POSTHOG_API_KEYOptional

    Personal API key, server-side only. Not needed to send events. Used by bun run verify to confirm the project exists. The MCP server signs in with OAuth instead. Scope it to read-only on the one project: a personal key inherits everything you can do.

    Where to get it
    PostHog -> Settings -> Personal API keys -> Create personal API key
    Placeholder
    phx_examplepersonalapikeydonotuse00000000

Dependencies

  • posthog-js^1.434.0
  • posthog-node^5.54.0
  • server-only^0.0.1

MCP server

  • posthog

    URL
    https://mcp.posthog.com/mcp?readonly=true

Files it writes

6 files, at these exact paths.

  • src/6 files
    • app/1 file
      • ingest/1 file
        • [...path]/1 file
          • route.ts
    • lib/5 files
      • analytics/5 files
        • events.ts
        • identify.ts
        • posthog-client.ts
        • posthog-server.ts
        • provider.tsx

Stack slots it fills

The stack declares these injection points; this battery supplies the fragment, so the provider tree, the env check and the schema stay one file each instead of many.

  • @slot env-required
  • @slot legal-processors
  • @slot providers
  • @slot verify-checks

The differentiator

What PostHog teaches your agent

Other starter kits stop at the package. This is the part an agent reads: where it may work, what it must never do there, and the problems someone already solved.

Rules (3)

Loaded when the agent opens a matching file.

Events are declared in the catalogue, named object_verb, past tense

Loads onsrc/lib/analytics/**src/app/**src/components/**.claude/rules/event-naming.md
The catalogue is the only place an event name is born

src/lib/analytics/events.ts declares every event this repo sends. A capture call whose name is not in EventCatalogue does not compile, and that is the point: the alternative is a project that contains user_signed_up, userSignedUp, signup, Signup Completed and sign_up_success as five unrelated series, none of which has a full history.

  • Never call posthog.capture() directly. Use capture() from @/lib/analytics/posthog-client in the browser and captureServer() from @/lib/analytics/posthog-server on the server. Both are typed against the catalogue and both strip personal data.
  • Adding an event means editing events.ts first: the name, its property type, and its line in EVENT_QUESTIONS. The /add-event skill does all three.
  • $pageview, $pageleave and autocapture events are PostHog's, not yours. Do not add them to the catalogue and do not fire them by hand; the provider in src/lib/analytics/provider.tsx owns page views.
Naming

object_verb, past tense, snake_case, checked by EVENT_NAME_PATTERN.

Write thisNot thisWhy
subscription_startedstartSubscriptionEvents are records of things that happened, not function calls
checkout_completedcheckout_completePast tense reads correctly in every funnel step label
project_createdcreate_projectObject first, so an alphabetical event list groups by object
onboarding_step_completedonboarding_2_doneThe step number is a property, never part of the name

Rules that follow from that table:

  • Two words minimum: an object and a verb. clicked and error are not events.
  • Never encode a value in the name. plan_pro_purchased and plan_team_purchased are one event with a plan property: otherwise every new plan silently breaks every chart.
  • Never put an id, an email, a URL or a timestamp in a name.
  • Pick the object's singular noun and stay with it. If the code calls it a project, the events say project_, not workspace_.
Properties
  • Properties are dimensions you will group or filter by: plan, source, step, method, interval. Low cardinality, stable spelling, snake_case.
  • Types come from the catalogue. Prefer a union of literals ("month" | "year") over string: it stops a typo becoming a permanent extra series in a breakdown.
  • No PII. FORBIDDEN_PROPERTY_KEYS in events.ts is enforced at runtime by scrubProperties(); do not work around it by renaming email to user_contact.
  • Never send a whole object. capture("project_created", { ...project }) ships every column the table gains in future, including ones added after this line was written.
Renaming an event is not free

A rename splits the history: charts built on the old name stop at the rename, charts built on the new one start there. If you must rename, fire both names for one full reporting period, migrate the insights, then delete the old one, and record the change in docs/solutions/posthog/, because the next person to see the seam in a graph will otherwise treat it as a product event.

Identify before the first event that matters, reset on sign-out

Loads onsrc/lib/analytics/**src/app/**src/components/**.claude/rules/identify-timing.md
The distinct id is your database id
  • identifyViewer({ id }) from @/lib/analytics/identify takes the user's primary key from your database. Never the email, never a per-device uuid, never a session id. Emails change and sessions end; when they do, one person becomes two in every chart and the retention curve quietly lies.
  • The same id is used on the server. captureServer({ distinctId: user.id }) and the browser's identify must agree, or a funnel that crosses the network boundary drops everyone at the crossing.
Timing
  • Identify where the session is already loaded (the layout or provider that knows who the user is) not in the component that happens to need analytics.
  • Identify before the first event you care about attributing. PostHog merges the current anonymous history into the person on identify, but only the current one: events captured in an earlier session, or after a full page load and before identify runs, stay on the anonymous profile forever.
  • The order that works: session resolves → identifyViewer() → feature events. The order that loses data: page renders → signup_completed fires → an effect three levels down calls identify a tick later.
  • Identify is idempotent here. identifyViewer() tracks the last id it saw, so re-rendering does not re-identify, and identifying a different id resets first rather than stitching two people together.
Sign-out
  • Call resetViewer() in the sign-out path, always, before the redirect. Not calling it means the next person on that browser inherits the previous user's distinct id: a real problem on shared machines, demo laptops and support sessions, and one you cannot repair after the fact.
  • Never call posthog.reset() on a route change, a token refresh or a failed request. Reset means "a different human is here now"; using it as a cache clear breaks every session that follows.
Person properties
  • Set traits through identifyViewer({ traits }) or setViewerTraits() and keep them inside the ViewerTraits shape: plan, role, signup date, acquisition source, account id.
  • Anything authoritative (plan, entitlement, role) is set from the server with identifyServer(), from the code that changed it. The browser is not a reliable narrator about a subscription.
  • Do not put the email or the name on the profile in this repo. The join back to a human belongs in your database, where a deletion request is one statement.
Groups
  • For a multi-tenant product, pass organisation to identifyViewer() and groups to captureServer(). Without it, "how many accounts activated" is unanswerable: you can only count users, and one account with forty seats looks like forty activations.

Server events for anything a client can lie about, and never PII in properties

Loads onsrc/lib/analytics/**src/app/**src/components/**.claude/rules/server-truth-and-pii.md
Which side fires it

A browser event is a claim. A server event is a record. Choose by asking what happens when the number is wrong.

Fire from the server (captureServer() in @/lib/analytics/posthog-server):

  • anything involving money: subscription_started, subscription_cancelled, refunds, plan changes. These come from the payment webhook, which is the only place that knows a charge succeeded.
  • anything about permissions, roles, quota or entitlement.
  • anything whose absence is a bug you would investigate: account created, invite accepted, job completed, export delivered.
  • anything that must be complete for a report someone else reads. Browser events are missing 10-30% of consumer traffic to ad blockers even with the /ingest proxy, plus everyone who closed the tab mid-request.

Fire from the browser (capture() in @/lib/analytics/posthog-client):

  • intent and interface behaviour: pricing_viewed, checkout_started, signup_started, an empty state seen, a filter used.
  • anything the server genuinely cannot see, which is most of what happens between two navigations.

Never fire the same event from both sides. Duplicated events double every count and there is no way to tell the copies apart afterwards. If a flow needs both, they are two events: checkout_started (browser, intent) and subscription_started (server, fact).

Never trust a value the client supplied
  • captureServer() takes the distinct id and the properties from server state: the session, the database row, the webhook payload you verified. Never from a request body a client composed.
  • A route handler that captures { plan: body.plan } is instrumenting what the browser claimed, not what was sold. Read the plan from the subscription you just wrote.
  • An analytics call is never a reason to relax an authorisation check, and never runs before one.
No personal data in event properties
  • Event properties are copied into every downstream tool, read by anyone with project access, and retained for years. FORBIDDEN_PROPERTY_KEYS in events.ts (email, name, phone, address, IP, tokens, card numbers) is stripped at runtime by scrubProperties(). Do not rename a field to sneak it past the list.
  • No secrets, ever: no API keys, no session tokens, no signed URLs. An analytics payload is an exfiltration path with a nice dashboard on top.
  • No free-text a user typed. A search query, a support message or a project description can contain anything, including someone else's personal data. Capture the length, the result count or a category instead.
  • Ids are fine when they are your ids and mean nothing outside your database: account_id, project_id. A third-party id (Stripe customer, OAuth subject) is a join key to someone else's system: keep it out.
Analytics never breaks the product
  • capture() and captureServer() do not throw and are never awaited in a request path. If PostHog is down, the checkout button still works.
  • Do not put a capture call inside a database transaction, and do not let one block a redirect. captureServer() flushes with after() precisely so the response is already on its way.

Skills (2)

Invoked by name.

  • /add-event

    Add a product event end to end: catalogue entry, the question it answers, the capture call on the correct side of the network, and a check that it arrives.

    .claude/skills/add-event/SKILL.md

  • /ask-product

    Answer a question about user behaviour from this repo's event catalogue and the PostHog project, with the caveats that make the number usable.

    .claude/skills/ask-product/SKILL.md

Subagents (1)

  • product-analyst

    Read-only product analyst. Answers questions about user behaviour from the event catalogue and the PostHog project, and says plainly when the instrumentation cannot answer them.

    ToolsReadGrepGlobmcp__posthog

How it fits

What PostHog needs, and what it goes well with

The resolver enforces this before it generates anything, and names every addition it makes.

Requires

Nothing. PostHog stands on its own.

Pairs well with

  • An error tracking battery. Suggested, never added for you.

Cannot be combined with

No hard conflicts.

Compared with the alternatives

Build a repo with PostHog

Free and MIT. The builder opens with PostHog picked. You download the zip right away, and we email you the link too.