DEV Community

Diven Rastdus
Diven Rastdus

Posted on • Originally published at astraedus.dev

"PostHog + Next.js 16 App Router: the Suspense gotcha that silenced my analytics for 6 days"

I shipped a no-op stub of PostHogProvider.tsx on April 20. I told myself I would come back to it that afternoon. Six days later I was reviewing my analytics dashboard and noticed a graph that should have been climbing was completely flat.

Every posthog.capture() in my Next.js 16 App Router app had been firing into a black hole. Including the one event I actually cared about: the waitlist signup.

This is the post-mortem. Three gotchas, real code, and how I verified the fix in a way that did not depend on trusting the PostHog dashboard.


How I broke it

The original component looked roughly like this:

"use client";

import posthog from "posthog-js";

export default function PostHogProvider({ children }: { children: React.ReactNode }) {
  if (typeof window !== "undefined") {
    posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
      api_host: "https://us.i.posthog.com",
    });
  }
  return <>{children}</>;
}
Enter fullscreen mode Exit fullscreen mode

Looks fine, builds fine, ships fine. But on App Router with Turbopack, posthog.init was getting called on every render and I was getting a console warning about a hydration mismatch that I had filed under "react thing, deal with later."

So I did the lazy thing. I replaced the whole file with this:

"use client";
export default function PostHogProvider({ children }: { children: React.ReactNode }) {
  return <>{children}</>;
}
Enter fullscreen mode Exit fullscreen mode

The intent was "I will come back tomorrow." The reality was six days of zero analytics.


Why I did not notice

I had posthog.capture("waitlist_signup") baked into a form handler. Forms were getting submitted. PostHog's dashboard showed nothing.

For six days I assumed nobody had signed up. The form was working. The capture was a no-op.

Lesson zero, before any of the technical ones: silence is not the same as zero. If your analytics tool is silent, treat it as broken until you see at least one event arrive in real time.


Gotcha 1: posthog-js/react ships in the same package, on a subpath

The official adapter for plugging posthog-js into React's context tree is at posthog-js/react. There is no separate posthog-react package on npm anymore (there used to be). The import looks like this:

import posthog from "posthog-js";
import { PostHogProvider as PHProvider } from "posthog-js/react";
Enter fullscreen mode Exit fullscreen mode

If you copy a snippet from a 2024 blog post that says npm install posthog-react, you get a "module not found" error and waste twenty minutes wondering why a one-million-download library is broken. It is not broken. The README is right. Older blog posts are wrong.


Gotcha 2: useSearchParams in App Router needs Suspense

I wanted manual pageview tracking, not the default capture_pageview: true, because I want to attribute ?utm_source params and route-level differences explicitly. So I wrote a <PageviewTracker /> client component that calls usePathname() and useSearchParams() and fires posthog.capture("$pageview", { $current_url: ... }).

Build broke immediately:

Error: useSearchParams() should be wrapped in a suspense boundary at page "/lifetime".
Read more: https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout
Enter fullscreen mode Exit fullscreen mode

This is a Next.js 13+ App Router rule. Any client component that reads useSearchParams() causes the entire route to bail out of static rendering unless that component sits inside a Suspense boundary. The fix is one line:

return (
  <PHProvider client={posthog}>
    <Suspense fallback={null}>
      <PageviewTracker />
    </Suspense>
    {children}
  </PHProvider>
);
Enter fullscreen mode Exit fullscreen mode

Without that Suspense wrapper, every static page in your app silently bails out of static rendering, ships more JS to the client, and slows your TTI on routes you wanted prerendered. The PostHog README does mention this. I had skimmed past it.


Gotcha 3: posthog.__loaded is the truth, not React state

For posthog.capture calls to actually fire, the SDK has to be initialized. In a SPA-style component you might write useEffect(() => posthog.init(...), []) and assume "it ran." But there is an edge case. React 18 StrictMode in dev double-invokes effects. If your init is not idempotent, the second call throws.

The posthog-js author thought of this. There is a __loaded flag on the global posthog object that flips to true exactly once after a successful init. The pattern I landed on:

"use client";

import { Suspense, useEffect } from "react";
import { usePathname, useSearchParams } from "next/navigation";
import posthog from "posthog-js";
import { PostHogProvider as PHProvider } from "posthog-js/react";

export default function PostHogProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  useEffect(() => {
    const key = process.env.NEXT_PUBLIC_POSTHOG_KEY;
    if (!key) return;
    if (typeof window === "undefined") return;
    if (posthog.__loaded) return;

    posthog.init(key, {
      api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST || "https://us.i.posthog.com",
      capture_pageview: false,
      capture_pageleave: true,
      persistence: "localStorage+cookie",
    });
  }, []);

  return (
    <PHProvider client={posthog}>
      <Suspense fallback={null}>
        <PageviewTracker />
      </Suspense>
      {children}
    </PHProvider>
  );
}

function PageviewTracker() {
  const pathname = usePathname();
  const searchParams = useSearchParams();

  useEffect(() => {
    if (!pathname) return;
    if (typeof window === "undefined") return;
    if (!posthog.__loaded) return;

    const qs = searchParams?.toString();
    const url = qs ? `${pathname}?${qs}` : pathname;
    posthog.capture("$pageview", { $current_url: url });
  }, [pathname, searchParams]);

  return null;
}
Enter fullscreen mode Exit fullscreen mode

Two __loaded checks, on opposite sides of the race. Init guards against StrictMode double-mount. Capture guards against firing before init completed. Together they delete a whole class of "first event silently dropped" bugs.


How I verified, without trusting the dashboard

Once the code was right and deploying, the question was: is it actually firing in production?

I distrust dashboards for first verification. They lag. They aggregate. They have their own client-side bugs. The cleanest signal is the network tab.

I opened the deployed page in Chrome, opened DevTools, filtered Network by posthog.com, hit refresh, and watched for two requests:

  1. POST https://us.i.posthog.com/decide/ to load feature flags. Status 200.
  2. POST https://us.i.posthog.com/e/ with a payload containing "event": "$pageview" and my project token. Status 200.

If both fire and both return 200, the SDK is healthy. The dashboard catching up is a separate problem and not my problem.

I also wired in a temporary debug button:

"use client";
import posthog from "posthog-js";

export function DebugFire() {
  return (
    <button onClick={() => posthog.capture("debug_fire", { ts: Date.now() })}>
      fire
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Click it, watch the network tab. If e/ returns 200, the pipeline is wired. Removed before the real ship.

For one extra layer I added a static-page mount tracker as a separate component:

"use client";
import { useEffect } from "react";
import posthog from "posthog-js";

export default function TrackPageView({ name }: { name: string }) {
  useEffect(() => {
    if (!posthog.__loaded) return;
    posthog.capture(name);
  }, [name]);
  return null;
}
Enter fullscreen mode Exit fullscreen mode

Then dropped <TrackPageView name="ltd_page_viewed" /> into /lifetime, <TrackPageView name="refund_page_viewed" /> into /refund, and so on. Clean, named events for routes I want to slice in PostHog. Costs nothing.


What I would do differently

Three habits I am absorbing:

  1. Never replace a broken integration with a no-op stub and a TODO. If it is broken, leave the broken code, file an issue, ship the issue ID in a comment. Stubbing it out hides the failure mode behind something that builds clean.
  2. Add a "did one new event land in PostHog within sixty seconds?" check to the deploy checklist for any route I touch. Not "did it build clean," not "does the page render," but did real telemetry land. Takes ninety seconds.
  3. Trust the network tab over the dashboard for first verification. Dashboards are downstream consumers. The network tab is the source of truth.

The fix shipped. Every event since ($pageview, ltd_page_viewed, ltd_notify_clicked, ltd_demo_clicked, refund_page_viewed, privacy_page_viewed) is landing. Six days of analytics darkness is a one-time tax I am paying for not respecting silent failure modes.


I build small, fast AI products as a solo dev. This was instrumentation for arc-landing-pi.vercel.app, the waitlist for Arc Mirror, a longitudinal journaling AI I am shipping a lifetime deal on next month. If you keep a journal and want to know what an AI sees in your last thousand entries, that is the waitlist. The rest of what I do lives at astraedus.dev.

Top comments (0)