Loading tutorials…
Loading tutorials…
PostHog has a one-line install — and a hundred ways to get it wrong. This walks through web, Next.js App Router, React Native, and the server SDK (which you need for any event that can't be lost). With the autocapture gotchas that show up at month two.
Who this is forEngineers installing PostHog into a real product. Especially helpful if you are on Next.js App Router (the SSR config is non-obvious), have a React Native app, or need server-side capture for revenue events that absolutely cannot be missed.
What you'll need
Step 1
Snippet (script tag) is easiest. npm gives bundling control. Reverse-proxy (via your domain) bypasses ad-blockers. Most production apps end up on npm + proxy.
Snippet install: paste the PostHog JS snippet from Project Settings → Project Variables into your `<head>`. Works on any site (WordPress, Webflow, raw HTML). Easiest for non-engineers.
npm install: `npm i posthog-js` (web) or `npm i posthog-node` (server). Gives you tree-shaking, TypeScript types, and explicit init. Required for any framework with SSR (Next.js, Remix, Nuxt).
Reverse proxy: route PostHog traffic through your own domain (e.g. `events.acme.com`) to bypass ad-blockers and Brave/Firefox tracking-protection. Cuts event-loss by 15-30%. Set up via Vercel rewrites, Cloudflare Workers, or a thin Node proxy.
For most modern SaaS: npm install + reverse proxy. Snippet is fine for marketing sites.
If you are on a non-JS frontend (Swift, Kotlin, Flutter), use the platform-native SDK from posthog.com/docs.
Step 2
Snippet in `<head>`, or npm + init in your app entry point. Configure autocapture, session_recording, and PII masking on init.
Snippet path: in PostHog → Project Settings → Project Variables, copy the JS snippet. Paste it into your `<head>` just before `</head>`. Done.
npm path: `npm i posthog-js`. In your app entry (e.g. `src/index.tsx`): `import posthog from "posthog-js"; posthog.init("phc_YOUR_KEY", { api_host: "https://us.i.posthog.com", autocapture: true, session_recording: { maskAllInputs: true }, respect_dnt: true });`
Replace `us.i.posthog.com` with `eu.i.posthog.com` if you are on EU Cloud, or your reverse-proxy domain if configured.
Set `disable_session_recording: true` initially. Turn replay on AFTER you have configured masking (see step 5).
Add `loaded: function(ph) { ph.identify(currentUserId) }` if you have an authenticated user at load time. Without identify, every session is anonymous.
Step 3
App Router needs a Client Component wrapper. PostHog cannot init in Server Components. Use a Provider pattern and a separate pageview hook for client-side route changes.
Create `app/providers.tsx` as a Client Component (with `"use client";` at top).
Inside the provider: `import posthog from "posthog-js"; import { PostHogProvider } from "posthog-js/react"; if (typeof window !== "undefined") { posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, { api_host: "/ingest", ui_host: "https://us.posthog.com", capture_pageview: false }); }`. Wrap children in `<PostHogProvider client={posthog}>`.
Set `capture_pageview: false` — App Router does soft navigations and PostHog cannot detect them automatically. You will capture pageviews manually.
Create a `PostHogPageview` Client Component that uses `usePathname` and `useSearchParams` hooks, and calls `posthog.capture("$pageview")` on changes. Mount it inside the provider.
Add a Next.js rewrite in `next.config.js`: `{ source: "/ingest/:path*", destination: "https://us.i.posthog.com/:path*" }`. This is the reverse proxy — bypasses ad-blockers.
Import the provider in `app/layout.tsx` and wrap `{children}`. Never import `posthog-js` directly in a Server Component — it will error.
Step 4
Use `posthog-react-native`. Configure a host, enable replay (optional), and call identify on login. Capture lifecycle events manually.
`npx expo install posthog-react-native` (Expo) or `npm i posthog-react-native @react-native-async-storage/async-storage react-native-device-info` (bare RN).
In `App.tsx`: `import { PostHogProvider } from "posthog-react-native"; <PostHogProvider apiKey="phc_YOUR_KEY" options={{ host: "https://us.i.posthog.com" }}><YourApp/></PostHogProvider>`.
Inside any screen / component: `import { usePostHog } from "posthog-react-native"; const posthog = usePostHog(); posthog.capture("video_played", { videoId: id });`.
For session replay on mobile: enable `enableSessionReplay: true` in PostHog options AND turn on Session Replay in your project settings. Mobile recording is in beta as of 2026 and captures less detail than web.
Call `posthog.identify(userId, { email, plan })` on login. Call `posthog.reset()` on logout to break the session continuity.
Step 5
Server-side capture for events that cannot be lost — purchases, signup completions, subscription changes. Browser capture is fire-and-forget; server capture is reliable.
Node: `npm i posthog-node`. In a singleton (e.g. `src/lib/posthog-server.ts`): `import { PostHog } from "posthog-node"; export const phServer = new PostHog(process.env.POSTHOG_KEY!, { host: "https://us.i.posthog.com" });`.
In your Stripe webhook or signup completion handler: `await phServer.capture({ distinctId: user.id, event: "purchase_completed", properties: { amount: 99, currency: "USD", plan: "pro" } });`.
Critical: call `await phServer.shutdown()` at process exit (Lambda handlers, Vercel functions) or events will be lost in the in-memory queue.
Other languages: `posthog-python`, `posthog-ruby`, `posthog-go`, `posthog-php`, `posthog-java`. All follow the same pattern.
Rule of thumb: anything that has a dollar value or a regulatory implication captures server-side. Pageviews and UI interactions stay client-side.
Step 6
Default PostHog captures email and IP. For GDPR / SOC 2 / health products, mask DOM inputs, redact properties, and honour DNT.
In `posthog.init`, set `respect_dnt: true`. PostHog will skip capture for users with the DNT browser flag set.
Set `mask_all_text: true` and `mask_all_element_attributes: true` on session_recording config if you handle sensitive data.
On any element with PII in the DOM, add `class="ph-no-capture"`. PostHog skips capture and masks the element in replay.
Override IP storage: `posthog.init(KEY, { ip: false })`. Useful for GDPR-strict deployments.
In your event-capture calls, never include raw PII in property values. Hash emails: `properties: { email_hash: sha256(email) }`. Reference user IDs, not user objects.
Step 7
Deploy the install to staging. Open your app in incognito. Walk through 3 user flows. Verify events appear in PostHog → Activity → Live events.
Deploy to staging. Open the staging URL in incognito (a clean session means clean events).
In PostHog, open Activity → Live events. Keep it open in another tab.
In the app, walk through: pageview to homepage, click a CTA, sign up, perform one core action.
Within 10-30 seconds, you should see: `$pageview`, `$autocapture` (for the click), `$identify` (after signup), and your custom event.
If events do not appear: check the `phc_` key is correct, check Network tab for failed POSTs to `us.i.posthog.com` (or your proxy), check ad-blocker is off in the test browser.
Common gotcha: events fire but `distinct_id` is `null` because `identify` was called before init completed. Use `posthog.onFeatureFlags(() => posthog.identify(...))` to wait for init.
Common mistakes
Autocapture-only with no custom events
What goes wrong: Six months in, you try to query "how many users clicked Submit on the checkout form" and find 47 different `$autocapture` events that match the selector. Your funnel queries are unmaintainable. Cost: a 2-week instrumentation rewrite + lost product-decision time.
How to avoid: From day one, fire a named custom event for every conversion-critical action: `posthog.capture("checkout_submitted", { plan, amount })`. Use autocapture for exploration only. Write an event-naming doc (verb_noun, snake_case, present-tense) and enforce it in PR reviews.
No server-side capture for revenue events
What goes wrong: Browser capture is fire-and-forget — if the user closes the tab in the 50ms before the request completes, the event is gone. Tab-close happens on purchase-confirmation pages constantly. You under-report revenue by 5-15% and your subscription dashboard disagrees with Stripe by thousands of dollars.
How to avoid: Move any revenue event (purchase, subscription_started, plan_upgraded) to a server-side webhook capture using `posthog-node`. Compare PostHog event counts to Stripe weekly — they should match within 1%.
Forgetting `posthog.reset()` on logout
What goes wrong: User A logs out, User B logs in on the same browser. PostHog still associates events with User A's distinct_id. Cohort analysis is poisoned. Identity resolution becomes impossible. You see 30% of users showing impossible behavior patterns.
How to avoid: In your logout handler: `posthog.reset()`. This generates a fresh anonymous distinct_id. Then call `posthog.identify(newUserId)` when the new user logs in.
Capturing PII in event properties
What goes wrong: You ship `posthog.capture('signup', { email, phone, password_strength })`. PostHog now stores plaintext emails and phone numbers indefinitely. On the next GDPR DSAR or SOC 2 audit, you have to manually scrub them — a multi-day job that costs ~$2,000-5,000 of engineering or external help.
How to avoid: Never put raw PII in event properties. Hash emails (`sha256(email)`), use user IDs not user objects, and configure `mask_all_text: true` for session recordings. Audit your top 20 event names quarterly with `event_definitions` API.
Skipping the reverse proxy
What goes wrong: Brave, Firefox-with-strict-mode, uBlock Origin, and Privacy Badger all block requests to `*.posthog.com` by default. You silently lose 15-30% of events from technical users — exactly the demographic that matters most for B2B SaaS adoption.
How to avoid: Set up a reverse proxy via Vercel rewrite, Cloudflare Worker, or a thin Node proxy. Route through your own domain (`events.acme.com` or `/ingest/*`). Cuts ad-blocker loss to <5%.
Init called multiple times
What goes wrong: PostHog is initialized in `app/layout.tsx` AND in a child component. The second init silently overrides the first. Session continuity breaks. Distinct IDs get rotated mid-session. Funnel completion rates appear to drop 40% overnight.
How to avoid: Init exactly once in your app entry (a top-level Provider component). Other components consume the singleton via `usePostHog()` hook. Add a console.log inside init in dev mode to catch double-init early.
Recap
Done — what's next
How to set up a PostHog account the right way
Read the next tutorial
Hand it off
A clean PostHog install is 60% of the work. The other 40% is the event taxonomy, server/client split, and identity-resolution discipline that compound over the next 12 months. EverestX matches you with a vetted analytics specialist who has done this 50+ times, from $14-16/hr — most ongoing engagements run $400-1,200/mo.
See specialist rates
Both. Autocapture is your safety net — it ensures you can answer questions about clicks you forgot to instrument. Custom events are your primary instrumentation for anything that drives a product decision. Most healthy installs have ~40 custom events + autocapture enabled.
Use the same PostHog project for all surfaces. Send a consistent `distinct_id` from each platform (your internal user ID). PostHog stitches sessions across platforms via the shared distinct_id and gives you a unified person view.
`identify(userId)` says "this anonymous session is now this user." `alias(oldId, newId)` merges two identities. Use identify on every login. Use alias only in rare cases — e.g. when a guest checkout is later linked to a registered account.
Create a thin `lib/analytics.ts` wrapper that exposes domain methods (`trackPurchase`, `trackSignup`). Call PostHog inside. Your business logic calls `trackPurchase`; never `phServer.capture`. This isolates the PostHog dependency and makes it trivial to swap analytics tools.
PostHog-JS is ~50KB gzipped and loads async. It adds ~5-15ms to initial page load when proxied through your own domain (~100-200ms direct, hence the proxy recommendation). Session replay adds another ~10KB and trivial CPU overhead. Negligible for any site that's already shipping a React bundle.
PostHog
PostHog is generous on the free tier but expensive when you outgrow it without realising. This walks through account setup, region choice (US vs EU), org structure, and the billing-cap settings that stop a runaway event from becoming a $4,000 surprise.
PostHog
Most teams ship 50 events in week one, then spend month four rewriting them because the names made no sense in retrospect. This walks through an event taxonomy that scales, a property schema that does not drift, and the identify flow that keeps your funnel reports honest.
PostHog
Session replay is the most valuable PostHog feature for debugging product UX — and the most dangerous if you skip the masking step. This walks through enabling replay, configuring DOM-level privacy, controlling storage cost, and the compliance checklist.
PostHog
DIY PostHog is the right call up to a point. Then it isn't. This is the honest framework: when the cost of self-managing exceeds the cost of hiring, and how to tell which side you're on.