dration and silently drops pageview events. The solution is a client component wrapped in a Suspense boundary.
// src/providers/analytics-provider.tsx
'use client';
import { PostHogProvider as PHProvider } from 'posthog-js/react';
import posthog from 'posthog-js';
import { useEffect } from 'react';
interface AnalyticsProviderProps {
children: React.ReactNode;
}
export function AnalyticsProvider({ children }: AnalyticsProviderProps) {
useEffect(() => {
const apiKey = process.env.NEXT_PUBLIC_POSTHOG_KEY;
if (!apiKey) return;
posthog.init(apiKey, {
api_host: 'https://us.i.posthog.com',
capture_pageview: true,
capture_pageleave: true,
persistence: 'localStorage',
});
}, []);
return <PHProvider client={posthog}>{children}</PHProvider>;
}
Architecture Rationale:
useEffect ensures initialization occurs only after client hydration.
persistence: 'localStorage' maintains session continuity across page navigations without relying on cookies, which improves compliance posture.
- The provider is exported as a client component, isolating SDK logic from server-rendered routes.
2. Mount & Interaction Tracking
Tracking page loads and button clicks requires a consistent interface. Rather than scattering posthog.capture calls throughout components, I recommend a typed hook that abstracts the SDK dependency and enforces property standardization.
// src/hooks/use-conversion-tracker.ts
import { usePostHog } from 'posthog-js/react';
import { useCallback } from 'react';
type ConversionEvent =
| 'pricing_page_loaded'
| 'demo_requested'
| 'checkout_initiated'
| 'purchase_completed';
interface EventProperties {
price?: number;
currency?: string;
plan_type?: string;
referrer?: string;
}
export function useConversionTracker() {
const client = usePostHog();
const track = useCallback(
(eventName: ConversionEvent, properties?: EventProperties) => {
if (!client) return;
client.capture(eventName, {
...properties,
timestamp: new Date().toISOString(),
environment: process.env.NODE_ENV,
});
},
[client]
);
return { track };
}
Usage in a Pricing Component:
'use client';
import { useConversionTracker } from '@/hooks/use-conversion-tracker';
export function PricingCard() {
const { track } = useConversionTracker();
const handleCheckout = () => {
track('checkout_initiated', {
price: 59,
currency: 'USD',
plan_type: 'lifetime',
});
// Redirect to Stripe or open modal
};
return (
<button onClick={handleCheckout}>
Get Lifetime Access
</button>
);
}
Why this approach:
- Centralizing event names in a union type prevents typos that break funnel steps.
- Attaching
timestamp and environment at the hook level ensures consistent metadata across all events.
- PostHog automatically deduplicates rapid-fire calls, making this safe for React StrictMode double-renders.
Knowing whether users actually see your pricing section requires DOM observation. IntersectionObserver is the performant standard for this. Wrapping it in a reusable component eliminates boilerplate and guarantees cleanup.
// src/components/viewport-tracker.tsx
'use client';
import { useConversionTracker } from '@/hooks/use-conversion-tracker';
import { useEffect, useRef } from 'react';
interface ViewportTrackerProps {
eventName: 'pricing_section_viewed' | 'faq_section_viewed';
threshold?: number;
children: React.ReactNode;
}
export function ViewportTracker({
eventName,
threshold = 0.5,
children
}: ViewportTrackerProps) {
const { track } = useConversionTracker();
const containerRef = useRef<HTMLDivElement>(null);
const hasFired = useRef(false);
useEffect(() => {
const element = containerRef.current;
if (!element) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && !hasFired.current) {
hasFired.current = true;
track(eventName);
}
},
{ threshold }
);
observer.observe(element);
return () => observer.disconnect();
}, [eventName, track, threshold]);
return <div ref={containerRef}>{children}</div>;
}
Implementation Note:
- The
hasFired ref ensures the event triggers exactly once per mount, preventing scroll-jitter duplication.
threshold: 0.5 means the event fires when 50% of the element enters the viewport. Adjust based on your layout density.
- The cleanup function
observer.disconnect() prevents memory leaks during route transitions.
4. Funnel Assembly in PostHog
Once events flow into the platform, navigate to Funnels > New Funnel. Add steps in strict chronological order:
pricing_page_loaded
pricing_section_viewed
checkout_initiated
purchase_completed
PostHog calculates conversion rates between each step. A sharp drop between step 2 and 3 indicates pricing friction. A drop between 3 and 4 points to checkout flow or payment gateway issues. The platform automatically segments by device, referrer, and custom properties, enabling rapid hypothesis testing.
Pitfall Guide
1. Omitting the Suspense Boundary
Explanation: Wrapping the PostHog provider directly in the root layout without Suspense forces server-side evaluation of a client-only SDK. This blocks hydration and causes pageview events to vanish silently.
Fix: Always wrap client-side analytics providers in <Suspense fallback={null}> in app/layout.tsx.
2. Event Name Inconsistency
Explanation: Typographical variations like checkout_started vs checkout_initiated split funnel data across two separate steps, artificially deflating conversion rates.
Fix: Centralize event names in a TypeScript union type or constants file. Enforce usage through linting or code review.
3. Missing Event Properties
Explanation: Firing events without contextual properties (price, plan, referrer) limits post-hoc analysis. You cannot segment by pricing tier or marketing channel later without redeploying code.
Fix: Attach relevant properties at capture time. PostHog properties are free and schema-flexible; over-annotate rather than under-annotate.
4. IntersectionObserver Memory Leaks
Explanation: Failing to call observer.disconnect() in the cleanup function leaves dangling references, especially problematic in Single Page Applications with frequent route changes.
Fix: Always return a cleanup function in useEffect that calls disconnect() and nullifies the ref.
5. Over-Tracking Micro-Interactions
Explanation: Tracking every hover, scroll pixel, or input keystroke creates noise that obscures meaningful conversion signals. It also increases payload size and client CPU usage.
Fix: Limit funnel events to 5-7 critical transitions. Use session recordings or heatmaps for granular UX analysis instead of flooding the event pipeline.
6. Funnel Step Misalignment
Explanation: PostHog funnels require strict sequential ordering. If you add checkout_initiated before pricing_section_viewed, the platform calculates conversion incorrectly, showing artificial drop-offs.
Fix: Map your funnel steps on paper first. Ensure the UI step order matches the logical user journey. Reorder steps in the PostHog UI if needed.
7. Ignoring StrictMode Double-Renders
Explanation: React StrictMode mounts components twice in development. While PostHog deduplicates events server-side, custom tracking logic without guards can fire twice, skewing local debugging.
Fix: Rely on PostHog's built-in deduplication for production. For local testing, use a useRef flag or disable StrictMode temporarily in next.config.js.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Early-stage SaaS (<10k MAU) | Inline posthog.capture with typed hook | Minimal abstraction overhead, fast iteration | Free tier sufficient |
| High-traffic e-commerce | Wrapper components + event batching | Reduces network calls, improves CLS metrics | May require paid tier for >1M events |
| Multi-step checkout flow | Sequential funnel with property segmentation | Isolates friction per step, enables A/B testing | No additional cost |
| Marketing-heavy landing page | Viewport tracking + scroll depth events | Measures content engagement before CTA exposure | Free tier sufficient |
Configuration Template
// app/layout.tsx
import { Suspense } from 'react';
import { AnalyticsProvider } from '@/providers/analytics-provider';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<Suspense fallback={null}>
<AnalyticsProvider>{children}</AnalyticsProvider>
</Suspense>
</body>
</html>
);
}
// .env.local
NEXT_PUBLIC_POSTHOG_KEY=phc_your_project_key_here
Quick Start Guide
- Install SDK: Run
npm install posthog-js in your Next.js project root.
- Create Provider: Add the
AnalyticsProvider component with useEffect initialization and export it.
- Wrap Layout: Import the provider into
app/layout.tsx and wrap {children} inside <Suspense>.
- Instrument Events: Import
useConversionTracker into pricing/checkout components and call track() on mounts and clicks.
- Build Funnel: Navigate to PostHog Funnels, add your events in order, and verify conversion rates after 24 hours of traffic.
Implementing event-driven attribution takes roughly 45 minutes of focused development. The return is immediate visibility into conversion leaks, eliminating weeks of infrastructure guesswork and replacing it with actionable, step-by-step optimization data.