PostHog guide · About two hours · Free up to 1 million events a month
Set up product analytics with PostHog
Page views and named events, and nothing else: decide what you will not collect, send a fixed list of properties, store nothing in the browser, then prove it with a check you can run.

Last verified against the official PostHog documentation on October 12, 2026. Prices and free tiers change, so open the linked pages before you rely on a number.
Working with a coding agent? Get the agent file for this guide. It covers the same setup as exact steps an agent can follow.
Decide what you will not collect before you install anything. PostHog's browser SDK attaches the page's full URL and the referrer to every event it sends: both are in its list of default properties. It can also record sessions, clicks and errors. Whether that is acceptable depends on what your URLs and pages can contain.
PostHog has been live on this site, www.startupquickstart.com, since October 12, 2026, on PostHog's US Cloud. It collects page views and named events and nothing else. This guide is that setup: the decision, the install, the configuration we run with its code, the one setting that lives on PostHog's side, and the check we ran to prove it. The project holds a few hours of data as we write this, so we have no dashboards or results to show. The sections on events and funnels are PostHog's documented advice and our reasoning.
Why PostHog
PostHog's pricing page lists product analytics, web analytics, session replay, feature flags, experiments, surveys and error tracking as products on one account. A new company gets page traffic, product events and funnels in one place now, with the other tools on the same account for later. This guide turns on the first group only. This site also keeps a small first-party page counter of its own, which needs no key and stays on whether PostHog is on or off.
What it costs
At the time of writing, the pricing page lists a Free plan with no credit card required, one project, one year of data retention and community support. The paid plan has a $0 base price, keeps the same free allowance every month and bills only for use above it. There are no per-seat charges.
The allowance that matters here is the first 1,000,000 product analytics events each month, with web analytics included in that number. Above it, the next million events are listed at $0.00005 each, which comes to $50 for a second million. PostHog's page on identifying users adds that anonymous events can be up to four times cheaper than identified ones.
Why we collect so little
Our site puts a Stripe Checkout session id in the URL after a purchase. A sign-in link carries a token in its query string. One account page shows the buyer's email address. With PostHog's defaults, three things would have followed.
- Every page view on those pages would have sent its full URL to PostHog, query string included.
- Session replay, once on, would have recorded those pages. PostHog's replay privacy page says input fields are masked by default and general text is not. An email address printed on a page is general text.
- The SDK keeps the first page URL of a visit in its own storage. PostHog's persistence page says the default storage includes a first-party cookie that sibling subdomains can read, and lists initial-person information among the values that cookie carries. A post-checkout URL could have traveled to our app and API hosts in that cookie.
We first tried to clean URLs out of event text. Several rounds of security review each found a way for that cleaning to miss something. What held was sending a fixed list of fields and nothing else. The rule we took from it: prefer a list you can read over a filter you have to trust.
Before you start
- A Next.js app on the App Router.
- A host where you can set environment variables. Ours is Railway, and the Railway guide covers setting them.
- If the app has accounts, a stable user id from your sign-in provider. The Clerk guide sets that up.
Step 1: write down what your URLs and pages can contain
Answer four questions about your own app, in writing.
- Which URLs carry a value that works as a credential or names a person? Look for checkout session ids, sign-in and reset tokens, invite codes and email addresses, in the query string and in the path.
- Which pages print personal data as text?
- Does the product run on more than one subdomain, such as a marketing site, an app and an API?
- Can an error message contain something a user typed? One of ours could have read "No purchases found for" followed by an email address.
If every answer is no, PostHog's defaults are a reasonable place to start, and step 5 says what you can loosen. If any answer is yes, start from the allow-list in step 5 and open things up one decision at a time. Then decide each capture feature by name, on or off: session replay, click autocapture, heatmaps, automatic exception capture, surveys and feature flags. On our marketing site all of them are off.
Step 2: create the project and choose the region
Sign up, create an organization and a project, and choose where the data lives. PostHog Cloud has a US and an EU region, and its API overview gives the ingestion address for each: https://us.i.posthog.com for US Cloud and https://eu.i.posthog.com for EU Cloud. The EU region is hosted in Frankfurt, and PostHog recommends it when you need to comply with GDPR.
Treat the region as hard to change. PostHog's page on projects says a move between regions is available only on its Scale and Enterprise plans, and that a PostHog engineer has to run it. If most of your customers are in the EU, choose EU. Ours is on US Cloud.
Use one project for the marketing site and the app. The same page strongly recommends it, so that one person can be followed from a first website visit to product use, and the Free plan includes one project.
Step 3: know the project token from a personal API key
PostHog has two kinds of key.
- The project token. PostHog's privacy page says it starts with
phc_and that it is fine for it to be public: it can send events and has no access to your private data. This is the value that goes in the browser, and it is in your project settings. - A personal API key. The page on personal API keys says one can give full access to your account, like signing in with your email and password, and that it must be kept private and never used in the frontend. The privacy page gives its prefix as
phx_.
Nothing in this guide needs a personal API key. Our code reads the project token under the name NEXT_PUBLIC_POSTHOG_KEY. PostHog's Next.js page now calls the same value NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN. The name is yours to choose, as long as it starts with NEXT_PUBLIC_:
# 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
Step 4: install it in a Next.js App Router app
pnpm add posthog-js
PostHog's Next.js page starts the SDK in a file named instrumentation-client.ts at the root of the app, a file convention Next.js added in version 15.3. It is the shortest install, and the one to use if step 1 came back all no.
Ours is wired through a provider component instead, so that analytics is optional and every setting sits in one module a test can read. That module decides whether PostHog is configured. Everything else asks it and never reads the variables itself:
// 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 };
}
The root layout calls it on the server and passes the result down as a prop:
// 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>
);
}
The prop is there because of a Next.js detail. The module reads its variables through a lookup by name, which keeps it easy to unit test. The Next.js environment variables page says lookups like that are not inlined into the browser bundle, so the same call made in the browser would come back empty.
With no token set, the provider renders the page and does nothing else, so local development, CI and preview builds send no events.
Step 5: choose the privacy posture
This is our configuration, as the worked example. It has three layers.
Store nothing in the browser
PostHog's persistence page lists the choices. The default keeps state in local storage and a cookie that lasts 365 days. With persistence: 'memory', data lives only for the duration of the page view. We use memory: no PostHog cookie, and nothing in local or session storage.
Send a fixed list of properties
The SDK's before_send option is a function that runs on every event just before it leaves the browser. PostHog's data collection page describes it as the final chance to modify an event, and its example removes the current URL. Ours goes further: it keeps a property only when its name is on a list and its value is a number, a boolean, or a short string with none of ?, #, =, @, &, % or whitespace in it. Nothing is rewritten: a property is sent as it was or dropped.
The list holds the path, the host, the referring domain, device basics, the five campaign tags and the ids PostHog needs. The full page URL, the referrer and all error text are absent, so they are never sent:
// 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;
}
PostHog's usage page carries a warning about this hook: its core features may need unmodified events, and it recommends changing only your own custom events. We filter every event anyway and accept the costs listed below.
Switch every other feature off in code
Several SDK features take their setting from the PostHog project when the code says nothing. The configuration reference says automatic exception capture, heatmaps and performance capture each fall back to your project settings when left undefined. A toggle in a dashboard would then change what your site collects with no code review, so each one is set explicitly:
// 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;
Two of those lines do more than their names suggest. The configuration reference says advanced_disable_flags stops the request to PostHog's flags endpoint, which is where the SDK learns whether session recording, surveys and the toolbar are enabled. It is also a request that does not pass through before_send, which is the reason ours is off. And the SDK reference says disable_external_dependency_loading prevents PostHog from requesting the external scripts that session replay and surveys need. A feature someone enables in the project then has no code on our pages to run.
The provider puts the three layers together. It also turns the automatic page view off and sends its own, with the path only:
// 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}
</>
);
}
A unit test pins the behavior. A fake checkout id, a fake sign-in token and an email address are the inputs that must not survive:
// 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);
});
});
What this posture costs
- A visitor is counted again after a full page reload, because nothing is stored to recognize them.
- PostHog holds no error messages or stack traces for the marketing site. Our top-level error boundary still calls PostHog when the whole app fails, and the filter drops the message and the stack from that event. By the code, what is left is that an error happened and on which path. We have not had one in production to confirm it.
- There is no session replay, and no feature flags, surveys or experiments on the marketing site.
- A campaign value with a space or a percent sign in it is dropped, so use hyphens in campaign names.
- A new property is dropped until someone adds it to the list. That applies to the properties of your own custom events too.
For the first cost PostHog documents another route. Its cookieless tracking tutorial describes a mode that stores nothing in the browser and counts visitors with a hash computed on PostHog's servers from the IP address, the user agent, the hostname and a daily salt. It has to be enabled in the project's settings first, and one of its listed limits is that location data is no longer added to events. We have not turned it on.
What you can loosen, and when
- No sensitive URLs and no personal data on any page. Use PostHog's documented install as it is. If you keep its default storage, read its GDPR page first: it says a site that uses PostHog with cookies for logged-out visitors should show a cookie banner.
- Session replay. Turn it on only after you can list the pages that show personal data. The replay privacy page documents how to mask text and block elements.
- Automatic exception capture. Reasonable once you know your error messages never contain what a user typed.
Loosen one thing at a time, in a reviewed change, and rerun the check in step 8 afterward.
Step 6: discard IP addresses and decide about location
Browser code cannot control one thing, the visitor's IP address. It arrives with the network request, and PostHog's servers decide what to keep. We found this during the check in step 8: our events had the visitor's IP address stored on each one.
The fix is a project setting. PostHog's data storage page names it: open the project's settings, find IP data capture configuration, and enable the Discard client IP data toggle. We turned it on the same day. PostHog's privacy controls page says EU organizations default to discarding IP addresses and US organizations set the default themselves, so check yours either way.
The data storage page is exact about what the toggle does not do. Location enrichment can still use the IP address before it is discarded. PostHog's person properties page says this GeoIP step is on by default for all projects, and lists what it adds to events: country, city, subdivision, postal code, an approximate latitude and longitude, and a time zone.
So discarding the IP address leaves a second decision: keep the derived location, or turn that enrichment off. The data storage page lists disabling it among the things its transformations can do. Decide which, and write the answer into your privacy policy either way.
We turned it off. In the project's Transformations list, GeoIP is paused, and a page view sent afterward arrived with no IP address, no city, no postal code and no coordinates. The cost is that we cannot break visitors down by country. A founder who needs that can keep GeoIP on and say so in the privacy policy.
Step 7: pass the key through the build and the host
The Next.js environment variables page says a NEXT_PUBLIC_ value is inlined into the JavaScript bundle at build time, and that the app stops responding to changes in it once built. So the token has to exist when the image is built.
A Docker build adds one more requirement. Railway's Dockerfile page says a variable is available during the build only if the Dockerfile declares it with ARG. Ours declares both PostHog values in the build stage:
# 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}
On the host, our infrastructure file writes the ingestion address in the file and marks the token as a value that stays on Railway. Both are set for production only, so staging traffic never lands in the real project. The Railway guide covers that file. Set the token, then redeploy.
Step 8: verify it yourself
This is the check we ran on the live site on October 12.
- Open a private browser window and load a page of your site with a fake id and a campaign tag added to the address, such as
/pricing?session_id=test_123&utm_source=privacycheck. - Open the browser's developer console and run the snippet below. No cookie name should start with
ph_, and neither storage list should hold a PostHog entry. Cookies set by other tools you run will still appear. - Click through to a second page, then close the tab.
- In PostHog, open the Activity tab, which lists captured events and lets you expand each one to its properties. Open the page view. The path and
utm_sourceshould be there. The current URL, the referrer and your fake id should appear nowhere. - After step 6, confirm that no IP address is stored on the event, and read the location properties so you know what is being kept.
// 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),
});
Our result: the page left no PostHog cookie and nothing in local or session storage. The event arrived in PostHog with the path and the campaign tag, and without the URL, the referrer or the fake id. The first page view of a visit is captured, and so are navigations inside the site and page leaves.
Step 9: identify signed-in users, only where you decided to
Until someone signs in, PostHog gives the browser an anonymous id. Calling identify ties that browser's events to a real account. Do it in the app, where people have accounts, and leave marketing visitors anonymous. Our marketing site never calls it.
PostHog's Next.js page is specific about how:
- Use a stable id from your auth system as the distinct id. Send the email and name as person properties.
- Never use a shared value such as "anonymous" or "user". It merges many people into one person.
- Call
posthog.reset()on sign-out, so the next person on that browser does not inherit the last one's identity.
Our customer app identifies by the Clerk user id. This is that component, trimmed, with our analytics wrapper replaced by the two SDK calls it makes. That app has its own PostHog configuration, which is separate from the one in this guide and has not been through the same review:
// 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 filter from step 5 applies to people as well as events. Left unchanged, it would send the user id and drop the email and the name, because neither is on the list. Adding a person property to the list is a decision to make on purpose.
Step 10: the first events and a first funnel
We have not run this step on real data, so read it as PostHog's advice and our plan. PostHog's best practices page opens with the point: before anything else, track the events that show whether the product is working, such as sign-ups, subscriptions and purchases. These are the five we would start with, in the order a customer meets them:
- Sign up. An account now exists. PostHog's example name is
user_signed_up. - Activation. The first time a new user gets what they came for. You define it.
- Key action. The thing a customer does every time they use the product. Retention is measured on this event.
- Checkout started. Someone clicked the button that leads to payment.
- Purchase. Money arrived.
Rules for the names, from the same page: lowercase, snake case, and fixed strings in your code. Put what varies in a property, never in the name, and remember that under the allow-list a new property has to be added before it is sent.
The best practices page says backend analytics are more reliable than frontend analytics, so sign-up and purchase belong on the server. Purchase belongs in the payment webhook from the Stripe guide, the only code that knows money arrived.
Our code has one product event today. The API captures organization_created when the Clerk webhook reports a new organization, which is this product's sign-up. Event names live in one map, and the function takes a narrow tracker interface, so a test passes a fake:
// 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,
});
}
A funnel shows how many people who start a flow finish it, and where the rest stop. PostHog's funnels page gives the steps:
- Click Product Analytics in the left sidebar.
- Click the + New Insights tab.
- Select Funnel in the dropdown.
- Add the steps in order: sign up, activation, checkout started, purchase.
Steps are sequential by default, which the page defines as step B happening after step A with other events allowed in between. Conversion can be shown relative to the first step or to the previous one, and the second view shows which step loses the most people. The page advises starting with the simplest flow and no optional steps.
Step 11: put it in your privacy policy
PostHog's GDPR page says to tell people which types of personal data are processed and which tools process them, and to name PostHog as a tool if you use PostHog Cloud. It also counts location information among the things that can be personal data. This list is a starting point, and it is not legal advice.
- That you use PostHog, and which region holds the data.
- What each event contains. With the list in step 5 you can state it exactly.
- Whether anything is stored in the visitor's browser.
- Whether IP addresses are stored, and whether location is derived from them and at what precision.
- Whether signed-in users are identified, and with which details.
- How to opt out. The data collection page documents
posthog.opt_out_capturing(), which stops all capture for that browser.
Change the policy in the same pull request as any change to step 5 or step 6.
Where it goes wrong
- A token that is set on the host but empty in the browser. The value was missing when the image was built. See step 7.
- A Content Security Policy. PostHog's Next.js page warns that a policy which blocks PostHog's domains fails silently: the install looks complete and no events arrive.
- Your own visits. The best practices page says a product with few users can inflate its own metrics. Filter out your team first.
Hand this to your agent
This guide is also written out for a coding agent, as one Markdown file. It has the checks to run in your repository first, each step with its commands and code, the environment variables by name, a check after every step, and the points where the agent has to stop and ask you. Get the agent file and give your agent the file or its address.
The file has the agent ask what your URLs and pages can contain before it writes any PostHog code, and it defaults to the allow-list. Creating the project, choosing the region, every key, the IP and location settings, and any decision to turn a capture feature on stay with you.
Next
See adding sign-in with Clerk, taking payments with Stripe and deploying to Railway.
Sources
- Pricing, PostHog
- Next.js, PostHog Docs
- JavaScript web configuration, PostHog Docs
- PostHogConfig, PostHog SDK reference
- JavaScript web persistence and cookies, PostHog Docs
- JavaScript web usage, PostHog Docs
- Events, PostHog Docs
- Privacy compliance, PostHog Docs
- Controlling data collection, PostHog Docs
- Controlling data storage, PostHog Docs
- PostHog and GDPR compliance, PostHog Docs
- Privacy controls, PostHog Docs
- Person properties, PostHog Docs
- Session replay privacy controls, PostHog Docs
- How to do cookieless tracking with PostHog, PostHog
- API overview, PostHog Docs
- Personal API keys, PostHog Docs
- Projects, PostHog Docs
- Identify users, PostHog Docs
- Activity tab, PostHog Docs
- Product analytics best practices, PostHog Docs
- Funnels, PostHog Docs
- Environment variables, Next.js Docs
- instrumentation-client.js, Next.js Docs
- Dockerfiles, Railway Docs
Want this already wired together?
This stack is packaged as production kits you can buy and build on. If you would rather have us build it with you, book a call.
