# Agent file: set up product analytics with PostHog, collecting only what was decided

This file is written for an AI coding agent working in a repository with its owner present. Follow the steps in order and run the check at the end of each step before starting the next. It is complete without the guide it accompanies.

Every fact about PostHog here was checked against the pages under Sources on October 12, 2026. Commands, flags and dashboard labels change. If one fails, read the linked page before trying again. Do not guess.

The TypeScript below is trimmed from working code and type-checks against `posthog-js` 1.396, Next.js 16.4 and React 19. The identify sample also needs `@clerk/nextjs` 7. On another major version, check each option against the installed types before copying it.

## Goal

The app sends page views and a small set of named events to one PostHog project, and nothing else. Nothing is stored in the visitor's browser. Every event passes through an allow-list of properties before it leaves the browser, so the full page URL, the referrer and error text are never sent. Session replay, click autocapture, heatmaps, automatic exception capture, surveys, experiments and feature-flag requests are off, each set in code. With no project token set, the app builds, starts and serves every page exactly as before and sends nothing.

This is the default. Anything looser is a decision the human makes, one item at a time.

## Preconditions

Do these before writing any PostHog code.

- Ask the human the four questions below and wait for the answers. Do not guess them from the code alone.
  1. Which URLs carry a value that works as a credential or names a person (checkout session ids, sign-in or reset tokens, invite codes, email addresses), in the query string or in the path?
  2. Which pages print personal data as text?
  3. Does the product run on more than one subdomain?
  4. Can an error message contain something a user typed?
- Then check the answers against the repository. Search the routes for query parameters and dynamic path segments, and report anything the human did not mention.
- Search for existing analytics (another analytics SDK, a `posthog` import, a tag manager script). If PostHog is already installed, report where and how it is initialized, and wait. Do not add a second `init`.
- Confirm the app is a Next.js App Router app, and read its version in `package.json`.
- Check whether the app sends a Content-Security-Policy header. If it does, the policy must allow the PostHog ingestion host. PostHog documents that a policy which blocks it fails silently. Report it and read the Next.js page under Sources.
- Check how the app is built for production. `NEXT_PUBLIC_` values are inlined during the build, so a Docker build needs an `ARG` for each one.

PostHog's Next.js page offers an install wizard and a default `posthog.init` call. Do not run the wizard or paste the default call. This file sets every option by hand so each one can be reviewed.

## Environment variables

Names only. PostHog documents the first one as `NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN`. If the repository already uses a name, keep it.

| Name | Notes |
| --- | --- |
| `NEXT_PUBLIC_POSTHOG_KEY` | The project token. Public by design: PostHog documents that it starts with `phc_`, can send events and cannot read data. Inlined at build time. The human sets the value. |
| `NEXT_PUBLIC_POSTHOG_HOST` | `https://us.i.posthog.com` for US Cloud, `https://eu.i.posthog.com` for EU Cloud. Must match the region the project was created in. |

```bash
# The project token. Public by design: it ships to the browser and can only send events.
NEXT_PUBLIC_POSTHOG_KEY=
# US Cloud. For a project created in the EU region use https://eu.i.posthog.com
NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
```

A personal API key (prefix `phx_`) can give full access to the account. Nothing in this file needs one. If you find one in client code or in git, stop and tell the human.

## Stop and ask the human

- Any secret value. Ask for the variable to be set by the human. Never print, log, commit or echo a secret, and never ask for one to be pasted into the conversation.
- Any sign-in, account creation, plan choice or payment method.
- Anything that deletes data or cannot be undone.
- Creating the PostHog organization and project, and choosing the US or EU region. PostHog documents that moving a project between regions needs its Scale or Enterprise plan and a PostHog engineer, so the choice is not yours to make.
- Any key. The project token is public, but the human still sets it on the host. Give them the variable names and wait.
- The IP address setting and the location decision in step 9. Both are in the PostHog project settings and both belong to the human.
- Any decision to turn a capture feature on: session replay, click autocapture, heatmaps, automatic exception capture, surveys, experiments or feature flags. The same goes for changing `persistence` from `memory`, loading external PostHog scripts, and calling `identify`.
- Adding a property to the allow-list. Say what the property contains and where its value comes from, and wait for a yes.
- The event names in step 12, and what counts as activation and the key action in this product.
- Any change to the privacy policy.

## Steps

### 1. Record the answers

Write the four answers from Preconditions, and the on or off decision for each capture feature, into the repository where the next person will find them: a short section in the project documentation, or the comment above the allow-list in step 4. If any of answers 1, 2 or 4 is yes, or the human has not decided, use the configuration in this file unchanged.

Check: the human has read the recorded answers and confirmed them.

### 2. Install the SDK

```bash
pnpm add posthog-js
```

Check: `posthog-js` is in `package.json` and the lockfile changed.

### 3. Add the module that decides whether PostHog is on

```ts
// lib/posthog.ts
/** The variable names, declared once so no other module re-types them. */
export const POSTHOG_ENV_VAR = {
  key: 'NEXT_PUBLIC_POSTHOG_KEY',
  host: 'NEXT_PUBLIC_POSTHOG_HOST',
} as const;

/** The US Cloud ingestion host, used when no host is set. */
export const DEFAULT_POSTHOG_HOST = 'https://us.i.posthog.com' as const;

export interface PublicPostHogConfig {
  readonly key: string;
  readonly host: string;
}

/** The config for the browser client, or undefined when no project token is set. */
export function getPublicPostHogConfig(): PublicPostHogConfig | undefined {
  const key = process.env[POSTHOG_ENV_VAR.key];
  if (!key) return undefined;
  const host = process.env[POSTHOG_ENV_VAR.host] || DEFAULT_POSTHOG_HOST;
  return { key, host };
}
```

Every other module asks this one. None reads the variables itself.

Check: a unit test shows `getPublicPostHogConfig()` returns `undefined` when the key is unset or empty, and the default host when only the key is set.

### 4. Add the allow-list

In the same module. The list is the whole of what an event may carry. A property that is not on it, or whose value is not a number, a boolean or a short plain string, is dropped. Nothing is rewritten.

```ts
// lib/posthog.ts (continued)
/** The campaign tags an event may carry. posthog-js reads them from the landing URL. */
export const ANALYTICS_CAMPAIGN_PROPERTIES: ReadonlySet<string> = new Set([
  'utm_source',
  'utm_medium',
  'utm_campaign',
  'utm_term',
  'utm_content',
]);

/**
 * Every property an event from this site may carry. A property is on this
 * list, with a plain value, or it is not sent. A new one is private until
 * someone adds it here. Deliberately absent: $current_url, $referrer,
 * $raw_user_agent and every $exception_* property.
 */
export const ANALYTICS_ALLOWED_PROPERTIES: ReadonlySet<string> = new Set([
  // Required by PostHog to accept and order the event.
  'token',
  'distinct_id',
  '$device_id',
  '$session_id',
  '$window_id',
  '$pageview_id',
  '$insert_id',
  '$time',
  '$lib',
  '$lib_version',
  '$is_identified',
  '$process_person_profile',
  // Where on the site, without the query string.
  '$host',
  '$pathname',
  '$prev_pageview_pathname',
  '$prev_pageview_duration',
  // Which site sent the visitor: the domain only, never the full referrer.
  '$referring_domain',
  // The visitor's device, in broad terms.
  '$browser',
  '$browser_version',
  '$browser_language',
  '$os',
  '$os_version',
  '$device_type',
  '$screen_height',
  '$screen_width',
  '$viewport_height',
  '$viewport_width',
  '$timezone',
  ...ANALYTICS_CAMPAIGN_PROPERTIES,
]);

/** The prefix posthog-js gives a property's first-seen copy ($initial_pathname). */
const INITIAL_PREFIX = '$initial_';

/** Characters that mark query data, a fragment or an address. A value holding one is not sent. */
const UNSAFE_VALUE_CHARACTERS = /[?#=@&%\s]/;

/** The longest string value an event property may have. */
const MAX_VALUE_LENGTH = 200;

function isAllowedProperty(name: string): boolean {
  if (ANALYTICS_ALLOWED_PROPERTIES.has(name)) return true;
  if (!name.startsWith(INITIAL_PREFIX)) return false;
  const base = name.slice(INITIAL_PREFIX.length);
  return ANALYTICS_ALLOWED_PROPERTIES.has(`$${base}`) || ANALYTICS_CAMPAIGN_PROPERTIES.has(base);
}

/** A character test, not a parse: one unsafe character anywhere rejects the whole value. */
function isSafeValue(value: unknown): value is string | number | boolean {
  if (typeof value === 'number' || typeof value === 'boolean') return true;
  return typeof value === 'string' && value.length <= MAX_VALUE_LENGTH && !UNSAFE_VALUE_CHARACTERS.test(value);
}

/** Nothing is rewritten: each property is kept exactly as it was or dropped. */
export function allowedEventProperties(properties: Readonly<Record<string, unknown>>): Record<string, string | number | boolean> {
  const allowed: Record<string, string | number | boolean> = {};
  for (const [name, value] of Object.entries(properties)) {
    if (isAllowedProperty(name) && isSafeValue(value)) allowed[name] = value;
  }
  return allowed;
}
```

Do not add `$current_url`, `$referrer` or any `$exception_` property. Do not replace the list with code that tries to clean a URL.

Check: the file exports `allowedEventProperties`, and no property name on the list can hold free text.

### 5. Add the options that switch everything else off

In the same module. PostHog documents that automatic exception capture, heatmaps and performance capture fall back to the project's remote settings when the option is left undefined, so each is set explicitly here. `advanced_disable_flags` stops the request to the flags endpoint. `disable_external_dependency_loading` stops the SDK from requesting the external scripts that session replay and surveys need.

```ts
// lib/posthog.ts (continued)
/**
 * Held as plain data, apart from the browser code, so a test can check that
 * none is quietly dropped. A local true or false here wins over the project's
 * remote settings.
 */
export const POSTHOG_PRIVACY_OPTIONS = {
  // Nothing is stored on the visitor's device: no cookie, no localStorage.
  // The cost: a visitor is counted again after a full page reload.
  persistence: 'memory',
  cross_subdomain_cookie: false,
  save_referrer: false,
  // No feature-flag requests. They go straight to PostHog and do not pass
  // through before_send.
  advanced_disable_flags: true,
  // posthog-js may not download any further script (the recorder, surveys,
  // the toolbar), so a feature turned on in PostHog's settings has no code
  // here to run.
  disable_external_dependency_loading: true,
  // No session replay: account pages show a buyer's email and purchases.
  disable_session_recording: true,
  // No click capture of any kind: it records the text of what was clicked.
  autocapture: false,
  rageclick: false,
  capture_dead_clicks: false,
  capture_heatmaps: false,
  enable_heatmaps: false,
  // No automatic exception capture: an error message is free text.
  capture_exceptions: false,
  // No performance or network capture: entries carry request URLs.
  capture_performance: false,
  // Nothing that renders PostHog content into the page or reports on it.
  disable_surveys: true,
  disable_web_experiments: true,
  disable_conversations: true,
  disable_product_tours: true,
  advanced_disable_toolbar_metrics: true,
} as const;
```

Check: every option in the block is present. Do not remove one to fix a type error. If the installed `posthog-js` does not know an option, report it.

### 6. Add the provider and mount it in the root layout

The provider starts PostHog once, spreads the options from step 5, filters every event through the allow-list in `before_send`, and captures the page view itself with the path only.

```tsx
// components/PostHogProvider.tsx
'use client';

import { Suspense, useEffect, type ReactElement, type ReactNode } from 'react';
import { usePathname, useSearchParams } from 'next/navigation';
import posthog from 'posthog-js';

import { POSTHOG_PRIVACY_OPTIONS, allowedEventProperties, type PublicPostHogConfig } from '@/lib/posthog';

/** Safe to call from more than one place: a second init is skipped. */
export function initPostHogOnce(config: PublicPostHogConfig): void {
  if (posthog.__loaded) return;
  posthog.init(config.key, {
    api_host: config.host,
    // Page views are captured by RouteChangeTracker below, so the automatic one is off.
    capture_pageview: false,
    capture_pageleave: true,
    ...POSTHOG_PRIVACY_OPTIONS,
    // The last gate before anything leaves the browser: only allow-listed
    // properties are sent, on the event and on the person.
    before_send: (event) => {
      if (event === null) return null;
      return {
        ...event,
        properties: allowedEventProperties(event.properties),
        ...(event.$set !== undefined ? { $set: allowedEventProperties(event.$set) } : {}),
        ...(event.$set_once !== undefined ? { $set_once: allowedEventProperties(event.$set_once) } : {}),
      };
    },
  });
}

/** Captures a page view on every App Router path or query change. */
function RouteChangeTracker(): null {
  const pathname = usePathname();
  const searchParams = useSearchParams();

  useEffect(() => {
    if (!posthog.__loaded) return;
    // The path only. Campaign tags reach PostHog as their own allow-listed properties.
    posthog.capture('$pageview', { $pathname: pathname });
  }, [pathname, searchParams]);

  return null;
}

export function PostHogProvider(props: {
  readonly config: PublicPostHogConfig | undefined;
  readonly children: ReactNode;
}): ReactElement {
  const { config, children } = props;

  useEffect(() => {
    if (config) initPostHogOnce(config);
  }, [config]);

  // No project token: render the page untouched. No SDK call, no network request.
  if (!config) return <>{children}</>;

  return (
    <>
      {/* useSearchParams() needs a Suspense boundary, or the whole route turns dynamic. */}
      <Suspense fallback={null}>
        <RouteChangeTracker />
      </Suspense>
      {children}
    </>
  );
}
```

The root layout resolves the config on the server and passes it down. Next.js documents that a lookup by variable name is not inlined into the browser bundle, so the config module must not be called from client code.

```tsx
// app/layout.tsx
import type { ReactNode } from 'react';

import { PostHogProvider } from '@/components/PostHogProvider';
import { getPublicPostHogConfig } from '@/lib/posthog';

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>
        <PostHogProvider config={getPublicPostHogConfig()}>{children}</PostHogProvider>
      </body>
    </html>
  );
}
```

Check: the app builds. With no token set, loading a page makes no request to a PostHog host.

### 7. Pin the behavior with tests

```ts
// lib/posthog.test.ts
import { describe, expect, it } from 'vitest';

import { POSTHOG_PRIVACY_OPTIONS, allowedEventProperties } from '@/lib/posthog';

describe('allowedEventProperties', () => {
  it('keeps where, what device and which campaign, and drops the URL, the referrer and free text', () => {
    expect(
      allowedEventProperties({
        $pathname: '/kits/thanks',
        $host: 'www.example.com',
        $referring_domain: 'www.google.com',
        $browser: 'Chrome',
        $viewport_width: 1440,
        utm_source: 'newsletter',
        $current_url: 'https://www.example.com/kits/thanks?session_id=cs_test_abc',
        $referrer: 'https://www.example.com/api/kits/login?token=secret',
        $exception_list: [{ value: 'No purchases found for dana@example.com' }],
        $el_text: 'dana@example.com',
        something_new: 'private until someone allows it',
      }),
    ).toEqual({
      $pathname: '/kits/thanks',
      $host: 'www.example.com',
      $referring_domain: 'www.google.com',
      $browser: 'Chrome',
      $viewport_width: 1440,
      utm_source: 'newsletter',
    });
  });

  it('drops an allowed property whose value carries query data, an address or anything not plain', () => {
    for (const value of ['/thanks?session_id=cs_test_abc', '/u/dana@example.com', 'a&b', 'two words', ['/pricing'], null]) {
      expect(allowedEventProperties({ $pathname: value, utm_source: value })).toEqual({});
    }
  });
});

describe('POSTHOG_PRIVACY_OPTIONS', () => {
  it('stores nothing on the device and shares no cookie across subdomains', () => {
    expect(POSTHOG_PRIVACY_OPTIONS.persistence).toBe('memory');
    expect(POSTHOG_PRIVACY_OPTIONS.cross_subdomain_cookie).toBe(false);
    expect(POSTHOG_PRIVACY_OPTIONS.save_referrer).toBe(false);
  });
});
```

Add a case for each sensitive value the human named in step 1, using fake values.

Check: the tests pass, and removing a line from the options in step 5 makes one fail.

### 8. Pass the variables through the build

Skip this step if the app is not built with Docker. Railway documents that a variable is available during a Docker build only if the Dockerfile declares it with `ARG`.

```dockerfile
# In the build stage, before the command that runs next build.
# The browser key and host are inlined at build time like every NEXT_PUBLIC_
# value, so statically rendered pages only see them if they reach the build.
# Unset means PostHog stays off.
ARG NEXT_PUBLIC_POSTHOG_KEY
ENV NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
ARG NEXT_PUBLIC_POSTHOG_HOST
ENV NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST}
```

Set the variables for production only, so staging and preview traffic stays out of the project.

Check: the two `ARG` lines come before the command that runs the build.

### 9. Hand the PostHog project steps to the human

Do not do these. Give them to the human and wait for each one:

- Create the project and choose the region.
- Set `NEXT_PUBLIC_POSTHOG_KEY` and `NEXT_PUBLIC_POSTHOG_HOST` on the host, then redeploy.
- In the project settings, find IP data capture configuration and enable Discard client IP data. PostHog documents that EU organizations default to discarding IP addresses and US organizations set the default themselves.
- Decide whether to keep location data. PostHog documents that GeoIP enrichment is on by default, can still use the IP address before it is discarded, and adds country, city, subdivision, postal code, approximate coordinates and time zone to events.

Check: the human has confirmed all four, and has told you the location decision so you can record it with the answers from step 1.

### 10. Verify on the deployed site

Ask the human to open a private browser window and load a page with a fake id and a campaign tag added to the address, for example `/pricing?session_id=test_123&utm_source=privacycheck`. Then run this in the browser console:

```ts
// Paste into the browser console on your own site, after a page has loaded.
({
  cookies: document.cookie.split('; ').filter(Boolean).map((cookie) => cookie.split('=')[0]),
  localStorage: Object.keys(localStorage),
  sessionStorage: Object.keys(sessionStorage),
});
```

Then the human opens the Activity tab in PostHog and expands the page view event.

Check: no cookie name starts with `ph_` and neither storage list holds a PostHog entry. The event has the path and `utm_source`. It has no current URL and no referrer, the fake id appears nowhere, and no IP address is stored on it.

### 11. Identify signed-in users, only if the human decided to

Skip this step unless the human said yes in step 1. PostHog documents the rules: use a stable id from the auth system as the distinct id, never a shared value such as "anonymous", and call `posthog.reset()` on sign-out. Anonymous events can be up to four times cheaper than identified ones.

```tsx
// components/AnalyticsIdentifier.tsx. Render it once, inside <ClerkProvider>.
'use client';

import { useUser } from '@clerk/nextjs';
import posthog from 'posthog-js';
import { useEffect } from 'react';

export function AnalyticsIdentifier(): null {
  const { isLoaded, isSignedIn, user } = useUser();

  useEffect(() => {
    // Nothing to do until Clerk has loaded, or when PostHog is switched off.
    if (!isLoaded || !posthog.__loaded) return;

    if (isSignedIn && user) {
      // The distinct id is the stable Clerk user id. Email and name are person properties.
      posthog.identify(user.id, {
        email: user.primaryEmailAddress?.emailAddress,
        name: user.fullName ?? undefined,
      });
    } else {
      // Signed out: the next person on this browser must not inherit the last one's identity.
      posthog.reset();
    }
  }, [isLoaded, isSignedIn, user]);

  return null;
}
```

The allow-list applies to person properties too. Unchanged, it sends the user id and drops the email and the name. Do not add either to the list without a yes from the human.

Check: signing in produces an identify event under the auth user id, and signing out is followed by a new anonymous id.

### 12. Capture the first events

Use the names the human gave you for sign up, activation, the key action, checkout started and purchase. PostHog recommends lowercase snake case and fixed strings: never build an event name from a variable. Keep the names in one map. Capture sign up and purchase on the server, where PostHog documents that analytics are more reliable. A property on a browser event is dropped until it is on the allow-list.

```ts
// lib/product-events.ts
/** Event names, as they appear in PostHog. Defined once, never typed inline. */
export const PRODUCT_EVENT = {
  organizationCreated: 'organization_created',
} as const;

/** The slice of the analytics service that sends one event. Must not throw. */
export interface EventTracker {
  track(distinctId: string, event: string, properties?: Readonly<Record<string, string>>): Promise<void> | void;
}

export interface OrganizationCreated {
  /** The Clerk user id of the person who created the organization. */
  readonly creatorClerkUserId: string;
  readonly organizationId: string;
  readonly planSlug: string;
}

/** Called from the Clerk webhook when an organization is created. */
export async function trackOrganizationCreated(tracker: EventTracker, event: OrganizationCreated): Promise<void> {
  await tracker.track(event.creatorClerkUserId, PRODUCT_EVENT.organizationCreated, {
    organization_id: event.organizationId,
    plan_slug: event.planSlug,
  });
}
```

Check: each event name appears once in the map and nowhere else as a string.

### 13. Hand over the privacy policy list

Do not edit the policy. Give the human this list of what it should now say: that PostHog is used and in which region, what each event contains (the allow-list), that nothing is stored in the browser, whether IP addresses are stored, whether location is derived from them, whether signed-in users are identified, and how to opt out.

## Done when

- The answers from step 1 and the location decision are recorded in the repository.
- With no token set, the app builds and runs and makes no request to PostHog.
- With the token set, the check in step 10 passes on the deployed site.
- The tests from step 7 pass.
- No capture feature is on that the human did not decide to turn on.
- No personal API key is in the repository or the client bundle.
- The human has the privacy policy list from step 13.

## Sources

- [Next.js](https://posthog.com/docs/libraries/next-js), PostHog Docs
- [JavaScript web configuration](https://posthog.com/docs/libraries/js/config), PostHog Docs
- [PostHogConfig](https://posthog.com/docs/references/posthog-js/types/PostHogConfig), PostHog SDK reference
- [JavaScript web persistence and cookies](https://posthog.com/docs/libraries/js/persistence), PostHog Docs
- [Privacy compliance](https://posthog.com/docs/privacy), PostHog Docs
- [Controlling data collection](https://posthog.com/docs/privacy/data-collection), PostHog Docs
- [Controlling data storage](https://posthog.com/docs/privacy/data-storage), PostHog Docs
- [Privacy controls](https://posthog.com/docs/product-analytics/privacy), PostHog Docs
- [Person properties](https://posthog.com/docs/product-analytics/person-properties), PostHog Docs
- [API overview](https://posthog.com/docs/api), PostHog Docs
- [Personal API keys](https://posthog.com/docs/api/personal-api-keys), PostHog Docs
- [Projects](https://posthog.com/docs/settings/projects), PostHog Docs
- [Identify users](https://posthog.com/docs/getting-started/identify-users), PostHog Docs
- [Activity tab](https://posthog.com/docs/activity), PostHog Docs
- [Product analytics best practices](https://posthog.com/docs/product-analytics/best-practices), PostHog Docs
- [Environment variables](https://nextjs.org/docs/app/guides/environment-variables), Next.js Docs
- [Dockerfiles](https://docs.railway.com/builds/dockerfiles), Railway Docs