← All posts
·17 min read

Claude Code with PostHog: Analytics, Feature Flags, A/B Tests

Claude CodePostHogAnalyticsFeature Flags
Claude Code with PostHog: Analytics, Feature Flags, A/B Tests

Why PostHog without CLAUDE.md double-tracks events and bloats your bill

PostHog is powerful precisely because it captures so much automatically. Autocapture records every click, form submit, and page view the moment the SDK loads. The problem is that Claude Code does not know where your instrumentation boundaries are. Without explicit rules, it generates posthog.capture('button clicked') calls on the same buttons PostHog's autocapture already recorded. Every user interaction appears twice in your event stream, your funnel numbers are inflated by 100%, and your monthly event count doubles against your PostHog billing tier.

The second category of silent mistake is the reverse proxy. PostHog's JavaScript SDK sends events to eu.posthog.com or us.posthog.com by default. Ad blockers treat those domains as tracking endpoints and block the requests. In a developer or technical audience, ad blocker penetration can reach 40 to 60%. If Claude Code generates a PostHog init without a reverse proxy rewrite, you are flying blind on a significant share of your traffic from day one.

The third category is the identity model. Claude will call posthog.identify(user.email) because email is the most obvious unique identifier available after login. PostHog's distinct ID is designed to be an internal ID, not PII. Using an email address as the distinct ID means that ID appears in PostHog's database unencrypted, it is included in data exports, and it complicates GDPR deletion requests because you cannot pseudonymise an ID that is already an email address.

This guide covers the CLAUDE.md configuration that prevents all three categories of mistake. If you are setting up Claude Code for the first time, the Claude Code with Next.js guide covers the broader project configuration. For the payment instrumentation that commonly pairs with PostHog funnels, Claude Code with Stripe applies the same CLAUDE.md approach to Stripe webhooks and conversion events.

The PostHog CLAUDE.md template

The CLAUDE.md at your project root is read at the start of every Claude Code session. For a PostHog integration it needs to declare: the SDK version and init configuration including reverse proxy settings, the autocapture policy, event naming conventions, the identify and reset lifecycle, feature flag access patterns, experiment variant routing, and the hard rules that block the patterns Claude generates most often without guidance.

# PostHog instrumentation rules

## Stack
- posthog-js 1.x (browser), posthog-node 4.x (server-side events)
- Next.js 14+ App Router
- TypeScript strict mode

## Installation
npm install posthog-js
npm install posthog-node  # server-side only

## PostHog init (app/providers.tsx or lib/posthog.ts)
import posthog from 'posthog-js';

posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
  api_host: '/ingest',          // reverse proxy. NEVER eu.posthog.com directly
  ui_host: 'https://eu.posthog.com',
  autocapture: false,           // manual capture only. see policy below
  capture_pageview: false,      // manual page views via usePathname effect
  capture_pageleave: true,
  session_recording: {
    maskAllInputs: true,        // mask by default, unmask selectively
    maskTextSelector: '.ph-mask',
  },
  loaded: (ph) => {
    if (process.env.NODE_ENV === 'development') ph.debug();
  },
});

## Reverse proxy (next.config.ts): REQUIRED, no exceptions
/** @type {import('next').NextConfig} */
const nextConfig = {
  async rewrites() {
    return [
      {
        source: '/ingest/:path*',
        destination: 'https://eu.posthog.com/:path*',
      },
    ];
  },
  skipTrailingSlashRedirect: true,
};

export default nextConfig;

## Autocapture policy
- autocapture is DISABLED globally (set in init above)
- NEVER re-enable autocapture on individual elements
- ALL events must be captured manually via posthog.capture()
- Reasoning: autocapture + manual capture = double-counted events in funnels

## Event naming conventions
- Format: snake_case only. NEVER spaces, camelCase, or kebab-case
- Pattern: {noun}_{past_tense_verb}, e.g. checkout_completed, plan_upgraded
- Prefix by product area: auth_*, checkout_*, dashboard_*, onboarding_*
- NEVER use generic names: button_clicked, link_clicked, form_submitted
- ALWAYS use specific names: signup_cta_clicked, pricing_page_viewed, trial_started

## Approved event list (add here, never invent new names mid-task)
- page_viewed          { path: string, referrer: string }
- signup_started       { source: string }
- signup_completed     { plan: string, source: string }
- login_completed      { method: 'email' | 'google' | 'github' }
- checkout_started     { plan: string, price_usd: number }
- checkout_completed   { plan: string, price_usd: number, payment_method: string }
- feature_flag_viewed  { flag: string, variant: string }
- onboarding_step_completed { step: number, step_name: string }

## Identity rules
- NEVER use email as distinct_id
- NEVER use PII (name, phone, IP) as distinct_id
- Use internal user ID from your database: posthog.identify(user.id, { email: user.email, plan: user.plan })
- Call identify() ONCE after confirmed login, not on every render
- Call posthog.reset() on logout. This creates a new anonymous distinct_id for the next session
- Anonymous-to-identified transition is automatic; do not call alias() unless merging two known IDs

## Feature flags
- Boolean flags: posthog.isFeatureEnabled('flag-name') returns boolean | undefined
- String variant flags: posthog.getFeatureFlag('flag-name') returns string | undefined
- ALWAYS handle the undefined case. Flags return undefined before PostHog loads
- NEVER use feature flags to gate security-critical paths (server-side check is authoritative)
- For SSR: pass bootstrap object on init to prevent UI flash (see bootstrap section)

## A/B experiments
- Experiments use the same API as feature flags
- posthog.getFeatureFlag('experiment-name') returns the variant string ('control' | 'treatment' | undefined)
- ALWAYS log an exposure event: posthog.capture('experiment_viewed', { experiment: 'name', variant })
- Variant routing belongs in a single function, not scattered across components

## Session replay privacy
- Inputs are masked globally via maskAllInputs: true in init
- To unmask a non-sensitive input: add data-attr="ph-no-capture" is WRONG; use class="ph-no-capture" on the element to UNMASK it. Clarification:
  - To BLOCK an element from replay: add class="ph-mask" or data-attr="ph-no-capture"
  - To unmask a specific input when maskAllInputs is true: set maskAllInputs: false and manually block sensitive fields
- NEVER capture password, card number, CVV, SSN, or DOB fields in session replay
- Add class="ph-mask" to any element containing PII that is not an input

## Hard rules
- NEVER call posthog.capture() on a button that autocapture would have caught (moot with autocapture:false, but still: no autocapture re-enablement)
- NEVER use spaces in event names
- NEVER identify with PII as distinct_id
- NEVER call identify() before login is confirmed
- NEVER skip posthog.reset() on logout
- NEVER use feature flags without handling the undefined state
- ALWAYS configure the reverse proxy. Direct calls to eu.posthog.com will be blocked by ad blockers
- Server-side events via posthog-node MUST use the same distinct_id as the client. Pass it from the session

Four rules here prevent the categories of mistake described above.

The autocapture: false rule eliminates double-counting. With autocapture disabled, every event in PostHog's database was placed there deliberately. Your funnel analysis reflects actual instrumentation decisions, not a combination of intentional captures and accidental duplicates.

The reverse proxy rule is non-negotiable. Claude will generate api_host: 'https://eu.posthog.com' by default because that is what the PostHog installation docs show. The rewrite in next.config.ts routes all PostHog traffic through your own domain, which ad blockers cannot distinguish from your application traffic. Without it, your analytics have a systematic hole that biases every metric toward non-ad-blocker users.

The distinct_id rule keeps PII out of PostHog's event stream. Using user.id as the distinct ID and passing email as a person property means email is stored as a person attribute (which PostHog can anonymise on deletion request) rather than baked into every event record as the primary key.

The approved event list prevents event naming entropy. Without it, Claude invents names per task: buttonClicked, Button Clicked, button-clicked, and button_clicked all appear in the same project as separate events. None of them can be merged retroactively. The approved list forces every capture call to use a pre-agreed name.

Install and reverse proxy setup

Add PostHog to a Next.js App Router project in three steps. First, install the packages:

npm install posthog-js
# for server-side event capture (webhooks, background jobs):
npm install posthog-node

Second, create the PostHog provider. The provider initialises the SDK once on the client side and makes the posthog instance available via React context:

// app/providers/PostHogProvider.tsx
'use client';

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

function PostHogPageView() {
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const ph = usePostHog();

  useEffect(() => {
    if (pathname && ph) {
      let url = window.origin + pathname;
      if (searchParams.toString()) {
        url = url + '?' + searchParams.toString();
      }
      ph.capture('page_viewed', { path: pathname, url });
    }
  }, [pathname, searchParams, ph]);

  return null;
}

export function PostHogProvider({ children }: { children: React.ReactNode }) {
  if (typeof window !== 'undefined') {
    posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
      api_host: '/ingest',
      ui_host: 'https://eu.posthog.com',
      autocapture: false,
      capture_pageview: false,
      capture_pageleave: true,
      session_recording: { maskAllInputs: true },
    });
  }

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

Third, add the rewrite to next.config.ts:

import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  async rewrites() {
    return [
      {
        source: '/ingest/:path*',
        destination: 'https://eu.posthog.com/:path*',
      },
    ];
  },
  skipTrailingSlashRedirect: true,
};

export default nextConfig;

The skipTrailingSlashRedirect: true setting prevents Next.js from stripping the trailing slash on PostHog's batch endpoint, which would break event delivery. Claude will omit this setting because it is not mentioned in the standard PostHog docs. Without it, events appear to send in the browser network tab but never arrive in PostHog.

Autocapture vs manual capture

PostHog's autocapture is on by default and records clicks, form submissions, and page views for every element with an ID, name, or data-attr. It is useful for exploratory analysis on a new product where you do not yet know which events matter. It is a liability for a mature product because it creates an uncontrolled event taxonomy you cannot predict or maintain.

The CLAUDE.md template above sets autocapture: false. With it off, Claude Code generates only deliberate capture calls. Here is the manual page view pattern and a set of component-level event captures:

// Checkout button, manual capture
import { usePostHog } from 'posthog-js/react';

export function CheckoutButton({ plan, priceUsd }: { plan: string; priceUsd: number }) {
  const posthog = usePostHog();

  function handleClick() {
    posthog.capture('checkout_started', {
      plan,
      price_usd: priceUsd,
    });
    // proceed to checkout...
  }

  return <button onClick={handleClick}>Start checkout</button>;
}

One capture call, one event, one row in PostHog per user click. With autocapture enabled and the same posthog.capture() call in place, PostHog records two events: the autocapture click event and your manual event. Funnel steps that use either event inflate your conversion rate calculations.

If you are migrating a project that was using autocapture, turn it off in a staging environment first and verify that your manual capture calls cover all the funnel steps you care about before disabling it in production. PostHog's event explorer will show you which events have disappeared.

Event naming conventions

Event names in PostHog are permanent. You cannot rename an event retroactively. An event called Button Clicked and one called button_clicked are two separate events in PostHog's database forever. The convention in the CLAUDE.md template is {noun}_{past_tense_verb} in snake_case, prefixed by product area:

// Correct
posthog.capture('signup_completed', { plan: 'pro', source: 'pricing_page' });
posthog.capture('checkout_started', { plan: 'pro', price_usd: 49 });
posthog.capture('onboarding_step_completed', { step: 2, step_name: 'connect_github' });

// Wrong, Claude generates these without guidance
posthog.capture('Signup Completed');            // spaces, title case
posthog.capture('signupCompleted');             // camelCase
posthog.capture('signup-completed');            // kebab-case
posthog.capture('button clicked');              // generic, spaces
posthog.capture('form submitted');              // tells you nothing useful

Property naming follows the same convention. All property keys use snake_case. Never use camelCase or spaces in property keys either, because PostHog's filter UI and SQL access layer treats them as separate dimensions.

The approved event list in CLAUDE.md means Claude does not invent new event names during a task. If the list does not include the event a new feature needs, Claude flags the gap and asks rather than generating a new name ad hoc. This keeps the event taxonomy under explicit control.

identify() and alias() patterns

PostHog assigns every anonymous visitor a UUID as their distinct_id. When a user logs in and you call posthog.identify(), PostHog merges the anonymous session with the identified user, so pre-login funnel steps are attributed to the same person as post-login steps.

// app/actions/auth.ts or wherever login confirmation happens
import { usePostHog } from 'posthog-js/react';

// In a login success handler (client component)
const posthog = usePostHog();

function onLoginSuccess(user: { id: string; email: string; plan: string }) {
  posthog.identify(user.id, {
    email: user.email,
    plan: user.plan,
    created_at: user.createdAt,
  });

  posthog.capture('login_completed', { method: 'email' });
}

The user.id is your database's internal user ID, not the email. The email is passed as a person property, which PostHog stores and can anonymise on a GDPR deletion request. A distinct ID that is the email itself is baked into every historical event and cannot be pseudonymised.

On logout, call posthog.reset():

function onLogout() {
  posthog.reset(); // generates a new anonymous distinct_id for the next session
  // redirect to login
}

Without posthog.reset(), the next anonymous user who uses the same browser inherits the previous user's distinct ID. Their anonymous activity is merged into the previous user's person record. Claude will omit posthog.reset() unless it is in CLAUDE.md because logout handlers are often not in the same file as the PostHog integration.

The posthog.alias() method is for merging two known IDs when the same user has two separate PostHog person records, typically from using two different devices before logging in on both. The CLAUDE.md template instructs Claude to leave alias to the PostHog automatic merge logic unless you are explicitly resolving a known duplicate.

Feature flags with bootstrap

PostHog evaluates feature flags by sending a request to its API when the SDK loads. On the first render, before that request returns, posthog.isFeatureEnabled('flag-name') returns undefined. If your component renders immediately on page load, users see a flash of the default state before the flag resolves.

The bootstrap option prevents this by passing flag values computed on the server during SSR:

// app/layout.tsx (Server Component)
import { PostHogServerClient } from '@/lib/posthog-server';
import { cookies } from 'next/headers';

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const cookieStore = cookies();
  const distinctId = cookieStore.get('ph_distinct_id')?.value ?? crypto.randomUUID();

  const serverClient = PostHogServerClient();
  const flags = await serverClient.getAllFlags(distinctId);
  await serverClient.shutdown();

  return (
    <html>
      <body>
        <PostHogProvider bootstrap={{ distinctId, featureFlags: flags }}>
          {children}
        </PostHogProvider>
      </body>
    </html>
  );
}
// lib/posthog-server.ts
import { PostHog } from 'posthog-node';

export function PostHogServerClient() {
  return new PostHog(process.env.POSTHOG_API_KEY!, {
    host: 'https://eu.posthog.com',
    flushAt: 1,
    flushInterval: 0,
  });
}

The bootstrap object on init tells the client SDK to use the pre-computed flag values for the first render, skipping the initial API call. The SDK still fetches fresh flags in the background and updates them if they differ. Users see the correct flag state immediately, no flash.

Without the bootstrap pattern, gates behind feature flags flash from the default state to the correct state on every hard page load. That is visible to users and pollutes your experiment exposure metrics with partial renders that did not actually expose the user to the variant.

A/B experiments and variant routing

PostHog experiments are configured in the PostHog dashboard and accessed via the same feature flag API. An experiment named pricing-layout-test returns 'control', 'treatment', or undefined from posthog.getFeatureFlag().

// components/PricingSection.tsx
'use client';

import { useFeatureFlagVariantKey } from 'posthog-js/react';
import { useEffect } from 'react';
import { usePostHog } from 'posthog-js/react';

export function PricingSection() {
  const variant = useFeatureFlagVariantKey('pricing-layout-test');
  const posthog = usePostHog();

  useEffect(() => {
    if (variant !== undefined) {
      posthog.capture('experiment_viewed', {
        experiment: 'pricing-layout-test',
        variant,
      });
    }
  }, [variant, posthog]);

  if (variant === undefined) {
    return <PricingDefault />; // loading state, same as control
  }

  if (variant === 'treatment') {
    return <PricingLayoutTreatment />;
  }

  return <PricingLayoutControl />;
}

The exposure event experiment_viewed is critical. PostHog uses it to attribute downstream conversions to the correct variant. If you render variant components without logging the exposure, conversions appear on users whose variant assignment is unknown. Your experiment results are uninterpretable.

The variant routing lives in one component or function, not scattered across the codebase. When the experiment ends, you delete one switch and keep the winning variant. Claude will scatter variant checks across multiple components without the routing-centralisation rule in CLAUDE.md.

For the subscription upgrade flow that typically follows a pricing experiment, Claude Code with Supabase covers the database writes that record plan changes alongside PostHog events.

Session replay privacy rules

Session replay records every DOM interaction. Without privacy configuration, it captures what users type into every input field, including passwords, card numbers, and personal information. PostHog's SDK defaults to masking all inputs, which is the correct starting point.

The CLAUDE.md template sets maskAllInputs: true in the session recording config. Within that default, you control replay further with CSS classes:

// Block this element entirely from session replay
<div class="ph-mask">
  {/* This element and its children are blacked out in replay */}
  <CreditCardForm />
</div>

// Block a specific input (redundant with maskAllInputs:true, but explicit)
<input
  type="text"
  className="ph-no-capture"
  placeholder="Card number"
/>

// Display-only element with PII that is not an input
<span className="ph-mask">{user.ssn}</span>

ph-mask blacks out the entire element area in the replay UI. ph-no-capture prevents the element from being captured. Both apply to the element and all its descendants. The difference matters for diagnostic value: a masked input still shows cursor position and timing, a no-capture element disappears entirely.

Claude Code will add session replay without privacy configuration when PostHog is present in the project but CLAUDE.md does not address it. The result is a session replay that captures plaintext from every form field including login screens and checkout flows. The CLAUDE.md template makes maskAllInputs: true and the ph-mask pattern the default rather than an afterthought.

Server-side capture with posthog-node

Client-side events capture what happens in the browser. Server-side events capture what happens in your backend: webhooks completing, background jobs running, payment confirmations arriving. The posthog-node client sends these events with the same distinct ID as your client events so PostHog can stitch the full user journey.

// lib/posthog-server.ts
import { PostHog } from 'posthog-node';

let client: PostHog | null = null;

export function getPostHogServerClient(): PostHog {
  if (!client) {
    client = new PostHog(process.env.POSTHOG_API_KEY!, {
      host: 'https://eu.posthog.com',
      flushAt: 1,
      flushInterval: 0,
    });
  }
  return client;
}

// In a webhook handler (e.g. Stripe checkout.session.completed)
export async function POST(request: Request) {
  const event = await parseStripeWebhook(request);

  if (event.type === 'checkout.session.completed') {
    const session = event.data.object;
    const userId = session.metadata?.userId;

    if (userId) {
      const ph = getPostHogServerClient();
      ph.capture({
        distinctId: userId,       // same as client-side posthog.identify(userId)
        event: 'checkout_completed',
        properties: {
          plan: session.metadata?.plan,
          price_usd: session.amount_total ? session.amount_total / 100 : 0,
          payment_method: session.payment_method_types?.[0],
        },
      });

      await ph.flush();
    }
  }

  return Response.json({ received: true });
}

The flushAt: 1 and flushInterval: 0 settings flush events immediately rather than batching. In a serverless environment (Next.js API routes, Vercel Functions), the process may exit before a batch is sent. Immediate flush ensures events are not lost when the function runtime terminates.

The distinctId on the server-side event must match the distinct_id PostHog assigned to the user on the client side. Passing userId from your session (the same ID you called posthog.identify() with) achieves this. If you use a different ID on the server, the event is attributed to a separate person record and does not appear in the user's timeline.

Common gotchas Claude generates without guidance

Five mistakes appear consistently when Claude Code instruments PostHog without CLAUDE.md constraints.

Missing the reverse proxy. Claude reads the official PostHog Next.js installation guide and generates api_host: 'https://eu.posthog.com'. This works in development, where ad blockers are less relevant, so the error is invisible until you check analytics data from production users with blockers installed. The reverse proxy setup must be in CLAUDE.md so it is generated by default, not as a follow-up fix.

Tracking the same event from client and server. A checkout completion event captured from the browser on the success page and from the server on the Stripe webhook fires twice for every purchase. Your checkout_completed count is double your actual purchase count. The CLAUDE.md rule is: server-side events for backend-confirmed state changes (payment confirmed, email delivered, job completed), client-side events for UI interactions (button clicked, step viewed, form submitted). Overlap is a bug.

Using PII as distinct_id. Claude defaults to what is most obviously unique about a user. That is typically email. Adding the explicit rule that distinct_id must be an internal database ID and that PII goes into person properties prevents this from reaching production.

Missing posthog.reset() on logout. Logout handlers are often in auth utilities far from the PostHog integration. Claude does not connect them unless the CLAUDE.md declares the lifecycle rule. A session that does not reset on logout transfers the previous user's identity to the next anonymous user on the same device.

Feature flag checks without undefined handling. posthog.isFeatureEnabled('flag') returns undefined before the SDK has loaded flags from the API. Code that treats undefined as false will hide the feature from users whose flags have not resolved yet, including users on slow connections. The CLAUDE.md rule to always handle undefined forces a third state into every flag check: loading, enabled, or disabled.

Permission hooks for PostHog scripts

PostHog's CLI and admin API can delete events, reset persons, and export raw event data. Gate these operations in .claude/settings.local.json:

{
  "permissions": {
    "allow": [
      "Bash(npx posthog-js*)",
      "Bash(node scripts/backfill-events.js*)",
      "Bash(node scripts/validate-events.js*)"
    ],
    "deny": [
      "Bash(node scripts/delete-person*.js*)",
      "Bash(node scripts/export-raw-events.js*)",
      "Bash(curl*posthog.com/api/person*DELETE*)"
    ]
  }
}

Event validation and backfill scripts can run without prompting. Person deletion and raw data export require explicit confirmation. The deny list prevents Claude from invoking GDPR deletion scripts as part of a data cleanup workflow without the developer seeing the confirmation prompt.

Analytics that reflects reality

The PostHog CLAUDE.md in this guide produces an instrumentation setup where events are captured once (autocapture off, manual only), event names are stable and consistent (approved list in CLAUDE.md, snake_case rule), analytics data arrives for ad blocker users (reverse proxy), user identity is correctly threaded from anonymous to identified and reset on logout, feature flags resolve on the first render without flash (bootstrap from SSR), experiment exposures are logged before variant components render, and session replay masks sensitive fields by default.

The underlying principle is the same as any instrumentation integration with Claude Code. Analytics without a CLAUDE.md produces a data set that looks complete in PostHog's dashboard but has systematic gaps and duplications that distort every metric you use to make decisions. Analytics with the configuration above is a direct reflection of user behaviour, not a reflection of Claude's best guess at what PostHog setup looks like.

For the mechanics of how CLAUDE.md is read at session start and how to version it alongside your application, see Claude Code with Next.js. Claudify includes a PostHog-specific CLAUDE.md template, pre-configured for the reverse proxy setup, event naming conventions, identify lifecycle, feature flag bootstrapping, experiment exposure logging, and session replay privacy controls shown in this guide.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir Claudify - Featured on Startup Fame