Twenty One Media
webJune 21, 2026

PostHog Does Two Jobs In Our API Routes

Every API route on twenty1-media.com that handles a form submission ends with two PostHog calls. One fires on success, one fires on failure. Same tool, two jobs.

// On fatal failure (user email couldn't send):
getPostHogClient()?.captureException(userResult.error, distinctId, {
  route: "/api/audit-request",
});
return NextResponse.json({ error: "Failed to send confirmation" }, { status: 502 });

// On success:
getPostHogClient()?.capture({
  distinctId,
  event: "audit_request_processed",
  properties: { lead_magnet: "ai-audit-v1" },
});
return NextResponse.json({ success: true });

The capture call gives us funnel data. The captureException call gives us error monitoring. Both live in PostHog. We don't run a separate exception tracker.

Why One Tool

PostHog's Node SDK has had captureException for a while, and for our volume it's enough. We're not running hundreds of routes or complex infrastructure. We have six form routes, a clear list of failure modes, and an already-configured PostHog project. Adding Sentry (or equivalent) would mean another service, another set of API keys, and another dashboard to check. The marginal benefit doesn't justify that at our scale.

The honest tradeoff: PostHog's exception UI is simpler than Sentry's. You get the error message and the properties you attach. You don't get full stack traces or fine-grained alert rules. For catching "the user email route broke," that's enough.

The Optional Chain Does Real Work

getPostHogClient() returns null when NEXT_PUBLIC_POSTHOG_KEY isn't set. That's intentional:

export function getPostHogClient(): PostHog | null {
  const key = process.env.NEXT_PUBLIC_POSTHOG_KEY;
  if (!key) return null;
  // ...
}

The ?. on every call means no-op when the client is null. Local dev runs without PostHog configured. No error thrown. No conditional block needed in the route. Production gets full instrumentation. CI gets silence. You add the env var to turn it on in an environment; you don't touch the route code.

This matters because every route is instrumented from day one. There's no "we'll add monitoring later" gap. The pattern is already in place, and enabling it is a config change, not a code change.

Only Fatal Paths Call captureException

The routes have three categories of failures: the user email (fatal), the notification email (non-fatal), and the Airtable write (non-fatal). captureException only fires for the fatal one.

Non-fatal failures log to console and let the request succeed. If the Airtable write fails, we miss a lead record. If the notify email fails, we miss an internal ping. Bad, but not the user's problem. Those are our problems. Logging them to console is enough.

If we called captureException on non-fatal paths, every Airtable hiccup would show up in the exception tracker. We'd start treating it like noise. The rule is: if it gets a 502 response, it gets a captureException. If the user doesn't see an error, neither does the tracker.

The result is that the PostHog exception view stays meaningful. Every exception that appears there represents a form submission that failed from the user's perspective.

The distinctId Fallback

Each route extracts an identity for the PostHog events:

const distinctId =
  request.headers.get("x-posthog-distinct-id") ?? crypto.randomUUID();

The intent is for client forms to pass the PostHog session ID in that header, linking server events to the person's browser session. In practice, the current client-side forms don't send that header yet. So each request gets a random UUID. The events still land in PostHog and still count, but they're not linked to a specific person's session timeline.

That's a gap worth closing. When an exception fires on a random UUID, you can't look at that person's session history to understand what led to the failure. The infrastructure for linking them is already in place on the server side. The missing piece is a line in each form's fetch call:

headers: {
  "Content-Type": "application/json",
  "x-posthog-distinct-id": posthog.get_distinct_id(),
}

Once that's wired in, exceptions become person-level events, not anonymous signals.