Set up PostHog product analytics: $ARGUMENTS
$ARGUMENTS should include:
--host <url>— PostHog Cloud (default: https://app.posthog.com) or self-hosted URL--replay— enable session replay (default: enabled with 10% sample rate)--flags— enable feature flags (default: enabled)--dry-run— preview without writing
Why PostHog Alongside GA4
GA4 = marketing analytics (traffic sources, conversions, attribution) PostHog = product analytics (user behavior, feature adoption, funnels, retention)
They are complementary. PostHog excels at:
- Funnels (where do users drop off in the course enrollment flow?)
- Session replay (watch what a user actually did before churning)
- Feature flags (roll out AI Lab to 10% of users)
- Retention cohorts (which users come back week over week?)
- Product-specific events with rich metadata
Before Starting
- Read
src/app/layout.tsx— understand root layout and existing providers - Read
src/lib/env.ts— understand env var validation - Read
src/lib/tenant.ts— understandgetTenantOrgId() - Read
src/lib/supabase/— understand auth session structure - Grep for
posthogto check if already partially installed
Step 1 — Install
pnpm add posthog-js posthog-node
posthog-js— browser SDKposthog-node— server-side event capture (API routes, Server Actions)
Step 2 — Environment Variables
Add to src/lib/env.ts:
// In the public (client-side) schema:
NEXT_PUBLIC_POSTHOG_KEY: z.string().startsWith('phc_').optional(),
NEXT_PUBLIC_POSTHOG_HOST: z.string().url().default('https://app.posthog.com'),
Add to .env.local.example:
# --- PostHog ---
NEXT_PUBLIC_POSTHOG_KEY=phc_xxxxxxxxxxxxxxxxxxxx
NEXT_PUBLIC_POSTHOG_HOST=https://app.posthog.com
# POSTHOG_PERSONAL_API_KEY= # Only needed for Admin API (creating projects, etc.)
Step 3 — PostHog Provider (Client)
Create src/lib/analytics/posthog-provider.tsx:
'use client'
import posthog from 'posthog-js'
import { PostHogProvider as PHProvider } from 'posthog-js/react'
import { useEffect } from 'react'
export function PostHogProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
const key = process.env.NEXT_PUBLIC_POSTHOG_KEY
const host = process.env.NEXT_PUBLIC_POSTHOG_HOST
if (!key) return // graceful no-op without key
posthog.init(key, {
api_host: host,
person_profiles: 'identified_only', // don't create anonymous profiles
capture_pageview: false, // manual in Next.js (route changes)
capture_pageleave: true,
session_recording: {
maskAllInputs: true, // mask sensitive input values
maskInputFn: (text, element) => {
if (element?.type === 'password') return '***'
return text
},
},
loaded: (ph) => {
if (process.env.NODE_ENV === 'development') {
ph.debug()
ph.opt_out_capturing() // don't track dev sessions in prod PostHog
}
},
})
}, [])
return <PHProvider client={posthog}>{children}</PHProvider>
}
Step 4 — Wire Provider in Root Layout
Edit src/app/layout.tsx:
import { PostHogProvider } from '@/lib/analytics/posthog-provider'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<PostHogProvider>
{children}
</PostHogProvider>
</body>
</html>
)
}
Step 5 — Page View Tracking (Next.js App Router)
Next.js App Router doesn't emit page view events on route changes automatically.
Create src/lib/analytics/posthog-page-view.tsx:
'use client'
import { usePathname, useSearchParams } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
import { useEffect } from 'react'
/**
* Tracks page views on route changes in Next.js App Router.
* Add to root layout inside a Suspense boundary.
*/
export function PostHogPageView() {
const pathname = usePathname()
const searchParams = useSearchParams()
const posthog = usePostHog()
useEffect(() => {
if (!pathname || !posthog) return
let url = window.origin + pathname
if (searchParams.toString()) {
url += `?${searchParams.toString()}`
}
posthog.capture('$pageview', { $current_url: url })
}, [pathname, searchParams, posthog])
return null
}
Add to src/app/layout.tsx:
import { Suspense } from 'react'
import { PostHogPageView } from '@/lib/analytics/posthog-page-view'
// Inside PostHogProvider:
<PostHogProvider>
<Suspense fallback={null}>
<PostHogPageView />
</Suspense>
{children}
</PostHogProvider>
Step 6 — Person Identification (Tie Events to Users)
Create src/lib/analytics/posthog-identify.tsx:
'use client'
import { usePostHog } from 'posthog-js/react'
import { useEffect } from 'react'
import { getTenantOrgId } from '@/lib/tenant'
interface PostHogIdentifyProps {
userId: string | null
orgId: string | null
role: string | null
email?: string | null // optional — PostHog can store email for internal use
}
/**
* Identifies the current user in PostHog.
* Mount this in authenticated layouts only.
*/
export function PostHogIdentify({ userId, orgId, role, email }: PostHogIdentifyProps) {
const posthog = usePostHog()
useEffect(() => {
if (!posthog) return
if (userId) {
posthog.identify(userId, {
// Person properties — shown in PostHog UI
org_id: orgId,
user_role: role,
tenant_id: getTenantOrgId(),
// email is optional — PostHog handles it specially for person display
...(email && { email }),
})
// Group analytics — tie events to org and tenant
if (orgId) {
posthog.group('organization', orgId, {
name: orgId, // update with real org name if available
tenant_id: getTenantOrgId(),
})
}
posthog.group('tenant', getTenantOrgId())
} else {
// User logged out — reset identity
posthog.reset()
}
}, [userId, orgId, role, posthog])
return null
}
Mount in authenticated layout (e.g., src/app/(auth)/layout.tsx):
import { PostHogIdentify } from '@/lib/analytics/posthog-identify'
import { createSupabaseServerClient } from '@/lib/supabase/server'
export default async function AuthLayout({ children }) {
const supabase = createSupabaseServerClient()
const { data: { user } } = await supabase.auth.getUser()
return (
<>
<PostHogIdentify
userId={user?.id ?? null}
orgId={user?.user_metadata?.org_id ?? null}
role={user?.user_metadata?.role ?? null}
email={user?.email ?? null}
/>
{children}
</>
)
}
Step 7 — Typed Event Utility
Create src/lib/analytics/posthog-events.ts:
'use client'
import posthog from 'posthog-js'
import { getTenantOrgId } from '@/lib/tenant'
type ProductEvent =
| 'course_started'
| 'course_completed'
| 'module_viewed'
| 'resource_viewed'
| 'assessment_taken'
| 'upgrade_clicked'
| 'checkout_started'
| 'purchase_completed'
| 'ai_lab_opened'
| 'ai_chat_sent'
| 'pathway_viewed'
| 'framework_viewed'
interface EventProps {
content_id?: string
content_type?: string
content_name?: string
week_number?: number
value?: number
currency?: string
[key: string]: string | number | boolean | undefined
}
/**
* Fire a typed PostHog event with automatic tenant context.
*/
export function captureEvent(event: ProductEvent, props: EventProps = {}) {
if (typeof window === 'undefined') return
posthog.capture(event, {
tenant_id: getTenantOrgId(),
environment: process.env.NODE_ENV,
...props,
})
}
Step 8 — Server-Side Event Capture
Create src/lib/analytics/posthog-server.ts:
import 'server-only'
import { PostHog } from 'posthog-node'
let _client: PostHog | null = null
function getPostHogClient(): PostHog | null {
const key = process.env.NEXT_PUBLIC_POSTHOG_KEY
const host = process.env.NEXT_PUBLIC_POSTHOG_HOST
if (!key) return null
if (!_client) {
_client = new PostHog(key, {
host,
flushAt: 1, // flush immediately in serverless
flushInterval: 0,
})
}
return _client
}
/**
* Capture a server-side PostHog event.
* Use in API routes, Server Actions, or webhooks.
*/
export async function captureServerEvent(
distinctId: string, // Supabase user UUID, or 'anonymous'
event: string,
properties: Record<string, unknown> = {}
) {
const client = getPostHogClient()
if (!client) return
client.capture({
distinctId,
event,
properties: {
tenant_id: process.env.TENANT_ORG_ID,
environment: process.env.NODE_ENV,
$lib: 'posthog-node',
...properties,
},
})
// In serverless, flush is critical — events are lost otherwise
await client.flush()
}
Usage in a Stripe webhook:
import { captureServerEvent } from '@/lib/analytics/posthog-server'
// In checkout.session.completed:
await captureServerEvent(session.client_reference_id, 'purchase_completed', {
value: session.amount_total / 100,
currency: session.currency,
})
Step 9 — Feature Flags
Use PostHog feature flags to gate features per tenant/user.
'use client'
import { useFeatureFlagEnabled } from 'posthog-js/react'
export function AILabSection() {
const isEnabled = useFeatureFlagEnabled('ai-lab')
if (!isEnabled) return null
return <div>AI Lab content</div>
}
Create flags in PostHog dashboard → Feature Flags → Create:
ai-lab— rolled out bytenant_idoruser_rolenew-course-player— percentage rolloutpricing-v2— A/B test
Step 10 — Verify
- Open PostHog dashboard → Live Events — confirm events appear
- Navigate between pages — confirm
$pageviewfires - Sign in — confirm
identifysets user properties - Trigger
captureEvent('course_started', { content_id: 'test' })— verify in Live Events - Check group analytics — org and tenant groups appear
Anti-Patterns
- Do NOT call
posthog.capture()in Server Components — useposthog-nodein server context - Do NOT identify anonymous users — use
person_profiles: 'identified_only' - Do NOT skip
posthog.reset()on logout — previous user data bleeds into next session - Do NOT track events in development without
opt_out_capturing()— pollutes production data - Do NOT store sensitive data in event properties — PostHog data is accessible to team members
- Do NOT forget
await client.flush()in serverless — events are silently dropped otherwise - Do NOT wrap the PostHogProvider in a
use clientlayout root — keep it as deep as possible