From 2ce1d07bf426c5164e0e5332214d2a48f09dc95f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Ristovi=C4=87?= Date: Tue, 1 Sep 2026 21:01:12 +0200 Subject: [PATCH 1/6] feat(feature-flags): add wizard feature-flags install skill --- .../skills/feature-flags-setup/config.yaml | 25 ++ .../skills/feature-flags-setup/description.md | 243 ++++++++++++++++++ 2 files changed, 268 insertions(+) create mode 100644 context/skills/feature-flags-setup/config.yaml create mode 100644 context/skills/feature-flags-setup/description.md diff --git a/context/skills/feature-flags-setup/config.yaml b/context/skills/feature-flags-setup/config.yaml new file mode 100644 index 00000000..03fc9aae --- /dev/null +++ b/context/skills/feature-flags-setup/config.yaml @@ -0,0 +1,25 @@ +# Feature flags — expert install for Next.js App Router. +# One stack, one pattern (Edwin/Vincent SuperDay constraint): server-side +# evaluateFlags() bootstrapped into the client, one real flag, one gated UI path. +# Distinct from wizard audit feature-flags (read-only, post-hoc) and from the +# docs-only `feature-flags` group (per-platform references, no workflow). +type: skill +template: description.md +category: feature-flags +description: Add PostHog feature flags to a Next.js App Router app the expert way. Evaluates flags server-side with posthog-node.evaluateFlags(), bootstraps the values into the client to avoid flicker and a duplicate /flags fetch, creates one real boolean flag, gates one UI path (after a single user confirm), and configures CI so forgotten test environments don't poll the /flags endpoint. +tags: [feature-flags, nextjs] +cli: + role: command + command: feature-flags +shared_docs: + - https://posthog.com/docs/feature-flags/start-here.md + - https://posthog.com/docs/feature-flags/bootstrapping.md + - https://posthog.com/docs/feature-flags/cutting-costs.md + - https://posthog.com/docs/feature-flags/adding-feature-flag-code.md +variants: + - id: all + display_name: Next.js App Router + tags: [nextjs] + docs_urls: + - https://posthog.com/docs/libraries/next-js.md + - https://posthog.com/docs/libraries/js/config.md diff --git a/context/skills/feature-flags-setup/description.md b/context/skills/feature-flags-setup/description.md new file mode 100644 index 00000000..746afa9f --- /dev/null +++ b/context/skills/feature-flags-setup/description.md @@ -0,0 +1,243 @@ +# Add PostHog feature flags (Next.js App Router) + +Use this skill to give a Next.js App Router app a **correct, cheap** feature-flag install: server-side evaluation bootstrapped into the client, one real flag in the user's PostHog project, one gated UI path, and CI configured so forgotten test environments don't poll `/flags`. + +This is **not** `wizard audit feature-flags` (read-only, after the fact) and **not** the default `wizard` install (product analytics; its prompt explicitly excludes the feature-flags category). This is the missing expert first hour. + +## Scope — one stack, one pattern + +**Supported: Next.js App Router only** (`app/` directory, `next` in `package.json`). + +If the project is Pages Router, a different framework, or a backend-only package, **stop**: emit `[ABORT] unsupported stack — currently Next.js App Router only` on its own line and do nothing else. Do not invent a second pattern. Do not "also support" middleware-only, local evaluation, or `@posthog/next` (pre-release). + +**The pattern (do this, nothing else):** + +1. Evaluate flags **once per request** on the server with `posthog-node`'s `evaluateFlags()`. +2. Pass those values + the same `distinct_id` into the client via `bootstrap` on `PostHogProvider`. +3. Gate UI with `useFeatureFlagEnabled` from `@posthog/react`. +4. Create **one** boolean flag at 100% rollout and gate **one** additive UI path after the user confirms. + +This avoids flicker (the client has values on first paint) and a duplicate `/flags` request on init. It also avoids local evaluation, whose default 30s poll costs an idle server ~864k requests/month (see `cutting-costs.md`). + +## Abort cases + +If anything blocks the run, **always** emit exactly one `[ABORT] ` line and stop. The wizard catches `[ABORT]` and terminates the run; don't try to exit yourself. Use one of: + +- `[ABORT] unsupported stack — currently Next.js App Router only` — no `app/` directory, or `next` is not a dependency. +- `[ABORT] could not locate a UI surface to gate` — exhaustive search found no page or component safe to add an additive, flag-gated element to. +- `[ABORT] no posthog project credentials` — no `phc_…` token in env and no PostHog MCP available to fetch one. +- `[ABORT] ` — anything else that blocks (unreadable project, MCP flag-create failed after retry). Keep it short. + +## Tools + +{{> mcp-tool-calling}} + +Wizard tools (when running inside the wizard): + +- `mcp__wizard-tools__wizard_ask` — the **only** way to ask the user which UI path to gate. Call it **exactly once**. Do not ask via chat. +- `mcp__wizard-tools__check_env_keys` / `mcp__wizard-tools__set_env_values` — env keys. Never hardcode the project token. + +PostHog MCP (via `exec`): `create-feature-flag`, `feature-flag-get-definition-by-key`, `projects-get`, `execute-sql` (to confirm `$feature_flag_called`). Always `info` before `call`. + +## Instructions + +Follow these steps IN ORDER. Emit `[STATUS] ` at the start of each step. + +### STEP 1: Confirm Next.js App Router + +Look for `next` in `package.json` **and** an `app/` directory (or `src/app/`). Lockfile decides the package manager (`pnpm-lock.yaml`, `package-lock.json`, `yarn.lock`, `bun.lockb`). + +If this is not Next.js App Router, apply the unsupported-stack abort. + +Record: package manager, whether `src/` is used, whether PostHog is already initialized (`posthog.init`, `PostHogProvider`, `instrumentation-client`, `posthog-js` / `posthog-node` in dependencies). + +### STEP 2: Credentials + +- If `.env` / `.env.local` already has a `phc_…` token and a host, reuse those keys (don't rename working names). +- Otherwise use `projects-get` to fetch `api_token`. If several projects come back, pick the one the wizard session is already authenticated to; if that's unclear, abort with `no posthog project credentials` rather than guessing. +- Host: `https://us.i.posthog.com` (US) or `https://eu.i.posthog.com` (EU). Match the project's region from `projects-get`. +- Write `NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN` and `NEXT_PUBLIC_POSTHOG_HOST` via `set_env_values` if missing. Never hardcode. + +### STEP 3: Install packages + +Install, with the project's package manager: + +- `posthog-js` — client +- `posthog-node` — server evaluation +- `@posthog/react` — `useFeatureFlagEnabled` / `PostHogProvider` + +Do not install `@posthog/next` (pre-release). Do not install extra OTel or analytics packages. + +If they're already present, leave the versions alone unless they're so old that `evaluateFlags` doesn't exist — then bump `posthog-node` only. + +### STEP 4: Server client + `evaluateFlags` + +Add a small server helper (e.g. `lib/posthog-server.ts` or `app/posthog.ts` — match the project's folder style): + +```ts +import { PostHog } from 'posthog-node' + +export function PostHogServer() { + return new PostHog(process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN!, { + host: process.env.NEXT_PUBLIC_POSTHOG_HOST, + flushAt: 1, + flushInterval: 0, + }) +} +``` + +`flushAt: 1` / `flushInterval: 0` is required in Next.js server functions — they freeze before a batched flush lands. Always `await client.shutdown()` after evaluating. + +**Use `evaluateFlags()`, not the deprecated `getFeatureFlag` / `isFeatureEnabled` / `getAllFlags`.** One `evaluateFlags(distinctId)` call is one `/flags` request; then read with `flags.isEnabled(key)` / `flags.getFlag(key)`. Calling the old methods once each is the billing footgun this program exists to prevent. + +```ts +const client = PostHogServer() +const flags = await client.evaluateFlags(distinctId) +const enabled = flags.isEnabled(flagKey) === true +await client.shutdown() +// Bootstrap at least the flag we created. If the snapshot exposes an +// enumerable map of all evaluated flags, pass that instead so the client +// is fully seeded — one /flags round-trip, no client refetch on init. +const featureFlags = { [flagKey]: enabled } +``` + +If you can only bootstrap the one flag, say so in the report (hack). Never call `getFeatureFlag` / `isFeatureEnabled` / `getAllFlags` — those are deprecated and each one is its own `/flags` request. + +### STEP 5: Distinct ID that matches on server and client + +Percentage rollout is deterministic per `distinct_id`. Server and client **must** use the same one. + +- **If the app already identifies users** (`posthog.identify`, next-auth session id, etc.): use that stable id on the server and bootstrap `distinctID` + `isIdentifiedID: true`. +- **If there is no auth** (typical of wizard-workbench apps): set a cookie (e.g. `ph_distinct_id`) in the root layout or a tiny helper, reuse it on both sides, bootstrap `distinctID` with `isIdentifiedID: false`. **This is a hack.** Call it out in the report. Do not invent `identify('anonymous')`. + +Do not introduce local evaluation. + +### STEP 6: Client provider with bootstrap + +Add a client provider (e.g. `app/providers.tsx`) and wrap `{children}` from the root layout. + +The root layout (a Server Component) evaluates flags, then passes them in: + +```tsx +'use client' +import { PostHogProvider } from 'posthog-js/react' + +export function PHProvider({ + children, + bootstrap, +}: { + children: React.ReactNode + bootstrap: { + distinctID: string + isIdentifiedID?: boolean + featureFlags: Record + } +}) { + const isTestEnv = process.env.NODE_ENV === 'test' || process.env.CI === 'true' + return ( + + {children} + + ) +} +``` + +`advanced_disable_feature_flags: true` in test/CI is the cutting-costs default: CI pipelines silently accumulate `/flags` requests otherwise. + +**If `instrumentation-client.ts` (or `.js`) already inits `posthog-js`:** relocate that init into this provider so bootstrap can be passed per request. Keep the existing `api_host` / `defaults` / other options — this is a move, not a rewrite of analytics behavior. Don't leave both inits in place (double-init is worse than relocating). + +**If there is no existing client init:** the provider is the only client init. Do not also add `instrumentation-client.ts`. + +### STEP 7: Create one real flag + +Search existing flags (`feature-flag-get-definition-by-key` or list) for a key matching the feature you plan to gate. Reuse it if it's a boolean flag. Otherwise create one: + +- key: kebab-case, descriptive (`new-todo-empty-state`, `show-about-banner`) +- type: **boolean** (not multivariate — that's a Learn-card concept, not this demo) +- active: true +- rollout: **100%** (deterministic. Targeting/phased rollout are taught, not made flaky here) +- name: one sentence saying the wizard created it and which UI path it gates + +Use `create-feature-flag` via `exec`. If create fails, retry once after `info`; then abort with a specific reason. + +### STEP 8: Confirm the gate target — exactly one `wizard_ask` + +Flags break real production UI. Ask **once**, then gate only what they picked. + +1. Scan pages/components for **additive** surfaces (a banner, an extra card, a "new" empty-state illustration). Prefer new elements over wrapping existing critical logic. +2. **Never** propose gating auth, checkout, payments, data-mutation handlers, or middleware that could 404 a route. +3. Call `mcp__wizard-tools__wizard_ask` **exactly once**: + - `subject`: `"gate-target"` + - one `kind: "select"` question + - `prompt`: explain you're about to gate one UI path with the flag you created, flag-off = current behavior, and they should pick a low-risk additive target. + - `options`: the **recommended** additive target first (label includes "recommended"), then 1–2 alternatives, then a last option `{ label: "Skip gating — install only", value: "skip" }`. +4. If `wizard_ask` errors (CI / headless): do **not** fail. Gate the recommended additive target and record "auto-picked (no TTY)" in the report. +5. If they pick `skip`: still leave the install + flag in place; write the report saying no UI was gated. + +Do not ask any other question. Credentials come from MCP/env, not from the user. + +### STEP 9: Gate the chosen path + +Additive only. Flag off = current behavior. + +```tsx +'use client' +import { useFeatureFlagEnabled } from '@posthog/react' + +export function FlaggedBanner() { + const enabled = useFeatureFlagEnabled('', false) + if (!enabled) return null + return +} +``` + +Pass `false` as the default so the type is `boolean` and the banner stays hidden while flags load (no flicker of the new element). + +Do not restructure the file. Read it immediately before editing it. + +### STEP 10: Verify + +1. Typecheck / lint the files you touched (`tsc --noEmit` or the project's `build` if that's the only check). Fix errors you introduced. +2. From a short server-side snippet or the helper you added: `evaluateFlags(distinctId)` → `flags.isEnabled(flagKey)` should be `true` at 100% rollout. `await client.shutdown()`. +3. Query `$feature_flag_called` via `execute-sql` for that flag key in the last 15 minutes. If the event hasn't landed yet, say so in the report (ingestion can lag ~1 minute) — that's a warning, not an abort. + +### STEP 11: Report + +Write `posthog-feature-flags-report.md` at the project root covering: + +- Stack detected, pattern used (server `evaluateFlags` → client bootstrap) +- Packages added, files changed +- Flag key + PostHog URL +- Which UI path was gated (or skipped) and why it was additive +- Bill-aware defaults applied (`evaluateFlags` once per request, CI `advanced_disable_feature_flags`, no local evaluation) +- **Hacks acknowledged** — cookie distinct_id, relocated `instrumentation-client` init, bootstrap of one flag rather than the full snapshot, auto-picked gate target in CI, anything else you did that isn't the general case +- How to demo the kill-switch: disable the flag in PostHog → reload → gated UI disappears +- What this did *not* do (local evaluation, experiments, multivariate, other frameworks) + +## Key principles + +- **One stack, one pattern.** Next.js App Router + server eval + bootstrap. Everything else aborts. +- **`evaluateFlags` once per request.** Never the deprecated per-call methods. +- **Same distinct_id on both sides.** Otherwise bootstrap lies. +- **Additive gating.** Flag off = current behavior. Never auth/checkout/mutations. +- **One `wizard_ask`.** The gate target. Nothing else. +- **Env, never hardcode.** +- **Don't commit.** The operator reviews the diff. +- **Ack the hacks** in the report instead of generalizing the skill to hide them. + +## Reference files + +{references} + +`libraries/next-js.md` is the Next.js SDK source of truth (App Router server client, env names). `bootstrapping.md` is the source of truth for `bootstrap.featureFlags` + matching `distinctID`. `cutting-costs.md` is why we disable flags in CI and refuse local evaluation as the default. `adding-feature-flag-code.md` is the source of truth for `evaluateFlags` / `useFeatureFlagEnabled`. `start-here.md` is the product overview. + +## Framework guidelines + +{commandments} From 7f47cc29fe297e50f19f1a6bcc6c77f46a3e440f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Ristovi=C4=87?= Date: Tue, 1 Sep 2026 21:11:46 +0200 Subject: [PATCH 2/6] feat(feature-flags): refactor feature-flags skill --- README.md | 4 +- .../skills/feature-flags-setup/config.yaml | 13 +- .../skills/feature-flags-setup/description.md | 255 ++++++++++-------- 3 files changed, 152 insertions(+), 120 deletions(-) diff --git a/README.md b/README.md index fbda25f4..7992c4b7 100644 --- a/README.md +++ b/README.md @@ -140,8 +140,8 @@ review to their owning team instead. Ownership is by directory. Skills not listed above (`audit`, `audit-*`, `cost-cutting`, `creating-product-tours`, `error-tracking`, `events-audit`, -`feature-flags`, `llm-analytics`, `logs`, `migrate`, `omnibus`, -`posthog-best-practices`, `quack`, `tools-and-features`) fall through the +`feature-flags`, `feature-flags-setup`, `llm-analytics`, `logs`, `migrate`, +`omnibus`, `posthog-best-practices`, `quack`, `tools-and-features`) fall through the default and are owned by `team-wizard-docs`. Today CODEOWNERS only auto-requests review — approval is not a merge gate. diff --git a/context/skills/feature-flags-setup/config.yaml b/context/skills/feature-flags-setup/config.yaml index 03fc9aae..a3f8f307 100644 --- a/context/skills/feature-flags-setup/config.yaml +++ b/context/skills/feature-flags-setup/config.yaml @@ -1,13 +1,10 @@ -# Feature flags — expert install for Next.js App Router. -# One stack, one pattern (Edwin/Vincent SuperDay constraint): server-side -# evaluateFlags() bootstrapped into the client, one real flag, one gated UI path. -# Distinct from wizard audit feature-flags (read-only, post-hoc) and from the -# docs-only `feature-flags` group (per-platform references, no workflow). +# Feature flags install — Next.js App Router. Distinct from wizard audit +# feature-flags (read-only) and from the docs-only `feature-flags` group. type: skill template: description.md category: feature-flags -description: Add PostHog feature flags to a Next.js App Router app the expert way. Evaluates flags server-side with posthog-node.evaluateFlags(), bootstraps the values into the client to avoid flicker and a duplicate /flags fetch, creates one real boolean flag, gates one UI path (after a single user confirm), and configures CI so forgotten test environments don't poll the /flags endpoint. -tags: [feature-flags, nextjs] +description: Add PostHog feature flags to a Next.js App Router app. Evaluates flags server-side with posthog-node.evaluateFlags(), bootstraps the values into the client to avoid flicker and a duplicate /flags fetch, creates one boolean flag, gates one UI path after a single confirm, and disables /flags polling in CI. +tags: [feature-flags, javascript, react] cli: role: command command: feature-flags @@ -19,7 +16,7 @@ shared_docs: variants: - id: all display_name: Next.js App Router - tags: [nextjs] + tags: [] docs_urls: - https://posthog.com/docs/libraries/next-js.md - https://posthog.com/docs/libraries/js/config.md diff --git a/context/skills/feature-flags-setup/description.md b/context/skills/feature-flags-setup/description.md index 746afa9f..2958d50a 100644 --- a/context/skills/feature-flags-setup/description.md +++ b/context/skills/feature-flags-setup/description.md @@ -1,84 +1,85 @@ -# Add PostHog feature flags (Next.js App Router) +# Add PostHog feature flags -Use this skill to give a Next.js App Router app a **correct, cheap** feature-flag install: server-side evaluation bootstrapped into the client, one real flag in the user's PostHog project, one gated UI path, and CI configured so forgotten test environments don't poll `/flags`. +Use this skill to add PostHog feature flags to a **Next.js App Router** app. Once installed, flags are evaluated once per request on the server with `posthog-node`'s `evaluateFlags()`, those values are bootstrapped into the client so the first paint has no flicker and no extra `/flags` fetch, one real boolean flag exists in the user's project, and one UI path is gated behind it. -This is **not** `wizard audit feature-flags` (read-only, after the fact) and **not** the default `wizard` install (product analytics; its prompt explicitly excludes the feature-flags category). This is the missing expert first hour. +This is **not** `wizard audit feature-flags` (read-only, after the fact). This is **not** the default `wizard` install (product analytics). This skill is the flags install: instrument, create, gate. -## Scope — one stack, one pattern +## Scope and guardrails -**Supported: Next.js App Router only** (`app/` directory, `next` in `package.json`). +- **Next.js App Router only.** Require `next` in `package.json` and an `app/` directory (or `src/app/`). If the project is Pages Router, a different framework, or backend-only, **stop**: emit `[ABORT] unsupported stack for feature flags` on its own line and do nothing else. Do not invent a second pattern. Do not add local evaluation. Do not install `@posthog/next`. +- **One evaluation per request.** Call `evaluateFlags(distinctId)` once, then read with `flags.isEnabled(key)` / `flags.getFlag(key)`. Do not call the deprecated `getFeatureFlag`, `isFeatureEnabled`, or `getAllFlags` — each of those is its own `/flags` request. +- **Same distinct_id on server and client.** Percentage rollout is deterministic per id. If they differ, bootstrap lies. +- **Additive gating only.** Flag off = current behavior. Never gate auth, checkout, payments, data-mutation handlers, or middleware that can 404 a route. +- **Minimal, additive changes.** Match the project's folder style. Read a file immediately before editing it. Do not restructure unrelated code. Do not commit. -If the project is Pages Router, a different framework, or a backend-only package, **stop**: emit `[ABORT] unsupported stack — currently Next.js App Router only` on its own line and do nothing else. Do not invent a second pattern. Do not "also support" middleware-only, local evaluation, or `@posthog/next` (pre-release). +### Abort cases -**The pattern (do this, nothing else):** +If anything blocks the run, **always** emit exactly one `[ABORT] ` line and stop — never halt, finish, or error out silently. The wizard catches `[ABORT]` and terminates the run for you; don't try to exit yourself. A silent stop is recorded as a failed run with no reason, which can't be acted on, so every dead end must carry a reason. Use one of: -1. Evaluate flags **once per request** on the server with `posthog-node`'s `evaluateFlags()`. -2. Pass those values + the same `distinct_id` into the client via `bootstrap` on `PostHogProvider`. -3. Gate UI with `useFeatureFlagEnabled` from `@posthog/react`. -4. Create **one** boolean flag at 100% rollout and gate **one** additive UI path after the user confirms. - -This avoids flicker (the client has values on first paint) and a duplicate `/flags` request on init. It also avoids local evaluation, whose default 30s poll costs an idle server ~864k requests/month (see `cutting-costs.md`). - -## Abort cases - -If anything blocks the run, **always** emit exactly one `[ABORT] ` line and stop. The wizard catches `[ABORT]` and terminates the run; don't try to exit yourself. Use one of: - -- `[ABORT] unsupported stack — currently Next.js App Router only` — no `app/` directory, or `next` is not a dependency. -- `[ABORT] could not locate a UI surface to gate` — exhaustive search found no page or component safe to add an additive, flag-gated element to. +- `[ABORT] unsupported stack for feature flags` — no `app/` directory, or `next` is not a dependency. +- `[ABORT] could not locate a UI surface to gate` — exhaustive search found no page or component that is safe to add an additive, flag-gated element to. - `[ABORT] no posthog project credentials` — no `phc_…` token in env and no PostHog MCP available to fetch one. -- `[ABORT] ` — anything else that blocks (unreadable project, MCP flag-create failed after retry). Keep it short. +- `[ABORT] ` — anything else that blocks the run (e.g. no readable project, or flag create failed after retry). Keep it short and specific so it's useful when aggregated across runs. -## Tools +## Available tools {{> mcp-tool-calling}} Wizard tools (when running inside the wizard): -- `mcp__wizard-tools__wizard_ask` — the **only** way to ask the user which UI path to gate. Call it **exactly once**. Do not ask via chat. +- `mcp__wizard-tools__wizard_ask` — the only way to ask which UI path to gate. Call it **exactly once**. Do not ask via chat. - `mcp__wizard-tools__check_env_keys` / `mcp__wizard-tools__set_env_values` — env keys. Never hardcode the project token. -PostHog MCP (via `exec`): `create-feature-flag`, `feature-flag-get-definition-by-key`, `projects-get`, `execute-sql` (to confirm `$feature_flag_called`). Always `info` before `call`. +For PostHog operations (create a flag, look up a flag by key, list projects, query `$feature_flag_called`), go through `exec` as above. Inner tool names move; discover them, don't assume them. ## Instructions -Follow these steps IN ORDER. Emit `[STATUS] ` at the start of each step. +Follow these steps IN ORDER. Emit the `[STATUS]` line named at the start of each step. ### STEP 1: Confirm Next.js App Router -Look for `next` in `package.json` **and** an `app/` directory (or `src/app/`). Lockfile decides the package manager (`pnpm-lock.yaml`, `package-lock.json`, `yarn.lock`, `bun.lockb`). +Emit `[STATUS] Detecting Next.js App Router`. -If this is not Next.js App Router, apply the unsupported-stack abort. +Look for `next` in `package.json` **and** an `app/` directory (or `src/app/`). The lockfile decides the package manager (`pnpm-lock.yaml`, `package-lock.json`, `yarn.lock`, `bun.lockb`). -Record: package manager, whether `src/` is used, whether PostHog is already initialized (`posthog.init`, `PostHogProvider`, `instrumentation-client`, `posthog-js` / `posthog-node` in dependencies). +If this is not Next.js App Router, apply the unsupported-stack abort. In the same message, say where you looked. + +Record: package manager, whether `src/` is used, and whether PostHog is already initialized (`posthog.init`, `PostHogProvider`, `instrumentation-client`, `posthog-js` / `posthog-node` in dependencies). If flags are already wired the way this skill describes (`evaluateFlags` + bootstrap + a gated call site), verify they are correct and skip to STEP 10. ### STEP 2: Credentials -- If `.env` / `.env.local` already has a `phc_…` token and a host, reuse those keys (don't rename working names). -- Otherwise use `projects-get` to fetch `api_token`. If several projects come back, pick the one the wizard session is already authenticated to; if that's unclear, abort with `no posthog project credentials` rather than guessing. -- Host: `https://us.i.posthog.com` (US) or `https://eu.i.posthog.com` (EU). Match the project's region from `projects-get`. +Emit `[STATUS] Resolving PostHog credentials`. + +- If `.env` / `.env.local` already has a `phc_…` token and a host, reuse those key names. Do not rename working names. +- Otherwise fetch the project's `api_token` via PostHog MCP. If several projects come back, use the one the current session is authenticated to. If that is unclear, abort with `no posthog project credentials` rather than guessing. +- Host: `https://us.i.posthog.com` (US) or `https://eu.i.posthog.com` (EU). Match the project's region from the MCP response. - Write `NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN` and `NEXT_PUBLIC_POSTHOG_HOST` via `set_env_values` if missing. Never hardcode. ### STEP 3: Install packages -Install, with the project's package manager: +Emit `[STATUS] Installing PostHog packages`. + +Install with the project's package manager: -- `posthog-js` — client -- `posthog-node` — server evaluation -- `@posthog/react` — `useFeatureFlagEnabled` / `PostHogProvider` +- `posthog-js` +- `posthog-node` +- `@posthog/react` -Do not install `@posthog/next` (pre-release). Do not install extra OTel or analytics packages. +Import **both** `PostHogProvider` and `useFeatureFlagEnabled` from `@posthog/react`. Do not import the provider from `posthog-js/react`. Do not install `@posthog/next`. -If they're already present, leave the versions alone unless they're so old that `evaluateFlags` doesn't exist — then bump `posthog-node` only. +If the packages are already present, leave the versions alone unless `evaluateFlags` is missing from `posthog-node` — then bump `posthog-node` only. -### STEP 4: Server client + `evaluateFlags` +### STEP 4: Server client -Add a small server helper (e.g. `lib/posthog-server.ts` or `app/posthog.ts` — match the project's folder style): +Emit `[STATUS] Adding the server client`. + +Add a small helper, matching the project's folder style (`lib/posthog-server.ts` or `app/posthog.ts`): ```ts import { PostHog } from 'posthog-node' -export function PostHogServer() { - return new PostHog(process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN!, { +export function PostHogServer(token: string) { + return new PostHog(token, { host: process.env.NEXT_PUBLIC_POSTHOG_HOST, flushAt: 1, flushInterval: 0, @@ -86,41 +87,79 @@ export function PostHogServer() { } ``` -`flushAt: 1` / `flushInterval: 0` is required in Next.js server functions — they freeze before a batched flush lands. Always `await client.shutdown()` after evaluating. +`flushAt: 1` / `flushInterval: 0` is required in Next.js server functions — they freeze before a batched flush lands. Always `await client.shutdown()` after evaluating. A missing token is handled in STEP 7 (render children without evaluating); do not throw at import time. + +### STEP 5: Distinct ID + +Emit `[STATUS] Wiring a shared distinct id`. -**Use `evaluateFlags()`, not the deprecated `getFeatureFlag` / `isFeatureEnabled` / `getAllFlags`.** One `evaluateFlags(distinctId)` call is one `/flags` request; then read with `flags.isEnabled(key)` / `flags.getFlag(key)`. Calling the old methods once each is the billing footgun this program exists to prevent. +- **Identified app** (`posthog.identify`, a session user id, etc.): use that stable id on the server and bootstrap `distinctID` with `isIdentifiedID: true`. Do not add the cookie below. +- **Anonymous app**: persist the id in a `ph_distinct_id` cookie. Mint it in `middleware.ts` if missing. Read it in the root layout. Bootstrap `distinctID` with `isIdentifiedID: false`. Do not call `identify()` with a shared literal like `"anonymous"`. If `middleware.ts` already exists, add the cookie logic to the existing handler — do not replace it. ```ts -const client = PostHogServer() -const flags = await client.evaluateFlags(distinctId) -const enabled = flags.isEnabled(flagKey) === true -await client.shutdown() -// Bootstrap at least the flag we created. If the snapshot exposes an -// enumerable map of all evaluated flags, pass that instead so the client -// is fully seeded — one /flags round-trip, no client refetch on init. -const featureFlags = { [flagKey]: enabled } +import { NextResponse } from 'next/server' +import type { NextRequest } from 'next/server' + +export function middleware(request: NextRequest) { + const response = NextResponse.next() + if (!request.cookies.get('ph_distinct_id')) { + response.cookies.set('ph_distinct_id', crypto.randomUUID(), { path: '/' }) + } + return response +} + +export const config = { + matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'], +} ``` -If you can only bootstrap the one flag, say so in the report (hack). Never call `getFeatureFlag` / `isFeatureEnabled` / `getAllFlags` — those are deprecated and each one is its own `/flags` request. +Reading `cookies()` in the root layout opts that tree into dynamic rendering. That is required — flags are per-user. -### STEP 5: Distinct ID that matches on server and client +Do not introduce local evaluation. -Percentage rollout is deterministic per `distinct_id`. Server and client **must** use the same one. +### STEP 6: Create one boolean flag -- **If the app already identifies users** (`posthog.identify`, next-auth session id, etc.): use that stable id on the server and bootstrap `distinctID` + `isIdentifiedID: true`. -- **If there is no auth** (typical of wizard-workbench apps): set a cookie (e.g. `ph_distinct_id`) in the root layout or a tiny helper, reuse it on both sides, bootstrap `distinctID` with `isIdentifiedID: false`. **This is a hack.** Call it out in the report. Do not invent `identify('anonymous')`. +Emit `[STATUS] Creating the feature flag`. -Do not introduce local evaluation. +Search existing flags for a key matching the feature you plan to gate. Reuse it if it is a boolean flag. Otherwise create one: + +- key: kebab-case, descriptive (`new-todo-empty-state`, `show-about-banner`) +- type: boolean, not multivariate +- active: true +- rollout: 100% (deterministic for this install; targeting is out of scope) +- name: one sentence naming the UI path it will gate + +Create via `exec`. If create fails, retry once after `info`; then abort with a specific reason. + +### STEP 7: Client provider with bootstrap + +Emit `[STATUS] Bootstrapping flags into the client`. + +The root layout is a Server Component. If `NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN` is missing, render `{children}` without evaluating or wrapping — boot must still work. Otherwise evaluate once and pass the snapshot into the provider: -### STEP 6: Client provider with bootstrap +```ts +const token = process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN +const cookieStore = await cookies() +const distinctId = + cookieStore.get('ph_distinct_id')?.value ?? crypto.randomUUID() -Add a client provider (e.g. `app/providers.tsx`) and wrap `{children}` from the root layout. +if (!token) { + return {children} +} -The root layout (a Server Component) evaluates flags, then passes them in: +const client = PostHogServer(token) +const flags = await client.evaluateFlags(distinctId) +const enabled = flags.isEnabled(flagKey) +await client.shutdown() +// Bootstrap docs drop false and empty values. Seed only the enabled flag. +const featureFlags = enabled ? { [flagKey]: true } : {} +``` + +Add `app/providers.tsx` (or `src/app/providers.tsx`) and wrap `{children}` from the root layout. Use the `apiKey` + `options` form of `PostHogProvider` (not `client={posthog}`) so bootstrap can be passed per request: ```tsx 'use client' -import { PostHogProvider } from 'posthog-js/react' +import { PostHogProvider } from '@posthog/react' export function PHProvider({ children, @@ -133,10 +172,13 @@ export function PHProvider({ featureFlags: Record } }) { + const token = process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN + if (!token) return children + const isTestEnv = process.env.NODE_ENV === 'test' || process.env.CI === 'true' return ( ', false) if (!enabled) return null - return + return } ``` -Pass `false` as the default so the type is `boolean` and the banner stays hidden while flags load (no flicker of the new element). - -Do not restructure the file. Read it immediately before editing it. +Pass `false` as the default so the type is `boolean` and the new element stays hidden while flags load. Read the target file immediately before editing it. Do not restructure the file. ### STEP 10: Verify -1. Typecheck / lint the files you touched (`tsc --noEmit` or the project's `build` if that's the only check). Fix errors you introduced. -2. From a short server-side snippet or the helper you added: `evaluateFlags(distinctId)` → `flags.isEnabled(flagKey)` should be `true` at 100% rollout. `await client.shutdown()`. -3. Query `$feature_flag_called` via `execute-sql` for that flag key in the last 15 minutes. If the event hasn't landed yet, say so in the report (ingestion can lag ~1 minute) — that's a warning, not an abort. +Emit `[STATUS] Verifying the flag`. + +1. Typecheck / lint the files you touched (`tsc --noEmit`, or the project's `build` if that is the only check). Fix errors you introduced. +2. `evaluateFlags(distinctId)` → `flags.isEnabled(flagKey)` should be `true` at 100% rollout. `await client.shutdown()`. +3. Query `$feature_flag_called` for that flag key in the last 15 minutes via `exec`. If the event has not landed yet, record it in the report as a warning (ingestion can lag about a minute) — not an abort. ### STEP 11: Report -Write `posthog-feature-flags-report.md` at the project root covering: +Emit `[STATUS] Writing the report`. -- Stack detected, pattern used (server `evaluateFlags` → client bootstrap) -- Packages added, files changed -- Flag key + PostHog URL +Write `./posthog-feature-flags-report.md` at the project root covering: + +- Stack detected and the pattern used (server `evaluateFlags` → client bootstrap) +- Packages added and files changed +- Flag key and its PostHog URL - Which UI path was gated (or skipped) and why it was additive -- Bill-aware defaults applied (`evaluateFlags` once per request, CI `advanced_disable_feature_flags`, no local evaluation) -- **Hacks acknowledged** — cookie distinct_id, relocated `instrumentation-client` init, bootstrap of one flag rather than the full snapshot, auto-picked gate target in CI, anything else you did that isn't the general case +- Bill-aware defaults: one `evaluateFlags` per request, CI `advanced_disable_feature_flags`, no local evaluation +- Constraints of this install, named plainly: anonymous `ph_distinct_id` cookie (if used); `instrumentation-client` init relocated (if it was); bootstrap seeds only this flag (`false` is dropped by the client SDK); gate target auto-picked on a non-interactive host (if it was) - How to demo the kill-switch: disable the flag in PostHog → reload → gated UI disappears -- What this did *not* do (local evaluation, experiments, multivariate, other frameworks) +- Out of scope: local evaluation, experiments, multivariate flags, other frameworks + +## Reference files + +{references} + +`libraries/next-js.md` is the source of truth for the Next.js SDK (App Router server client, env names). `bootstrapping.md` is the source of truth for `bootstrap.featureFlags` and matching `distinctID`. `cutting-costs.md` is why flags are disabled in CI and why local evaluation is not the default. `adding-feature-flag-code.md` is the source of truth for `evaluateFlags` and `useFeatureFlagEnabled`. `start-here.md` is the product overview. ## Key principles -- **One stack, one pattern.** Next.js App Router + server eval + bootstrap. Everything else aborts. +- **One stack, one pattern.** Next.js App Router + server `evaluateFlags` + client bootstrap. Everything else aborts. - **`evaluateFlags` once per request.** Never the deprecated per-call methods. - **Same distinct_id on both sides.** Otherwise bootstrap lies. -- **Additive gating.** Flag off = current behavior. Never auth/checkout/mutations. -- **One `wizard_ask`.** The gate target. Nothing else. -- **Env, never hardcode.** +- **Additive gating.** Flag off = current behavior. Never auth, checkout, or mutations. +- **One `wizard_ask`.** The gate target. Skip is first so a stray Enter declines. Nothing else is a question. +- **Env, never hardcode.** A missing token must not crash boot. - **Don't commit.** The operator reviews the diff. -- **Ack the hacks** in the report instead of generalizing the skill to hide them. - -## Reference files - -{references} - -`libraries/next-js.md` is the Next.js SDK source of truth (App Router server client, env names). `bootstrapping.md` is the source of truth for `bootstrap.featureFlags` + matching `distinctID`. `cutting-costs.md` is why we disable flags in CI and refuse local evaluation as the default. `adding-feature-flag-code.md` is the source of truth for `evaluateFlags` / `useFeatureFlagEnabled`. `start-here.md` is the product overview. ## Framework guidelines From 06133892999b9e712d7195791c77b75cfa3e967a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Ristovi=C4=87?= Date: Tue, 1 Sep 2026 22:38:17 +0200 Subject: [PATCH 3/6] feat(feature-flags): set 0% rollout on boolean flag --- .../skills/feature-flags-setup/config.yaml | 2 +- .../skills/feature-flags-setup/description.md | 86 +++++++++++-------- 2 files changed, 50 insertions(+), 38 deletions(-) diff --git a/context/skills/feature-flags-setup/config.yaml b/context/skills/feature-flags-setup/config.yaml index a3f8f307..8dcb7ccb 100644 --- a/context/skills/feature-flags-setup/config.yaml +++ b/context/skills/feature-flags-setup/config.yaml @@ -3,7 +3,7 @@ type: skill template: description.md category: feature-flags -description: Add PostHog feature flags to a Next.js App Router app. Evaluates flags server-side with posthog-node.evaluateFlags(), bootstraps the values into the client to avoid flicker and a duplicate /flags fetch, creates one boolean flag, gates one UI path after a single confirm, and disables /flags polling in CI. +description: Add PostHog feature flags to a Next.js App Router app. Evaluates flags server-side with posthog-node.evaluateFlags(), bootstraps the values into the client to avoid flicker and a duplicate /flags fetch, and disables /flags polling in CI. After one confirm, optionally creates one boolean flag at 0% rollout and gates one additive UI path so production users are unchanged until rollout is raised. tags: [feature-flags, javascript, react] cli: role: command diff --git a/context/skills/feature-flags-setup/description.md b/context/skills/feature-flags-setup/description.md index 2958d50a..f649a763 100644 --- a/context/skills/feature-flags-setup/description.md +++ b/context/skills/feature-flags-setup/description.md @@ -1,8 +1,10 @@ # Add PostHog feature flags -Use this skill to add PostHog feature flags to a **Next.js App Router** app. Once installed, flags are evaluated once per request on the server with `posthog-node`'s `evaluateFlags()`, those values are bootstrapped into the client so the first paint has no flicker and no extra `/flags` fetch, one real boolean flag exists in the user's project, and one UI path is gated behind it. +Use this skill to add PostHog feature flags to a **Next.js App Router** app. Once installed, flags are evaluated once per request on the server with `posthog-node`'s `evaluateFlags()`, those values are bootstrapped into the client so the first paint has no flicker and no extra `/flags` fetch, and `/flags` polling is off in CI. -This is **not** `wizard audit feature-flags` (read-only, after the fact). This is **not** the default `wizard` install (product analytics). This skill is the flags install: instrument, create, gate. +This is a **production-safe install**. It does not turn a new flag on for real users. A kill-switch demo is optional: after they confirm a UI path, create one boolean flag at **0% rollout** and gate that path additively. Flag-off (including 0%) = current behavior. They test by raising rollout to 100% in PostHog, then setting it back to 0%. + +This is **not** `wizard audit feature-flags` (read-only, after the fact). This is **not** the default `wizard` install (product analytics). This skill is the flags install: instrument, then optionally create-and-gate. ## Scope and guardrails @@ -10,6 +12,7 @@ This is **not** `wizard audit feature-flags` (read-only, after the fact). This i - **One evaluation per request.** Call `evaluateFlags(distinctId)` once, then read with `flags.isEnabled(key)` / `flags.getFlag(key)`. Do not call the deprecated `getFeatureFlag`, `isFeatureEnabled`, or `getAllFlags` — each of those is its own `/flags` request. - **Same distinct_id on server and client.** Percentage rollout is deterministic per id. If they differ, bootstrap lies. - **Additive gating only.** Flag off = current behavior. Never gate auth, checkout, payments, data-mutation handlers, or middleware that can 404 a route. +- **Off until they turn it on.** Never create a flag at 100% rollout. Never create a flag before they confirm a gate target. Skip = no new flag in PostHog and no `isEnabled('invented-key')` in layout. Confirm = one boolean flag, **active, 0% rollout**, plus one additive UI path. Production users keep seeing today's UI until someone raises rollout in PostHog. - **Minimal, additive changes.** Match the project's folder style. Read a file immediately before editing it. Do not restructure unrelated code. Do not commit. ### Abort cases @@ -19,7 +22,8 @@ If anything blocks the run, **always** emit exactly one `[ABORT] ` line - `[ABORT] unsupported stack for feature flags` — no `app/` directory, or `next` is not a dependency. - `[ABORT] could not locate a UI surface to gate` — exhaustive search found no page or component that is safe to add an additive, flag-gated element to. - `[ABORT] no posthog project credentials` — no `phc_…` token in env and no PostHog MCP available to fetch one. -- `[ABORT] ` — anything else that blocks the run (e.g. no readable project, or flag create failed after retry). Keep it short and specific so it's useful when aggregated across runs. +- `[ABORT] could not create the feature flag` — they confirmed a gate target, but creating the 0% flag failed after retry (missing `feature_flag:write`, or the create tool errored). +- `[ABORT] ` — anything else that blocks the run (e.g. no readable project). Keep it short and specific so it's useful when aggregated across runs. Do not paper over a failed flag create by writing "create this manually" and continuing. ## Available tools @@ -117,25 +121,47 @@ Reading `cookies()` in the root layout opts that tree into dynamic rendering. Th Do not introduce local evaluation. -### STEP 6: Create one boolean flag +### STEP 6: Confirm the gate target + +Emit `[STATUS] Asking which UI path to gate`. + +Flags change production UI. Ask **once**, then create a flag and gate only if they picked a target. + +1. Scan pages and components for additive surfaces (a banner, an extra card, an empty-state illustration). Prefer a new element over wrapping existing critical logic. +2. Never propose gating auth, checkout, payments, data-mutation handlers, or route-blocking middleware. +3. Call `mcp__wizard-tools__wizard_ask` **exactly once**: + - `subject`: `"gate-target"` + - one `kind: "select"` question + - `prompt`: the SDK install does not change what users see. Optionally add one additive UI path behind a new boolean flag at **0% rollout** (off for everyone, including production, until someone raises rollout in PostHog). Pick a low-risk target or skip. + - Put **skip first** so it is the default highlight — an accidental Enter then declines instead of wrapping UI: `{ label: "Skip gating — install only", value: "skip" }`, then the recommended additive target (label includes "recommended"), then 1–2 alternatives. +4. If `wizard_ask` errors (CI / headless): do not fail. Treat it as the recommended additive target (still 0% rollout) and record in the report that the target was auto-picked because the host was non-interactive. +5. If they pick `skip`: do **not** create a flag. Do **not** call `isEnabled` / `getFlag` on an invented key. Continue to STEP 8 with no `flagKey`. Write the report saying no flag was created and no UI was gated. + +Do not ask any other question. Credentials come from MCP/env, not from the user. + +### STEP 7: Create one boolean flag at 0% Emit `[STATUS] Creating the feature flag`. -Search existing flags for a key matching the feature you plan to gate. Reuse it if it is a boolean flag. Otherwise create one: +If STEP 6 returned `skip`, skip this step (`flagKey` stays unset). + +Otherwise search existing flags for a key matching the feature they confirmed. Reuse it if it is a boolean flag. Otherwise create one: - key: kebab-case, descriptive (`new-todo-empty-state`, `show-about-banner`) - type: boolean, not multivariate - active: true -- rollout: 100% (deterministic for this install; targeting is out of scope) +- rollout: **0%** (release condition group with `rollout_percentage: 0` and no extra property filters). Never 100%. 0% is what makes this safe to merge: production users keep current UI. Manual test is raising that slider to 100% in PostHog, then setting it back. - name: one sentence naming the UI path it will gate -Create via `exec`. If create fails, retry once after `info`; then abort with a specific reason. +Create via `exec`. If create fails, retry once after `info`; then emit `[ABORT] could not create the feature flag` and stop. Do not leave a "create this manually" note and continue. -### STEP 7: Client provider with bootstrap +### STEP 8: Client provider with bootstrap Emit `[STATUS] Bootstrapping flags into the client`. -The root layout is a Server Component. If `NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN` is missing, render `{children}` without evaluating or wrapping — boot must still work. Otherwise evaluate once and pass the snapshot into the provider: +The root layout is a Server Component. If `NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN` is missing, render `{children}` without evaluating or wrapping — boot must still work. Otherwise evaluate once and pass the snapshot into the provider. + +Only read a specific key when STEP 7 created (or reused) one. Do not invent a key to satisfy this snippet. Bootstrap docs drop false and empty values — at 0% rollout `getFlag` is `false`, so `featureFlags` is `{}`. That is correct: the client matches "off." ```ts const token = process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN @@ -149,10 +175,12 @@ if (!token) { const client = PostHogServer(token) const flags = await client.evaluateFlags(distinctId) -const enabled = flags.isEnabled(flagKey) +const featureFlags: Record = {} +if (flagKey) { + const value = flags.getFlag(flagKey) + if (value) featureFlags[flagKey] = value +} await client.shutdown() -// Bootstrap docs drop false and empty values. Seed only the enabled flag. -const featureFlags = enabled ? { [flagKey]: true } : {} ``` Add `app/providers.tsx` (or `src/app/providers.tsx`) and wrap `{children}` from the root layout. Use the `apiKey` + `options` form of `PostHogProvider` (not `client={posthog}`) so bootstrap can be passed per request: @@ -197,29 +225,11 @@ export function PHProvider({ **If there is no existing client init:** the provider is the only client init. Do not add `instrumentation-client.ts`. -### STEP 8: Confirm the gate target - -Emit `[STATUS] Asking which UI path to gate`. - -Flags change production UI. Ask **once**, then gate only what they picked. - -1. Scan pages and components for additive surfaces (a banner, an extra card, an empty-state illustration). Prefer a new element over wrapping existing critical logic. -2. Never propose gating auth, checkout, payments, data-mutation handlers, or route-blocking middleware. -3. Call `mcp__wizard-tools__wizard_ask` **exactly once**: - - `subject`: `"gate-target"` - - one `kind: "select"` question - - `prompt`: you are about to gate one UI path with the flag you created; flag-off equals current behavior; pick a low-risk additive target or skip. - - Put **skip first** so it is the default highlight — an accidental Enter then declines instead of wrapping UI: `{ label: "Skip gating — install only", value: "skip" }`, then the recommended additive target (label includes "recommended"), then 1–2 alternatives. -4. If `wizard_ask` errors (CI / headless): do not fail. Gate the recommended additive target and record in the report that the target was auto-picked because the host was non-interactive. -5. If they pick `skip`: leave the install and the flag in place; write the report saying no UI was gated. - -Do not ask any other question. Credentials come from MCP/env, not from the user. - ### STEP 9: Gate the chosen path Emit `[STATUS] Gating the chosen UI path`. -If STEP 8 returned `skip`, skip this step. Otherwise additive only — flag off = current behavior: +If STEP 6 returned `skip`, skip this step. Otherwise additive only — flag off (including 0% rollout) = current behavior: ```tsx 'use client' @@ -239,8 +249,9 @@ Pass `false` as the default so the type is `boolean` and the new element stays h Emit `[STATUS] Verifying the flag`. 1. Typecheck / lint the files you touched (`tsc --noEmit`, or the project's `build` if that is the only check). Fix errors you introduced. -2. `evaluateFlags(distinctId)` → `flags.isEnabled(flagKey)` should be `true` at 100% rollout. `await client.shutdown()`. -3. Query `$feature_flag_called` for that flag key in the last 15 minutes via `exec`. If the event has not landed yet, record it in the report as a warning (ingestion can lag about a minute) — not an abort. +2. If a flag was created: look it up by key and confirm it exists, is boolean, is active, and is **0% rollout**. Then `evaluateFlags(distinctId)` → `flags.isEnabled(flagKey)` should be **`false`**. That is the production-safe check. `await client.shutdown()`. Do not treat "flag is off" as a failure. +3. If a flag was created, query `$feature_flag_called` for that flag key in the last 15 minutes via `exec`. If the event has not landed yet, record it in the report as a warning (ingestion can lag about a minute; the app may not have been requested yet) — not an abort. +4. If they skipped gating: confirm no new flag was created and layout does not `isEnabled` / `getFlag` a demo key. ### STEP 11: Report @@ -250,11 +261,11 @@ Write `./posthog-feature-flags-report.md` at the project root covering: - Stack detected and the pattern used (server `evaluateFlags` → client bootstrap) - Packages added and files changed -- Flag key and its PostHog URL +- Whether a flag was created. If yes: key, 0% rollout, PostHog URL. If skip: say no flag was created - Which UI path was gated (or skipped) and why it was additive - Bill-aware defaults: one `evaluateFlags` per request, CI `advanced_disable_feature_flags`, no local evaluation -- Constraints of this install, named plainly: anonymous `ph_distinct_id` cookie (if used); `instrumentation-client` init relocated (if it was); bootstrap seeds only this flag (`false` is dropped by the client SDK); gate target auto-picked on a non-interactive host (if it was) -- How to demo the kill-switch: disable the flag in PostHog → reload → gated UI disappears +- Constraints of this install, named plainly: anonymous `ph_distinct_id` cookie (if used); `instrumentation-client` init relocated (if it was); bootstrap seeds only enabled flags (`false` is dropped by the client SDK); 0% default so production users are unchanged; gate target auto-picked on a non-interactive host (if it was) +- How to demo the kill-switch (only if a path was gated): PostHog → that flag → set rollout to **100%** → save → reload the app → gated UI appears → set rollout back to **0%** → reload → UI disappears. Do not tell them to start from 100%. - Out of scope: local evaluation, experiments, multivariate flags, other frameworks ## Reference files @@ -268,7 +279,8 @@ Write `./posthog-feature-flags-report.md` at the project root covering: - **One stack, one pattern.** Next.js App Router + server `evaluateFlags` + client bootstrap. Everything else aborts. - **`evaluateFlags` once per request.** Never the deprecated per-call methods. - **Same distinct_id on both sides.** Otherwise bootstrap lies. -- **Additive gating.** Flag off = current behavior. Never auth, checkout, or mutations. +- **Additive gating.** Flag off (including 0% rollout) = current behavior. Never auth, checkout, or mutations. +- **Off until they turn it on.** Skip = no new flag. Confirm = 0% rollout, never 100%. Abort if create fails. - **One `wizard_ask`.** The gate target. Skip is first so a stray Enter declines. Nothing else is a question. - **Env, never hardcode.** A missing token must not crash boot. - **Don't commit.** The operator reviews the diff. From 5976bf74aa2f7f4bc7289a2a158c76603e314ec8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Ristovi=C4=87?= Date: Wed, 2 Sep 2026 17:08:35 +0200 Subject: [PATCH 4/6] feat(feature-flags): stop killing the run if there is no demo widget --- context/skills/feature-flags-setup/description.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/context/skills/feature-flags-setup/description.md b/context/skills/feature-flags-setup/description.md index f649a763..e0519add 100644 --- a/context/skills/feature-flags-setup/description.md +++ b/context/skills/feature-flags-setup/description.md @@ -20,7 +20,6 @@ This is **not** `wizard audit feature-flags` (read-only, after the fact). This i If anything blocks the run, **always** emit exactly one `[ABORT] ` line and stop — never halt, finish, or error out silently. The wizard catches `[ABORT]` and terminates the run for you; don't try to exit yourself. A silent stop is recorded as a failed run with no reason, which can't be acted on, so every dead end must carry a reason. Use one of: - `[ABORT] unsupported stack for feature flags` — no `app/` directory, or `next` is not a dependency. -- `[ABORT] could not locate a UI surface to gate` — exhaustive search found no page or component that is safe to add an additive, flag-gated element to. - `[ABORT] no posthog project credentials` — no `phc_…` token in env and no PostHog MCP available to fetch one. - `[ABORT] could not create the feature flag` — they confirmed a gate target, but creating the 0% flag failed after retry (missing `feature_flag:write`, or the create tool errored). - `[ABORT] ` — anything else that blocks the run (e.g. no readable project). Keep it short and specific so it's useful when aggregated across runs. Do not paper over a failed flag create by writing "create this manually" and continuing. @@ -129,13 +128,14 @@ Flags change production UI. Ask **once**, then create a flag and gate only if th 1. Scan pages and components for additive surfaces (a banner, an extra card, an empty-state illustration). Prefer a new element over wrapping existing critical logic. 2. Never propose gating auth, checkout, payments, data-mutation handlers, or route-blocking middleware. -3. Call `mcp__wizard-tools__wizard_ask` **exactly once**: +3. If the scan found no safe additive surface: do **not** abort. Do **not** call `wizard_ask`. Treat as skip (`flagKey` unset) and continue to STEP 8. The example gate is optional; the SDK install is the product. Record in the report that gating was skipped because no safe UI surface was found. +4. Otherwise call `mcp__wizard-tools__wizard_ask` **exactly once**: - `subject`: `"gate-target"` - one `kind: "select"` question - `prompt`: the SDK install does not change what users see. Optionally add one additive UI path behind a new boolean flag at **0% rollout** (off for everyone, including production, until someone raises rollout in PostHog). Pick a low-risk target or skip. - Put **skip first** so it is the default highlight — an accidental Enter then declines instead of wrapping UI: `{ label: "Skip gating — install only", value: "skip" }`, then the recommended additive target (label includes "recommended"), then 1–2 alternatives. -4. If `wizard_ask` errors (CI / headless): do not fail. Treat it as the recommended additive target (still 0% rollout) and record in the report that the target was auto-picked because the host was non-interactive. -5. If they pick `skip`: do **not** create a flag. Do **not** call `isEnabled` / `getFlag` on an invented key. Continue to STEP 8 with no `flagKey`. Write the report saying no flag was created and no UI was gated. +5. If `wizard_ask` errors (CI / headless): do not fail. Treat it as the recommended additive target (still 0% rollout) and record in the report that the target was auto-picked because the host was non-interactive. +6. If they pick `skip`: do **not** create a flag. Do **not** call `isEnabled` / `getFlag` on an invented key. Continue to STEP 8 with no `flagKey`. Write the report saying no flag was created and no UI was gated. Do not ask any other question. Credentials come from MCP/env, not from the user. @@ -261,8 +261,8 @@ Write `./posthog-feature-flags-report.md` at the project root covering: - Stack detected and the pattern used (server `evaluateFlags` → client bootstrap) - Packages added and files changed -- Whether a flag was created. If yes: key, 0% rollout, PostHog URL. If skip: say no flag was created -- Which UI path was gated (or skipped) and why it was additive +- Whether a flag was created. If yes: key, 0% rollout, PostHog URL. If skip (user declined, or no safe UI surface was found): say no flag was created +- Which UI path was gated (or skipped) and why it was additive — if skipped for no safe surface, say that plainly - Bill-aware defaults: one `evaluateFlags` per request, CI `advanced_disable_feature_flags`, no local evaluation - Constraints of this install, named plainly: anonymous `ph_distinct_id` cookie (if used); `instrumentation-client` init relocated (if it was); bootstrap seeds only enabled flags (`false` is dropped by the client SDK); 0% default so production users are unchanged; gate target auto-picked on a non-interactive host (if it was) - How to demo the kill-switch (only if a path was gated): PostHog → that flag → set rollout to **100%** → save → reload the app → gated UI appears → set rollout back to **0%** → reload → UI disappears. Do not tell them to start from 100%. @@ -281,7 +281,8 @@ Write `./posthog-feature-flags-report.md` at the project root covering: - **Same distinct_id on both sides.** Otherwise bootstrap lies. - **Additive gating.** Flag off (including 0% rollout) = current behavior. Never auth, checkout, or mutations. - **Off until they turn it on.** Skip = no new flag. Confirm = 0% rollout, never 100%. Abort if create fails. -- **One `wizard_ask`.** The gate target. Skip is first so a stray Enter declines. Nothing else is a question. +- **No safe surface is skip, not abort.** The example gate is optional. Finish the SDK install. +- **One `wizard_ask`.** The gate target, and only if a safe surface exists. Skip is first so a stray Enter declines. Nothing else is a question. - **Env, never hardcode.** A missing token must not crash boot. - **Don't commit.** The operator reviews the diff. From 554704464d68017d82b948bfbb31f6ed8021ed8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Ristovi=C4=87?= Date: Wed, 2 Sep 2026 17:29:30 +0200 Subject: [PATCH 5/6] feat(feature-flags): add nextjs-flags-bootstrap commandment exception --- context/commandments.yaml | 10 ++++++++++ context/skills/feature-flags-setup/config.yaml | 2 +- context/skills/feature-flags-setup/description.md | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/context/commandments.yaml b/context/commandments.yaml index 167ba065..004a80c7 100644 --- a/context/commandments.yaml +++ b/context/commandments.yaml @@ -44,6 +44,16 @@ commandments: - For flags that affect initial render, evaluate server-side and pass as props to prevent UI flicker - Client-side hooks may return undefined initially while flags load - handle this loading state + # Exception to `nextjs` / `nextjs-feature-flags` (analytics-simple path: + # instrumentation-client, no provider). Only for skills that server-evaluate + # flags and bootstrap them into the client. Do not add this tag to the + # docs-only `feature-flags` group. + nextjs-flags-bootstrap: + - When flags are evaluated on the server and bootstrapped into the client, initialize posthog-js via PostHogProvider with a per-request bootstrap option — not in instrumentation-client.ts, which cannot take per-request bootstrap + - If instrumentation-client.ts (or .js) already inits posthog-js, move that init into the provider and keep existing api_host / defaults / other options. Do not leave both inits in place + - If there is no existing client init, the provider is the only client init — do not add instrumentation-client.ts + - Evaluate flags with posthog-node evaluateFlags() once per request. Do not call getAllFlags, getFeatureFlag, or isFeatureEnabled — each is its own /flags request + javascript_web: - When a reverse proxy is configured, both /static/* AND /array/* must route to the assets origin (us-assets.i.posthog.com or eu-assets.i.posthog.com). - posthog-js is the JavaScript SDK package name diff --git a/context/skills/feature-flags-setup/config.yaml b/context/skills/feature-flags-setup/config.yaml index 8dcb7ccb..25ad6374 100644 --- a/context/skills/feature-flags-setup/config.yaml +++ b/context/skills/feature-flags-setup/config.yaml @@ -4,7 +4,7 @@ type: skill template: description.md category: feature-flags description: Add PostHog feature flags to a Next.js App Router app. Evaluates flags server-side with posthog-node.evaluateFlags(), bootstraps the values into the client to avoid flicker and a duplicate /flags fetch, and disables /flags polling in CI. After one confirm, optionally creates one boolean flag at 0% rollout and gates one additive UI path so production users are unchanged until rollout is raised. -tags: [feature-flags, javascript, react] +tags: [feature-flags, javascript, react, nextjs-flags-bootstrap] cli: role: command command: feature-flags diff --git a/context/skills/feature-flags-setup/description.md b/context/skills/feature-flags-setup/description.md index e0519add..c149ec89 100644 --- a/context/skills/feature-flags-setup/description.md +++ b/context/skills/feature-flags-setup/description.md @@ -221,7 +221,7 @@ export function PHProvider({ `advanced_disable_feature_flags: true` in test/CI stops forgotten CI jobs from polling `/flags` (see `cutting-costs.md`). A missing token in production is a no-op (render `children`); in development throw the missing-config error named in the framework guidelines. -**If `instrumentation-client.ts` (or `.js`) already inits `posthog-js`:** move that init into this provider so bootstrap can be passed per request. Keep the existing `api_host` / `defaults` / other options. Do not leave both inits in place. Do not follow any framework note that says to keep init in `instrumentation-client.ts` — that path cannot take per-request bootstrap. +**If `instrumentation-client.ts` (or `.js`) already inits `posthog-js`:** move that init into this provider so bootstrap can be passed per request. Keep the existing `api_host` / `defaults` / other options. Do not leave both inits in place. The `nextjs-flags-bootstrap` commandment is the source of truth — the generic Next.js commandment (`instrumentation-client.ts`) is the analytics-simple path and does not apply here. **If there is no existing client init:** the provider is the only client init. Do not add `instrumentation-client.ts`. From 1783e7e21ab51c83d1165372ad080065a63b493a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Ristovi=C4=87?= Date: Wed, 2 Sep 2026 18:07:00 +0200 Subject: [PATCH 6/6] feat(feature-flags): refacor description so it makes more sense for the user --- .../skills/feature-flags-setup/config.yaml | 2 +- .../skills/feature-flags-setup/description.md | 84 ++++++++++++------- 2 files changed, 54 insertions(+), 32 deletions(-) diff --git a/context/skills/feature-flags-setup/config.yaml b/context/skills/feature-flags-setup/config.yaml index 25ad6374..288dd421 100644 --- a/context/skills/feature-flags-setup/config.yaml +++ b/context/skills/feature-flags-setup/config.yaml @@ -3,7 +3,7 @@ type: skill template: description.md category: feature-flags -description: Add PostHog feature flags to a Next.js App Router app. Evaluates flags server-side with posthog-node.evaluateFlags(), bootstraps the values into the client to avoid flicker and a duplicate /flags fetch, and disables /flags polling in CI. After one confirm, optionally creates one boolean flag at 0% rollout and gates one additive UI path so production users are unchanged until rollout is raised. +description: Add the cheap feature-flags path to an existing PostHog install on Next.js App Router 15.3+. Evaluates flags server-side with posthog-node.evaluateFlags(), bootstraps the values into the client to avoid flicker and a duplicate /flags fetch, and disables /flags polling in CI. After one confirm, optionally creates one boolean flag at 0% rollout and gates one additive UI path so production users are unchanged until rollout is raised. Does not replace the default wizard integration. tags: [feature-flags, javascript, react, nextjs-flags-bootstrap] cli: role: command diff --git a/context/skills/feature-flags-setup/description.md b/context/skills/feature-flags-setup/description.md index c149ec89..fb5f81fa 100644 --- a/context/skills/feature-flags-setup/description.md +++ b/context/skills/feature-flags-setup/description.md @@ -1,14 +1,17 @@ # Add PostHog feature flags -Use this skill to add PostHog feature flags to a **Next.js App Router** app. Once installed, flags are evaluated once per request on the server with `posthog-node`'s `evaluateFlags()`, those values are bootstrapped into the client so the first paint has no flicker and no extra `/flags` fetch, and `/flags` polling is off in CI. +Use this skill to add the cheap feature-flags path to an **existing PostHog install** on **Next.js App Router 15.3+**. Flags are evaluated once per request on the server with `posthog-node`'s `evaluateFlags()`, those values are bootstrapped into the client so the first paint has no flicker and no extra `/flags` fetch, and `/flags` polling is off in CI. + +This skill **extends** `npx @posthog/wizard` (or an equivalent existing `posthog-js` / `posthog-node` init). It does not replace the default integration, does not invent a second analytics init, and aborts if PostHog is not already initialized. This is a **production-safe install**. It does not turn a new flag on for real users. A kill-switch demo is optional: after they confirm a UI path, create one boolean flag at **0% rollout** and gate that path additively. Flag-off (including 0%) = current behavior. They test by raising rollout to 100% in PostHog, then setting it back to 0%. -This is **not** `wizard audit feature-flags` (read-only, after the fact). This is **not** the default `wizard` install (product analytics). This skill is the flags install: instrument, then optionally create-and-gate. +This is **not** `wizard audit feature-flags` (read-only, after the fact). This is **not** the default `wizard` install (product analytics). This skill is the flags layer: reuse the existing SDK, add bootstrap, then optionally create-and-gate. ## Scope and guardrails -- **Next.js App Router only.** Require `next` in `package.json` and an `app/` directory (or `src/app/`). If the project is Pages Router, a different framework, or backend-only, **stop**: emit `[ABORT] unsupported stack for feature flags` on its own line and do nothing else. Do not invent a second pattern. Do not add local evaluation. Do not install `@posthog/next`. +- **Next.js App Router 15.3+ only.** Require `next` in `package.json` at **15.3.0 or newer** and an `app/` directory (or `src/app/`). If the project is Pages Router, Next below 15.3, a different framework, or backend-only, **stop**: emit `[ABORT] unsupported stack for feature flags` on its own line and do nothing else. Do not invent a second pattern. Do not add local evaluation. Do not install `@posthog/next`. +- **Existing PostHog init required.** This skill extends an existing client init (`posthog.init`, `PostHogProvider`, or `instrumentation-client` with `posthog-js`). If none is present, **stop**: emit `[ABORT] posthog not initialized` and tell them to run `npx @posthog/wizard` first. Do not write env or install packages on the way to that abort. - **One evaluation per request.** Call `evaluateFlags(distinctId)` once, then read with `flags.isEnabled(key)` / `flags.getFlag(key)`. Do not call the deprecated `getFeatureFlag`, `isFeatureEnabled`, or `getAllFlags` — each of those is its own `/flags` request. - **Same distinct_id on server and client.** Percentage rollout is deterministic per id. If they differ, bootstrap lies. - **Additive gating only.** Flag off = current behavior. Never gate auth, checkout, payments, data-mutation handlers, or middleware that can 404 a route. @@ -19,8 +22,9 @@ This is **not** `wizard audit feature-flags` (read-only, after the fact). This i If anything blocks the run, **always** emit exactly one `[ABORT] ` line and stop — never halt, finish, or error out silently. The wizard catches `[ABORT]` and terminates the run for you; don't try to exit yourself. A silent stop is recorded as a failed run with no reason, which can't be acted on, so every dead end must carry a reason. Use one of: -- `[ABORT] unsupported stack for feature flags` — no `app/` directory, or `next` is not a dependency. -- `[ABORT] no posthog project credentials` — no `phc_…` token in env and no PostHog MCP available to fetch one. +- `[ABORT] unsupported stack for feature flags` — no `app/` directory, `next` is not a dependency, or `next` is below 15.3.0. +- `[ABORT] posthog not initialized` — no `posthog.init`, `PostHogProvider`, or `instrumentation-client` client init. This skill extends an existing install; tell them to run `npx @posthog/wizard` first. +- `[ABORT] no posthog project credentials` — init exists, but no `phc_…` token in env and no PostHog MCP available to fetch one. - `[ABORT] could not create the feature flag` — they confirmed a gate target, but creating the 0% flag failed after retry (missing `feature_flag:write`, or the create tool errored). - `[ABORT] ` — anything else that blocks the run (e.g. no readable project). Keep it short and specific so it's useful when aggregated across runs. Do not paper over a failed flag create by writing "create this manually" and continuing. @@ -45,38 +49,40 @@ Emit `[STATUS] Detecting Next.js App Router`. Look for `next` in `package.json` **and** an `app/` directory (or `src/app/`). The lockfile decides the package manager (`pnpm-lock.yaml`, `package-lock.json`, `yarn.lock`, `bun.lockb`). -If this is not Next.js App Router, apply the unsupported-stack abort. In the same message, say where you looked. +Read `dependencies.next` / `devDependencies.next`. Strip `^` `~` `>=`. If the version is below **15.3.0**, apply the unsupported-stack abort. In the same message, say the version you found and that this pattern needs Next.js 15.3+ (App Router). Do not invent a Pages Router or pre-15.3 layout-only path. + +If this is not Next.js App Router 15.3+, apply the unsupported-stack abort. In the same message, say where you looked. -Record: package manager, whether `src/` is used, and whether PostHog is already initialized (`posthog.init`, `PostHogProvider`, `instrumentation-client`, `posthog-js` / `posthog-node` in dependencies). If flags are already wired the way this skill describes (`evaluateFlags` + bootstrap + a gated call site), verify they are correct and skip to STEP 10. +Record: package manager, whether `src/` is used, the `next` version, and whether PostHog is already initialized (`posthog.init`, `PostHogProvider`, `instrumentation-client`, `posthog-js` / `posthog-node` in dependencies). If flags are already wired the way this skill describes (`evaluateFlags` + bootstrap + a gated call site), verify they are correct and skip to STEP 10. -### STEP 2: Credentials +### STEP 2: Credentials and existing init Emit `[STATUS] Resolving PostHog credentials`. -- If `.env` / `.env.local` already has a `phc_…` token and a host, reuse those key names. Do not rename working names. -- Otherwise fetch the project's `api_token` via PostHog MCP. If several projects come back, use the one the current session is authenticated to. If that is unclear, abort with `no posthog project credentials` rather than guessing. -- Host: `https://us.i.posthog.com` (US) or `https://eu.i.posthog.com` (EU). Match the project's region from the MCP response. -- Write `NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN` and `NEXT_PUBLIC_POSTHOG_HOST` via `set_env_values` if missing. Never hardcode. +PostHog must already be initialized (`posthog.init`, `PostHogProvider`, or `instrumentation-client` with `posthog-js`). This skill does not replace `npx @posthog/wizard`. If there is no client init, emit `[ABORT] posthog not initialized` and stop. In the same message, tell them to run `npx @posthog/wizard` and re-run this command. Do not write env or install packages after that abort. -### STEP 3: Install packages +- If `.env` / `.env.local` already has a `phc_…` token and a host, **reuse those key names**. Do not rename working names. Copy the env names from `example-apps/next-app-router` (`NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN`, `NEXT_PUBLIC_POSTHOG_HOST`) only when creating missing keys — do not invent parallel names. +- If init exists but the token is missing: fetch `api_token` via PostHog MCP (the wizard OAuth session can supply it). If several projects come back, use the one this session is authenticated to. If that is unclear, abort `no posthog project credentials` rather than guessing. +- Host: `https://us.i.posthog.com` (US) or `https://eu.i.posthog.com` (EU). Match the project's region from the MCP response. +- Write `NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN` and `NEXT_PUBLIC_POSTHOG_HOST` via `set_env_values` **only if those keys are missing**. Never hardcode. Never write env if you are about to abort for missing init. -Emit `[STATUS] Installing PostHog packages`. +Do not add a second `posthog.init`. STEP 8 may relocate an existing `instrumentation-client` init into `PostHogProvider` so bootstrap can be passed per request — that is flags wiring, not a new integration. -Install with the project's package manager: +### STEP 3: Packages -- `posthog-js` -- `posthog-node` -- `@posthog/react` +Emit `[STATUS] Installing PostHog packages`. -Import **both** `PostHogProvider` and `useFeatureFlagEnabled` from `@posthog/react`. Do not import the provider from `posthog-js/react`. Do not install `@posthog/next`. +Use the project's package manager. **Do not reinstall working versions** — this is not a second integration. -If the packages are already present, leave the versions alone unless `evaluateFlags` is missing from `posthog-node` — then bump `posthog-node` only. +- If `posthog-js` / `posthog-node` are already in `package.json`, leave them. Bump `posthog-node` **only** if `evaluateFlags` is missing from the installed package. +- If `@posthog/react` is missing, add it (hooks + `PostHogProvider`). Import **both** `PostHogProvider` and `useFeatureFlagEnabled` from `@posthog/react`. Do not import the provider from `posthog-js/react`. +- Do not install `@posthog/next`. ### STEP 4: Server client Emit `[STATUS] Adding the server client`. -Add a small helper, matching the project's folder style (`lib/posthog-server.ts` or `app/posthog.ts`): +If a server helper already exists (e.g. `lib/posthog-server.ts` from the default integration / `example-apps/next-app-router`), **reuse it** — keep `flushAt: 1` / `flushInterval: 0`. Do not add a second client. If none exists, add a small helper matching the project's folder style (`lib/posthog-server.ts` or `app/posthog.ts`): ```ts import { PostHog } from 'posthog-node' @@ -97,16 +103,23 @@ export function PostHogServer(token: string) { Emit `[STATUS] Wiring a shared distinct id`. - **Identified app** (`posthog.identify`, a session user id, etc.): use that stable id on the server and bootstrap `distinctID` with `isIdentifiedID: true`. Do not add the cookie below. -- **Anonymous app**: persist the id in a `ph_distinct_id` cookie. Mint it in `middleware.ts` if missing. Read it in the root layout. Bootstrap `distinctID` with `isIdentifiedID: false`. Do not call `identify()` with a shared literal like `"anonymous"`. If `middleware.ts` already exists, add the cookie logic to the existing handler — do not replace it. +- **Anonymous app**: persist one id in a `ph_distinct_id` cookie. Mint it in `middleware.ts` **only if missing**, and copy the same value onto the request as `x-ph-distinct-id` so the root layout can read it on the minting request. Read cookie first, then the header. Bootstrap `distinctID` with `isIdentifiedID: false` so the client SDK adopts that id — after hydration, `posthog.get_distinct_id()` matches. Do not call `identify()` with a shared literal like `"anonymous"`. If `middleware.ts` already exists, add the cookie logic to the existing handler — do not replace it. +- **Do not** mint a distinct id in the layout. Never `crypto.randomUUID()` (or any per-request random) as `bootstrap.distinctID` — that is a volatile id and blocks later `identify()` merges. If cookie and header are both missing, skip `evaluateFlags` for that request and render `{children}` without bootstrap (same as a missing token). Middleware will set the cookie; the next request evaluates. ```ts import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' export function middleware(request: NextRequest) { - const response = NextResponse.next() - if (!request.cookies.get('ph_distinct_id')) { - response.cookies.set('ph_distinct_id', crypto.randomUUID(), { path: '/' }) + const existing = request.cookies.get('ph_distinct_id')?.value + const distinctId = existing ?? crypto.randomUUID() + const requestHeaders = new Headers(request.headers) + requestHeaders.set('x-ph-distinct-id', distinctId) + const response = NextResponse.next({ + request: { headers: requestHeaders }, + }) + if (!existing) { + response.cookies.set('ph_distinct_id', distinctId, { path: '/' }) } return response } @@ -159,17 +172,23 @@ Create via `exec`. If create fails, retry once after `info`; then emit `[ABORT] Emit `[STATUS] Bootstrapping flags into the client`. -The root layout is a Server Component. If `NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN` is missing, render `{children}` without evaluating or wrapping — boot must still work. Otherwise evaluate once and pass the snapshot into the provider. +The root layout is a Server Component. Init already exists (STEP 2). You are adding bootstrap, not a first install. + +If `NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN` is missing, render `{children}` without evaluating or wrapping — boot must still work. Otherwise evaluate once and pass the snapshot into the provider. Only read a specific key when STEP 7 created (or reused) one. Do not invent a key to satisfy this snippet. Bootstrap docs drop false and empty values — at 0% rollout `getFlag` is `false`, so `featureFlags` is `{}`. That is correct: the client matches "off." ```ts +import { cookies, headers } from 'next/headers' + const token = process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN const cookieStore = await cookies() +const headerStore = await headers() const distinctId = - cookieStore.get('ph_distinct_id')?.value ?? crypto.randomUUID() + cookieStore.get('ph_distinct_id')?.value ?? + headerStore.get('x-ph-distinct-id') -if (!token) { +if (!token || !distinctId) { return {children} } @@ -183,6 +202,8 @@ if (flagKey) { await client.shutdown() ``` +For an identified app, skip the cookie/header and pass the stable user id as `distinctID` with `isIdentifiedID: true` instead. + Add `app/providers.tsx` (or `src/app/providers.tsx`) and wrap `{children}` from the root layout. Use the `apiKey` + `options` form of `PostHogProvider` (not `client={posthog}`) so bootstrap can be passed per request: ```tsx @@ -264,7 +285,7 @@ Write `./posthog-feature-flags-report.md` at the project root covering: - Whether a flag was created. If yes: key, 0% rollout, PostHog URL. If skip (user declined, or no safe UI surface was found): say no flag was created - Which UI path was gated (or skipped) and why it was additive — if skipped for no safe surface, say that plainly - Bill-aware defaults: one `evaluateFlags` per request, CI `advanced_disable_feature_flags`, no local evaluation -- Constraints of this install, named plainly: anonymous `ph_distinct_id` cookie (if used); `instrumentation-client` init relocated (if it was); bootstrap seeds only enabled flags (`false` is dropped by the client SDK); 0% default so production users are unchanged; gate target auto-picked on a non-interactive host (if it was) +- Constraints of this install, named plainly: existing init was required; anonymous `ph_distinct_id` cookie (if used); `instrumentation-client` init relocated (if it was); bootstrap seeds only enabled flags (`false` is dropped by the client SDK); 0% default so production users are unchanged; gate target auto-picked on a non-interactive host (if it was); Next.js version recorded - How to demo the kill-switch (only if a path was gated): PostHog → that flag → set rollout to **100%** → save → reload the app → gated UI appears → set rollout back to **0%** → reload → UI disappears. Do not tell them to start from 100%. - Out of scope: local evaluation, experiments, multivariate flags, other frameworks @@ -276,9 +297,10 @@ Write `./posthog-feature-flags-report.md` at the project root covering: ## Key principles -- **One stack, one pattern.** Next.js App Router + server `evaluateFlags` + client bootstrap. Everything else aborts. +- **One stack, one pattern.** Next.js App Router 15.3+ + existing PostHog init + server `evaluateFlags` + client bootstrap. Everything else aborts. +- **Extend, don't reinstall.** No init → `[ABORT] posthog not initialized`. Reuse packages, env names, and the server helper. Relocating `instrumentation-client` into the provider is flags bootstrap, not a second integration. - **`evaluateFlags` once per request.** Never the deprecated per-call methods. -- **Same distinct_id on both sides.** Otherwise bootstrap lies. +- **Same distinct_id on both sides.** Cookie + request header on the minting request; never a layout `randomUUID()`. After hydration, `posthog.get_distinct_id()` matches `bootstrap.distinctID`. - **Additive gating.** Flag off (including 0% rollout) = current behavior. Never auth, checkout, or mutations. - **Off until they turn it on.** Skip = no new flag. Confirm = 0% rollout, never 100%. Abort if create fails. - **No safe surface is skip, not abort.** The example gate is optional. Finish the SDK install.