Fix: PostHog Not Working — Events Not Tracking, Feature Flags Stale, or Session Replay Blank
Part of: JavaScript & TypeScript Errors
Quick Answer
How to fix PostHog analytics issues — JavaScript and Next.js setup, event capture, feature flags, A/B testing, session replay, user identification, and server-side tracking.
The Problem
posthog.capture() is called but events don’t appear in the dashboard:
posthog.capture('button_clicked', { button: 'signup' });
// PostHog dashboard shows zero eventsOr feature flags always return the default value:
const isEnabled = posthog.isFeatureEnabled('new-dashboard');
// Always false — even though the flag is enabled in PostHogOr session replay shows a blank recording:
Session replays list is empty even after multiple page visitsWhy This Happens
PostHog is a product analytics platform that captures events, evaluates feature flags, and records session replays. Each of those features has its own initialization path inside the posthog-js client and its own failure mode. The most common root cause is also the simplest: posthog.capture() was called before posthog.init() finished, so the event was queued against an uninitialized client and silently dropped. In Next.js App Router this happens constantly because initialization has to live in a client component, but capture() calls often live in libraries or contexts that may run before the provider mounts.
The second root cause is the asynchronous nature of feature flags. When the client initializes, it fires a /decide request to PostHog to fetch the user’s flag set. Until that request resolves, isFeatureEnabled() returns undefined. undefined is falsy in JavaScript, so code like if (posthog.isFeatureEnabled('new-dashboard')) consistently shows the old experience on first render — even when the flag is enabled. The useFeatureFlagEnabled hook from posthog-js/react handles the loading state correctly; the raw API does not. For server-rendered pages you have a different problem: the server has no client state, so you must use posthog-node and the bootstrap option to inject flag values into the initial render.
The third major cause is invisibility from outside forces. Ad blockers — uBlock Origin, Brave’s shields, Safari’s tracker prevention, corporate firewalls — block requests to *.posthog.com by default. In some industries the blocked rate exceeds 50% of traffic. Events, identification, flags, and replay all silently fail. The standard fix is a reverse proxy on your own domain so traffic looks like a first-party call to /ingest/*. Session replay also requires explicit opt-in: session_recording must be enabled in the init config, the PostHog project setting must allow recording, and your CSP must not block the recorder bundle.
- Init must complete before captures — race conditions silently drop events.
- Flags load asynchronously —
undefinedon first read is the default. - Ad blockers block PostHog — without a reverse proxy, a meaningful fraction of users are invisible.
- Session replay is opt-in — at both the SDK config and the project settings level.
In Production: Incident Lens
When PostHog breaks in production, the blast radius is your product metrics blind spot. Unlike a crash, this is a silent failure: dashboards still load, charts still render, and graphs still update — they’re just systematically wrong. Decisions get made on incomplete data: an A/B test concludes “no significant lift” because half the variant exposures never made it to PostHog, a funnel analysis shows a fake drop-off because a key step’s event stopped firing after a deploy, a feature flag rollout appears safe because the new code path is silently throwing on getFeatureFlag. None of these incidents page anyone — they corrupt decisions for days or weeks.
How it surfaces: a PM notices that daily event volume on a specific event dropped 40% on Tuesday. The on-call engineer checks the deploy log — yes, there was a release at 10:14 Tuesday. They look at the diff: a refactor renamed the event from signup_started to signup_initiated on the client without a corresponding rename on the dashboard. Or, more insidiously, a third-party script was added that loaded before PostHog initialized, and now half the page-loads race against init. The data is gone — you cannot retroactively capture events that never made it to the server.
Monitoring signal: maintain a small “canary event” — a synthetic event fired by a healthcheck endpoint every minute from production. Alert when the per-minute count of that event drops below the expected baseline (PostHog’s API supports counting queries). For more granular detection, set up a daily anomaly alert on critical event counts: a deviation of more than 25% from the trailing 7-day average is worth investigating. Also track the posthog.com request error rate from your reverse-proxy logs; a spike there tells you ingestion is throttled or down.
Recovery sequence: when an event regression is detected, revert the offending commit immediately — silent ingestion failures cannot be backfilled. While you investigate, switch the dashboards that consumed the renamed event to point at the new name (or vice versa). For ad-blocker-related drops, the recovery is the reverse proxy: set up the rewrite once and the issue mostly goes away. The postmortem preventive is a contract test in CI that asserts the event names and properties your dashboards rely on still fire — a Playwright run against the staging site that performs the critical user journey and asserts the expected PostHog events appear within 30 seconds.
Fix 1: Next.js App Router Setup
npm install posthog-js posthog-node// lib/posthog.ts — client-side instance
import posthog from 'posthog-js';
export function initPostHog() {
if (typeof window === 'undefined') return;
if (posthog.__loaded) return; // Already initialized
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://us.i.posthog.com',
person_profiles: 'identified_only',
// Session replay
session_recording: {
maskAllInputs: true, // Mask input values
maskTextSelector: '.sensitive',
},
// Feature flags
bootstrap: {
// Pre-load flags from server (optional — avoids flash)
},
// Autocapture
autocapture: true,
capture_pageview: false, // We'll handle this manually for SPA
capture_pageleave: true,
// Reduce noise
persistence: 'localStorage+cookie',
loaded: (posthog) => {
if (process.env.NODE_ENV === 'development') {
posthog.debug();
}
},
});
}
export { posthog };// components/PostHogProvider.tsx
'use client';
import { useEffect } from 'react';
import { usePathname, useSearchParams } from 'next/navigation';
import { initPostHog, posthog } from '@/lib/posthog';
import { PostHogProvider as PHProvider } from 'posthog-js/react';
export function PostHogProvider({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
initPostHog();
}, []);
// Track page views on route change
useEffect(() => {
if (pathname && posthog) {
let url = window.origin + pathname;
if (searchParams.toString()) {
url += '?' + searchParams.toString();
}
posthog.capture('$pageview', { $current_url: url });
}
}, [pathname, searchParams]);
return <PHProvider client={posthog}>{children}</PHProvider>;
}// app/layout.tsx
import { PostHogProvider } from '@/components/PostHogProvider';
import { Suspense } from 'react';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<Suspense fallback={null}>
<PostHogProvider>{children}</PostHogProvider>
</Suspense>
</body>
</html>
);
}Fix 2: Event Tracking
'use client';
import { usePostHog } from 'posthog-js/react';
function SignupButton() {
const posthog = usePostHog();
function handleSignup() {
// Track custom event with properties
posthog.capture('signup_started', {
method: 'email',
referrer: document.referrer,
page: window.location.pathname,
});
}
return <button onClick={handleSignup}>Sign Up</button>;
}
// Identify users after login
function useIdentifyUser(user: { id: string; email: string; name: string; plan: string }) {
const posthog = usePostHog();
useEffect(() => {
if (user) {
posthog.identify(user.id, {
email: user.email,
name: user.name,
plan: user.plan,
});
}
}, [user, posthog]);
}
// Reset on logout
function LogoutButton() {
const posthog = usePostHog();
function handleLogout() {
posthog.reset(); // Clears user identity and starts new session
// ... actual logout logic
}
return <button onClick={handleLogout}>Logout</button>;
}
// Group analytics (for B2B — associate user with organization)
posthog.group('company', 'company-123', {
name: 'Acme Corp',
plan: 'enterprise',
employeeCount: 50,
});Fix 3: Feature Flags
'use client';
import { useFeatureFlagEnabled, useFeatureFlagPayload, usePostHog } from 'posthog-js/react';
// Boolean flag
function Dashboard() {
const isNewDashboard = useFeatureFlagEnabled('new-dashboard');
if (isNewDashboard === undefined) {
return <div>Loading...</div>; // Flags still loading
}
return isNewDashboard ? <NewDashboard /> : <OldDashboard />;
}
// Flag with payload (JSON configuration)
function PricingPage() {
const pricingConfig = useFeatureFlagPayload('pricing-experiment');
if (!pricingConfig) return <DefaultPricing />;
return (
<div>
<h1>{pricingConfig.headline}</h1>
<p>{pricingConfig.description}</p>
<span>${pricingConfig.price}/mo</span>
</div>
);
}
// Multivariate flag
function HeroSection() {
const posthog = usePostHog();
const variant = posthog.getFeatureFlag('hero-experiment');
// variant: 'control' | 'variant-a' | 'variant-b'
switch (variant) {
case 'variant-a':
return <HeroA />;
case 'variant-b':
return <HeroB />;
default:
return <HeroDefault />;
}
}
// Server-side feature flags
// app/page.tsx
import { PostHog } from 'posthog-node';
const posthogServer = new PostHog(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
});
export default async function Page() {
const flags = await posthogServer.getAllFlags('user-123');
const showBanner = flags['announcement-banner'];
return (
<div>
{showBanner && <AnnouncementBanner />}
</div>
);
}Fix 4: Reverse Proxy (Bypass Ad Blockers)
// next.config.mjs — proxy PostHog requests through your domain
const nextConfig = {
async rewrites() {
return [
{
source: '/ingest/static/:path*',
destination: 'https://us-assets.i.posthog.com/static/:path*',
},
{
source: '/ingest/:path*',
destination: 'https://us.i.posthog.com/:path*',
},
{
source: '/ingest/decide',
destination: 'https://us.i.posthog.com/decide',
},
];
},
};
export default nextConfig;// Update PostHog config to use the proxy
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
api_host: '/ingest', // Use your own domain as proxy
ui_host: 'https://us.posthog.com', // For toolbar
});Fix 5: Server-Side Tracking
// lib/posthog-server.ts
import { PostHog } from 'posthog-node';
const posthog = new PostHog(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
host: process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://us.i.posthog.com',
});
export { posthog as posthogServer };
// Track server-side events
// app/api/checkout/route.ts
import { posthogServer } from '@/lib/posthog-server';
export async function POST(req: Request) {
const { userId, plan } = await req.json();
posthogServer.capture({
distinctId: userId,
event: 'subscription_created',
properties: {
plan,
amount: plan === 'pro' ? 29 : 99,
source: 'api',
},
});
// Flush before serverless function ends
await posthogServer.shutdown();
return Response.json({ success: true });
}Fix 6: A/B Testing with Experiments
'use client';
import { usePostHog } from 'posthog-js/react';
import { useEffect } from 'react';
function CheckoutPage() {
const posthog = usePostHog();
const variant = posthog.getFeatureFlag('checkout-experiment');
// Track experiment exposure
useEffect(() => {
if (variant) {
posthog.capture('$feature_flag_called', {
$feature_flag: 'checkout-experiment',
$feature_flag_response: variant,
});
}
}, [variant, posthog]);
// Track conversion
function handlePurchase() {
posthog.capture('purchase_completed', {
amount: 49.99,
variant,
});
}
if (variant === 'one-step') {
return <OneStepCheckout onPurchase={handlePurchase} />;
}
return <MultiStepCheckout onPurchase={handlePurchase} />;
}Still Not Working?
Events don’t appear in the dashboard — check the browser Network tab for requests to posthog.com or your proxy. If requests are blocked (status 0 or missing), an ad blocker is active. Set up the reverse proxy. Also verify the API key matches your project — keys are project-specific.
Feature flags always return undefined — flags load asynchronously after posthog.init(). Use useFeatureFlagEnabled() which handles the loading state, or use posthog.onFeatureFlags(() => { ... }) to wait for flags. For server-rendered pages, use posthog-node to evaluate flags on the server.
Session replay is empty — verify session_recording is not disabled in the init config. Also check that the PostHog project has session recording enabled (Project Settings → Session Recording). Some content security policies block the recording script.
Identified user events appear as anonymous — posthog.identify() must be called before capture events. If you capture events before identifying, they’re attributed to an anonymous user. After identification, PostHog merges the anonymous and identified profiles, but this can take a few minutes to reflect in the dashboard.
Serverless function never reports server-side events — posthog-node batches events and flushes asynchronously. In a serverless environment the function exits before the flush completes, so events vanish. Always await posthog.shutdown() (or await posthog.flush()) before returning the response. On Vercel Edge Runtime, use waitUntil() to keep the runtime alive until the flush completes.
Page-view counts are double or missing in App Router — capture_pageview: true (default) combined with manual posthog.capture('$pageview') on every route change doubles the count. Pick one. The standard pattern in Next.js App Router is capture_pageview: false plus a useEffect watching usePathname() that fires the pageview manually — anything else races with App Router’s soft navigation.
Replay shows masked content where it shouldn’t — maskAllInputs: true masks every input by default. For non-sensitive fields, add data-attr="ph-no-mask" to the element or set a custom maskInputFn. Conversely, if PII is leaking into replay, audit your selectors — maskTextSelector only catches elements matching the selector, not their descendants.
For related analytics and monitoring issues, see Fix: Sentry Not Working, Fix: OpenTelemetry Not Working, Fix: Sentry Source Maps Not Working, and Fix: Next.js API Route Not Working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Deno PermissionDenied — Missing --allow-read, --allow-net, and Other Flags
How to fix Deno PermissionDenied (NotCapable in Deno 2) errors — the right permission flags, path-scoped permissions, deno.json permission sets, and the Deno.permissions API.
Fix: Fastify Not Working — 404, Plugin Encapsulation, and Schema Validation Errors
How to fix Fastify issues — route 404 from plugin encapsulation, reply already sent, FST_ERR_VALIDATION, request.body undefined, @fastify/cors, hooks not running, and TypeScript type inference.
Fix: Vinxi Not Working — Dev Server Not Starting, Routes Not Matching, or Build Failing
How to fix Vinxi server framework issues — app configuration, routers, server functions, middleware, static assets, and deployment to different platforms.
Fix: Clack Not Working — Prompts Not Displaying, Spinners Stuck, or Cancel Not Handled
How to fix @clack/prompts issues — interactive CLI prompts, spinners, multi-select, confirm dialogs, grouped tasks, cancellation handling, and building CLI tools with beautiful output.