PostHog Setup
Analytics is the one system where bugs don't throw errors. Wire PostHog wrong and everything looks fine: the dashboard fills with numbers, the charts go up and to the right, and six months later you make a real decision based on data that was double-counted, half-anonymous, or missing every purchase that happened while a tab was closed. Nothing crashed. You just steered with a bent compass.
The wiring mistakes are specific and repeatable, which is good news, because it means a spec can prevent them. Single-page apps navigate without full page loads, so pageviews need to be captured on route change or you'll record one view per visit and wonder why your funnel starts at the front door and ends there too. identify ties events to a user; call it with your database's user id, not an email, because emails change and when one does, that user's history splits in half. And the events you actually bill decisions on, signups and payments, belong on the server, because ad blockers eat somewhere around a third of client-side events and they do not eat them evenly.
One more habit this spec bakes in: every event that gets captured is written down first, in a small table your agent keeps in the repo. Untracked event sprawl is how you end up with signup, sign_up, and user_signed_up all meaning the same thing and no funnel that trusts any of them. The event taxonomy template goes deeper; this spec just refuses to let the sprawl start.
Prerequisites
- A PostHog account (cloud free tier is fine): project API key and host from Settings.
# Spec: PostHog analytics wiring
Instrument [app name] with PostHog: pageviews that survive client-side
routing, identity tied to our user ids, and revenue events captured
server-side. No event fires unless it appears in the event table below.
## Stack
- Framework: [e.g., Next.js 14 App Router / SvelteKit / plain React + Vite]
- PostHog Cloud [US / EU; EU if your users are, pick once, migrating is pain]
## Environment variables
```bash
NEXT_PUBLIC_POSTHOG_KEY=phc_... # project API key; client-safe
NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com # or eu.i.posthog.com
POSTHOG_API_KEY=phx_... # personal/server key, server ONLY
```
## Client init (once, at the app root)
- Initialize `posthog-js` in a provider mounted once. Guard against double
init in dev (React strict mode mounts twice; two inits = doubled events).
- `capture_pageview: false` in the config: we capture pageviews manually
because [framework] routes without full page loads.
- On every route change, `posthog.capture('$pageview')` from a hook watching
[the router's pathname]. Verify in the PostHog activity feed that one
client-side navigation = exactly one pageview.
- Dev traffic: when `NODE_ENV !== 'production'`, either skip init or call
`posthog.opt_out_capturing()`. Your own clicking is not user behavior.
## Identity
- On login (and on session restore): `posthog.identify(user.id, { email,
plan })`. The first argument is OUR database user id, never the email:
emails change, ids don't, and identity built on an email splits when it
does.
- On signup, identify the same way; PostHog merges the anonymous pre-signup
events into the new identity automatically.
- On logout: `posthog.reset()`. Without it, the next person on a shared
laptop inherits the previous user's identity and your per-user numbers
quietly rot.
## Server-side events (the ones money rides on)
- Use `posthog-node` with the project key. Capture from the server whenever
the event IS the business: `signup_completed` in the signup handler,
`payment_succeeded` in the Stripe webhook (never the thank-you page; the
webhook template explains why the page can't be trusted).
- Pass `distinct_id: user.id` so server events join the same identity.
- `await posthog.shutdown()` (or flush) before serverless handlers return,
or events die in the buffer when the function freezes.
## Event table (the only events allowed to exist)
| Event | Fires when | Properties |
|---------------------|-----------------------------------|-----------------------|
| $pageview | route change | (automatic) |
| signup_completed | server confirms account creation | plan |
| [feature]_used | [the action, described exactly] | [what varies] |
| payment_succeeded | Stripe webhook confirms payment | amount_usd, plan |
Names are lowercase snake_case, object_verb, past tense for completed
things. Adding an event means adding a row here in the same PR. An event
not in this table does not get captured.
## Privacy floor
- Never capture passwords, tokens, message bodies, or free-text user input
as properties. Property values show up in every export forever.
- Mask sensitive inputs from autocapture/replay with the `ph-no-capture`
class if session replay is ever enabled.
- [If EU users: EU host above, and wire PostHog's consent opt-in before
init. Decide now, not after the data exists.]
## Verification (before calling this done)
1. Activity feed shows one $pageview per navigation, including client-side
route changes. Refresh a page: still one, not two.
2. Log in: events attach to your user id. Log out: `posthog.reset()`
confirmed by the next event being anonymous.
3. Complete a test purchase with an ad blocker enabled: payment_succeeded
arrives anyway, because the server sent it.
4. Grep the codebase for `posthog.capture` and `.capture(`: every event
name found appears in the table above, spelled identically.Adaptation notes:
- Feature flags and session replay ride on this same init; turn them on later without rewiring. Get identity right first, because flags keyed to a broken identity misfire per-user.
- Self-hosting PostHog swaps the host env var and nothing else in this spec. Start on cloud; self-host when you have a reason, not a vibe.
- Ad blockers also eat the client library itself, not just events. A reverse proxy through your own domain (PostHog's docs cover it) recovers most of that; do it when the numbers start mattering, and keep server-side capture for revenue regardless.
- If marketing wants UTM tracking, PostHog picks up UTM parameters on its own; resist hand-rolling any of it into properties.
- The mistake: calling identify with whatever string is handy. Email today, database id after the refactor, and now the same human is two people in every chart, forever. Pick the immutable id on day one; it's the one analytics decision you can't quietly fix later.