Typed event tracking for Next.js 14 — PostHog wrapper with auto page view hook, user identification, a self-hosted Supabase fallback, and six pre-written SQL queries for DAU, WAU, funnel, revenue, and retention cohorts.
PostHog wrapper
initAnalytics(config)— initialises PostHog with lazy import; disables autocapture and built-in pageview tracking; acceptsposthogKey,apiHost,debug,disabledtrack(event, properties?)— captures a typedAnalyticsEventwith an auto-addedtimestamp; logs to console in development; no-ops silently if PostHog isn't loadedidentifyUser(userId, traits?)— callsposthog.identify; pass after login/session restoreresetAnalytics()— callsposthog.reset; call on logout to disassociate the devicetrackPageView(url?)— manual page view capture; defaults towindow.location.pathnamesetUserProperty(key, value)— sets a PostHog person property viapeople.set
React hooks
usePageTracking()— auto-firestrackPageViewonusePathnamechanges; deduplicates consecutive fires; drop into a layout componentuseTrackOnce(event, props?)— fires a single event on mount; useful for impression tracking on detail pages
Self-hosted Supabase fallback
trackToSupabase(event, userId, properties?)— POSTs to/api/analytics; includes session ID (fromsessionStorage), URL, and referrer; use when PostHog is unavailable or for private dataANALYTICS_API_ROUTE— paste-ready content forapp/api/analytics/route.ts; inserts intoanalytics_eventsvia service role
SQL queries
ANALYTICS_QUERIES.dailyActiveUsers— DAU for the last 30 daysANALYTICS_QUERIES.weeklyActiveUsers— WAU for the last 90 daysANALYTICS_QUERIES.topEvents— top 15 events by count in the last 7 daysANALYTICS_QUERIES.conversionFunnel— page view → detail view → purchase started → purchase completed counts for the last 30 daysANALYTICS_QUERIES.revenueByEvent— revenue and purchase count perblock_idfrompurchase_completedeventsANALYTICS_QUERIES.retentionCohort— weekly retention cohort table by signup week
Types
AnalyticsEvent— union of 21 typed event namesEventProperties—Record<string, string | number | boolean | null | undefined>
npm install posthog-jsNEXT_PUBLIC_POSTHOG_KEY=phc_xxxxxxxxxxxxxxxxxxxx # PostHog project API key
NEXT_PUBLIC_POSTHOG_HOST=https://app.posthog.com # or your self-hosted host
# Self-hosted fallback only:
NEXT_PUBLIC_SUPABASE_URL=your Supabase project URL
SUPABASE_SERVICE_ROLE_KEY=service role key (server-only)
// app/providers.tsx
'use client'
import { useEffect } from 'react'
import { initAnalytics, usePageTracking } from '@/blocks/analytics'
function PageTracker() { usePageTracking(); return null }
export function Providers({ children }: { children: React.ReactNode }) {
useEffect(() => {
initAnalytics({ posthogKey: process.env.NEXT_PUBLIC_POSTHOG_KEY! })
}, [])
return <>{children}<PageTracker /></>
}Copy ANALYTICS_API_ROUTE to app/api/analytics/route.ts, then run this in Supabase:
CREATE TABLE analytics_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES profiles(id) ON DELETE SET NULL,
session_id TEXT NOT NULL,
event TEXT NOT NULL,
properties JSONB NOT NULL DEFAULT '{}',
url TEXT,
referrer TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX analytics_event_type_idx ON analytics_events(event, created_at DESC);
CREATE INDEX analytics_user_idx ON analytics_events(user_id, created_at DESC);// Track events anywhere — server or client
import { track } from '@/blocks/analytics'
// After a successful purchase
track('purchase_completed', { block_id: 'auth', amount: 19, currency: 'USD' })
// After a failed purchase
track('purchase_failed', { block_id: 'auth', reason: 'card_declined' })// Identify user after login (call once per session)
import { identifyUser, resetAnalytics } from '@/blocks/analytics'
// On sign-in:
identifyUser(session.user.id, {
email: session.user.email,
role: session.user.role,
plan: 'pro',
createdAt: session.user.createdAt,
})
// On sign-out:
resetAnalytics()// Track a block detail view once on mount
'use client'
import { useTrackOnce } from '@/blocks/analytics'
export function BlockDetailPage({ block }) {
useTrackOnce('block_detail_viewed', { block_id: block.id, price: block.price })
return <div>{/* content */}</div>
}initAnalyticsuses a dynamicimport('posthog-js')— PostHog is not loaded untilinitAnalyticsis called, so events fired before the async import resolves are silently dropped; callinitAnalyticsas early as possible (top ofproviders.tsx)trackis a no-op on the server — the_posthogmodule variable is alwaysnullin RSC/API routes; usetrackToSupabasefor server-side event captureANALYTICS_QUERIESare plain SQL strings — run them viadb.rpc, a Supabase SQL editor, or a migration; they reference theanalytics_eventstable and thepropertiesJSONB column directly, so they only work with the self-hosted fallback, not with PostHogconversionFunnelcounts events across all users for the period, not per-user funnel steps — a user who viewed a detail page 10 times counts as 10; for true per-user funnel analysis, use PostHog's built-in funnel charts