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-jswith 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 moreShow fewer
- Autocapture cuts both ways. It gives you data before you instrument anything, and it fills your project with
$autocaptureevents 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.
- 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 verifyto 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. Usecapture()from@/lib/analytics/posthog-clientin the browser andcaptureServer()from@/lib/analytics/posthog-serveron the server. Both are typed against the catalogue and both strip personal data. - Adding an event means editing
events.tsfirst: the name, its property type, and its line inEVENT_QUESTIONS. The/add-eventskill does all three. $pageview,$pageleaveand autocapture events are PostHog's, not yours. Do not add them to the catalogue and do not fire them by hand; the provider insrc/lib/analytics/provider.tsxowns page views.
Naming
object_verb, past tense, snake_case, checked by EVENT_NAME_PATTERN.
| Write this | Not this | Why |
|---|---|---|
subscription_started | startSubscription | Events are records of things that happened, not function calls |
checkout_completed | checkout_complete | Past tense reads correctly in every funnel step label |
project_created | create_project | Object first, so an alphabetical event list groups by object |
onboarding_step_completed | onboarding_2_done | The step number is a property, never part of the name |
Rules that follow from that table:
- Two words minimum: an object and a verb.
clickedanderrorare not events. - Never encode a value in the name.
plan_pro_purchasedandplan_team_purchasedare one event with aplanproperty: 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 sayproject_, notworkspace_.
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") overstring: it stops a typo becoming a permanent extra series in a breakdown. - No PII.
FORBIDDEN_PROPERTY_KEYSinevents.tsis enforced at runtime byscrubProperties(); do not work around it by renamingemailtouser_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/identifytakes 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'sidentifymust 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_completedfires → 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 })orsetViewerTraits()and keep them inside theViewerTraitsshape: 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
organisationtoidentifyViewer()andgroupstocaptureServer(). 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
/ingestproxy, 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_KEYSinevents.ts(email, name, phone, address, IP, tokens, card numbers) is stripped at runtime byscrubProperties(). 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()andcaptureServer()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 withafter()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
Solution docs (5)
Written before you hit the problem. Each one ships in the repo at docs/solutions/ and is published here as a cookbook page.
- Ad blockers eat a third of your analytics: proxy ingestion through your own domainBlockers match on hostnames, not behaviour. A first-party /ingest route handler forwards events to PostHog server-side and recovers most of the missing traffic.docs/solutions/posthog/ad-blocker-reverse-proxy.md
- Event names that still make sense in twelve monthsWhy analytics projects rot into five spellings of "signup", and the object_verb convention plus a typed catalogue that stops it.docs/solutions/posthog/event-naming-that-survives.md
- Feature flags without the flickerClient-side flags render the control experience first and swap it a beat later. Evaluate on the server, pass the decision down, and keep a bootstrap for the client hooks.docs/solutions/posthog/feature-flags-without-flicker.md
- The identify race that empties your signup funnelEvents fired before identify() stay on the anonymous profile forever. Here is why the merge is not retroactive, and the ordering that fixes it.docs/solutions/posthog/identify-race-conditions.md
- Server events versus client events, and when each one liesA browser event is a claim, a server event is a record. Which side to fire from, why serverless drops events without after(), and how to keep the two from double-counting.docs/solutions/posthog/server-vs-client-events.md
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.
Presets
Presets that already include PostHog
A tested selection with its own file tree and its own generated CLAUDE.md. Start from one instead of from blank.