This post is a writeup of the PostHog setup we shipped on lrok. If you're a small team setting up product analytics for the first time, you can copy this nearly verbatim and skip the 4-week phase where you're capturing pageviews and not much else.
The end-to-end pipeline:
browser ──── /ingest ────▶ PostHog (EU)
▲
│
edge backend (Go) ────────────┤
posthog-go capture() │
│
Clerk webhook ────────────────┘
posthog-node capture()
All three sources use the same Clerk userID as distinct_id, so events from CLI, browser, and webhook merge on a single PostHog person profile.
Step 1: Browser SDK
// app/_components/PostHogProvider.tsx
'use client'
import posthog from 'posthog-js'
import { PostHogProvider as PHProvider } from 'posthog-js/react'
import { useEffect } from 'react'
export default function PostHogProvider({ children }) {
useEffect(() => {
if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) return
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
api_host: '/ingest', // ← reverse proxy, see step 2
ui_host: 'https://eu.posthog.com', // toolbar links go to the real UI
person_profiles: 'identified_only',
capture_pageview: false, // we drive it via App Router hook
capture_pageleave: true,
mask_all_text: false,
disable_session_recording: true,
respect_dnt: true,
})
}, [])
return <PHProvider client={posthog}>{children}</PHProvider>
}
person_profiles: 'identified_only' is the key flag. Anonymous visitors don't get profiles; only signed-in users do. That keeps the profile count manageable and gives every profile real attribution.
Step 2: Reverse-proxy ingest
// next.config.ts
const nextConfig: NextConfig = {
async rewrites() {
return [
{ source: '/ingest/static/:path*', destination: 'https://eu-assets.i.posthog.com/static/:path*' },
{ source: '/ingest/:path*', destination: 'https://eu.i.posthog.com/:path*' },
{ source: '/ingest/decide', destination: 'https://eu.i.posthog.com/decide' },
]
},
}
Ad-blockers target eu.i.posthog.com and app.posthog.com directly. Posting through your own origin at /ingest recovers ~30-40% of events that would have been blocked otherwise. PostHog officially supports this; their docs call it the "reverse proxy" pattern.
Step 3: Identify on Clerk auth
// app/_components/PostHogIdentify.tsx
'use client'
import { useEffect, useRef } from 'react'
import { useUser } from '@clerk/nextjs'
import { usePostHog } from 'posthog-js/react'
export default function PostHogIdentify() {
const ph = usePostHog()
const { isLoaded, isSignedIn, user } = useUser()
const lastId = useRef<string | null>(null)
useEffect(() => {
if (!isLoaded || !ph) return
if (!isSignedIn || !user) {
if (lastId.current) {
ph.reset()
lastId.current = null
}
return
}
if (lastId.current === user.id) return
ph.identify(user.id,
{ email: user.primaryEmailAddress?.emailAddress, plan: user.publicMetadata.plan ?? 'free' },
{ signup_source: sessionStorage.getItem('lrok-utm-source') ?? '$direct',
created_at: user.createdAt },
)
if (typeof ph.group === 'function') {
ph.group('plan', user.publicMetadata.plan ?? 'free')
}
lastId.current = user.id
}, [isLoaded, isSignedIn, user, ph])
return null
}
The $set_once for signup_source is the magic move. We read UTM params from sessionStorage (stamped by an earlier marketing-page beacon) and persist them on the person profile forever. PostHog then groups all your acquisition sources without you having to join tables.
Step 4: Server-side capture in the webhook
// app/api/webhook/clerk/route.ts
import { withPostHog } from '@/lib/posthog-server'
export async function POST(req: Request) {
// ... svix verification ...
const evt = wh.verify(...)
await withPostHog(async (capture) => {
if (evt.type === 'user.created') {
capture(userId, 'account_created',
{ auth_provider: provider },
{ created_at: now, auth_provider: provider }, // $set_once
{ email, plan: 'free' }, // $set
)
} else if (evt.type === 'subscriptionItem.active') {
capture(userId, 'upgrade_completed', {}, { upgraded_at: now }, { plan: 'pro' })
}
// ... canceled, past_due, etc.
})
return NextResponse.json({ received: true })
}
Why server-side? The browser may navigate away during the Clerk-hosted sign-up handoff. Browser-fired account_created events get lost; webhook events don't. Stripe + Clerk billing webhooks are similarly authoritative for revenue events.
The withPostHog helper wraps new PostHog() + shutdown() so the queued event flushes before the route returns:
export async function withPostHog(fn) {
const client = new PostHog(KEY, { host: 'https://eu.i.posthog.com', flushAt: 1, flushInterval: 0 })
try { return await fn(...) } finally { await client.shutdown() }
}
Step 5: Backend (Go) capture
// internal/analytics/analytics.go
client := posthog.NewWithConfig(apiKey, posthog.Config{ Endpoint: "https://eu.i.posthog.com" })
defer client.Close()
client.Enqueue(posthog.Capture{
DistinctId: userID,
Event: "tunnel_started",
Properties: posthog.NewProperties().Set("protocol", "http").Set("$set_once", map[string]any{ "first_tunnel_at": now }),
})
The $set_once for first_tunnel_at makes activation cohorts trivial in PostHog. You can build a "users who hit first_tunnel within 24h of signup" cohort in two clicks.
Step 6: Privacy posture
The whole pipeline ships with these defaults:
- Session recording: off.
- DNT: respected.
- Sensitive surfaces (Clerk auth widgets, token reveal modals) marked
data-ph-no-captureso autocapture skips them. - All ingestion through
/ingest(same-origin proxy) so the page's CSP can stay strict.
Step 7: Privacy policy
The most-skipped step that always bites later. Update your privacy page to actually mention PostHog as a subprocessor. EU residency, identified-only profiles, no session recording — the same details a customer's compliance review will ask about. Two paragraphs of plain text. Ours →
What this gets you
After ~2 weeks of traffic, the PostHog dashboards have:
- Acquisition funnel —
pageview→signup_started→account_created, broken down bysignup_source. Tells you which marketing channel converts to signups. - Activation funnel —
account_created→tunnel_started. Tells you what % of signups actually become users. - Retention curve —
tunnel_startedweekly cohorts. Tells you whether users come back. - Revenue funnel —
account_created→upgrade_started→upgrade_completed. Tells you what your free → paid conversion is.
All four with one shared distinct_id across browser, server, and CLI.
What I'd avoid
- Tracking everything: don't fire an event on every button click. Pick the canonical CTAs (a typed list in one file is the right pattern). Ours has ~35 events.
- Letting autocapture roam free on auth widgets:
data-ph-no-captureeverywhere a password / token / payment field renders. - Forgetting server-side capture: the browser can navigate away mid-flow. Anything authoritative — accounts, payments — fires from the server.
The whole config is open in the lrok-web-app source. Copy what's useful.