// blog

← all posts

PostHog setup for a 2-person devtool — the actual config

·9 min read·posthog · analytics · tutorial

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-capture so 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 funnelpageviewsignup_startedaccount_created, broken down by signup_source. Tells you which marketing channel converts to signups.
  • Activation funnelaccount_createdtunnel_started. Tells you what % of signups actually become users.
  • Retention curvetunnel_started weekly cohorts. Tells you whether users come back.
  • Revenue funnelaccount_createdupgrade_startedupgrade_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-capture everywhere 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.

// try it yourself

lrok is a hosted reverse-tunnel: one command, public HTTPS URL, reserved subdomain on the free plan. $9/mo flat for unlimited.