Build This Now
Build This Now
Qu'est-ce que le code Claude ?Installer Claude CodeL'installateur natif de Claude CodeTon premier projet Claude Code
Subscription BillingMulti-Tenant SaaSAI Chat FeatureRate LimitingStripe WebhooksSemantic SearchRealtime UpdatesDrip Email Sequencesv2.1.122 Release NotesClaude Code Dynamic Workflows : comment orchestrer 1 000 sous-agents sur une vraie codebaseBonnes pratiques Claude CodeMeilleures pratiques pour Claude Opus 4.7Claude Code sur un VPSIntégration GitRevue de code avec Claude CodeLes Worktrees avec Claude CodeClaude Code à distanceClaude Code ChannelsChannels, Routines, Teleport, DispatchTâches planifiées avec Claude CodePermissions Claude CodeLe mode auto de Claude CodeAjouter les paiements Stripe avec Claude CodeFeedback LoopsWorkflows TodoGestion des tâches dans Claude CodeTemplates de projetTarification et utilisation des tokens Claude CodeTarifs de Claude Code : ce que tu vas vraiment payerClaude Code Ultra ReviewConstruire une app Next.js avec Claude CodeSupabase DatabaseVercel DeepsecTest-Driven DevelopmentConstruire un MVP SaaS avec Claude CodeAjouter l'authentification avec Claude Code (Supabase Auth)Ajouter les emails transactionnels avec Claude Code (Resend + React Email)Construire une API type-safe avec Claude Code (oRPC + Zod)File UploadsBackground Jobs (Inngest)Admin DashboardFull-Text SearchCommerce agentique : comment construire une app que les agents IA peuvent payer1M ContextUser API KeysAudit LogsCSV Import PipelineDatabase MigrationsProduction Error TrackingFeature FlagsGitHub ActionsHeadless ModeMax Plan vs APICaching and RevalidationIn-App NotificationsOutbound WebhooksPrompt CachingRoles & PermissionsMarketplace PaymentsUsage-Based BillingCombien coûte la création d'un SaaS avec Claude Code en 2026Parallel AI AgentsCoding Agent Injection
speedy_devvkoen_salo
Blog/Handbook/Workflow/Feature Flags

Feature Flags

How to ship server side feature flags in Next.js 16 with PostHog: percentage rollouts, a kill switch on oRPC procedures, per workspace overrides in Supabase, and Claude Code sweeping dead flags out of the codebase.

Arrête de tout configurer. Place à la construction.

Des templates SaaS avec orchestration IA.

Découvrez ce que nous construisons pour les entreprises →
speedy_devvWritten by speedy_devvPublished Jul 20, 202610 min readHandbook hubWorkflow index

A bad release should cost you one toggle, not a rollback and a redeploy. This is the full server side flag setup for a Next.js 16 app: PostHog evaluated before any HTML is sent, a guard that blocks oRPC procedures the moment a flag flips off, per workspace overrides stored in PostgreSQL via Supabase, and a Claude Code command that deletes a flag once it has won. The result is a kill switch that takes about five seconds and a rollout you can widen from a dashboard while you watch the error rate.

What You Are Actually Building

Four layers, each doing one job.

LayerWhere it livesWhat it decides
Registrylib/flags/registry.tsWhich flag keys exist at all
Evaluationlib/flags/evaluate.tsThe value for this request
GuardoRPC middlewareWhether the API call is allowed
Overridefeature_flag_overrides tableThe one workspace that gets an exception

PostHog owns the rollout percentage. Supabase owns the exceptions. Your code owns nothing except the branch, which is the point. Nobody should have to deploy to change who sees what.

Install and Wire the Server Client

Start with the Node SDK. It is a separate package from the browser one and it is the only half that can evaluate flags without shipping the answer to the client first.

npm install posthog-node

Server functions on Vercel can be short lived, so PostHog recommends flushAt: 1 and flushInterval: 0 to send events immediately instead of batching them for a process that may not exist a second later. Adding secretKey turns on local evaluation, where the SDK polls flag definitions into memory and computes results without a network round trip per call. It accepts either a personal API key (phx_...) or a project secret key (phs_...). The older personalApiKey option still works but is deprecated, and secretKey wins if you set both.

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

let client: PostHog | undefined;

export function posthogServer(): PostHog {
  if (!client) {
    client = new PostHog(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
      host: process.env.NEXT_PUBLIC_POSTHOG_HOST ?? "https://us.i.posthog.com",
      secretKey: process.env.POSTHOG_SECRET_KEY,
      featureFlagsPollingInterval: 30_000,
      flushAt: 1,
      flushInterval: 0,
    });
  }
  return client;
}

The module level singleton matters. Creating a new PostHog instance per request starts a new polling loop each time and throws away the cached flag definitions, which turns local evaluation back into a network call. Keep one instance per process. In a one off script or a cron job that exits, call await posthogServer().shutdown() at the end so queued events flush before the process dies.

featureFlagsPollingInterval defaults to 30 seconds. That number is your worst case kill switch latency on already warm instances, so leave it where it is unless you have a reason not to.

A Typed Registry Is the Whole Trick

Most flag messes start the same way. Someone types a string literal into a component, ships it, and now the only record of that flag is a grep result. Put every key in one file and make it a type.

// lib/flags/registry.ts
export const FLAGS = {
  "reports-v2": {
    description: "Reports page backed by the new aggregate table",
    owner: "hugues",
    createdAt: "2026-07-06",
    kind: "release",
  },
  "bulk-export": {
    description: "Whole workspace CSV export, heavy query",
    owner: "hugues",
    createdAt: "2026-07-14",
    kind: "kill-switch",
  },
} as const;

export type FlagKey = keyof typeof FLAGS;
export const FLAG_KEYS = Object.keys(FLAGS) as FlagKey[];

Every function downstream takes FlagKey instead of string. Misspell a key and the build fails. More importantly, deleting an entry from this object turns every stale call site into a type error, which is exactly the property that makes the Claude Code sweep at the end of this post work mechanically instead of hopefully.

The kind field is a note to your future self. A release flag is temporary and should be deleted within weeks. A kill switch is permanent infrastructure for an expensive or risky operation. Mixing the two in your head is how release flags survive for two years.

Give Every Request a Stable Identity

Flags need a distinct ID. Logged in users have one. Anonymous visitors on your marketing pages do not, and if you generate a random one per request, a visitor gets a different answer on every page load. In Next.js 16, proxy.ts replaces middleware.ts and is the right place to stamp a stable cookie.

// proxy.ts
import { NextResponse, type NextRequest } from "next/server";

const COOKIE = "ph_distinct_id";

export default function proxy(request: NextRequest) {
  const existing = request.cookies.get(COOKIE)?.value;
  const distinctId = existing ?? crypto.randomUUID();

  // Write it onto the request too, so this same request can read it.
  if (!existing) request.cookies.set(COOKIE, distinctId);

  const response = NextResponse.next({ request });

  if (!existing) {
    response.cookies.set(COOKIE, distinctId, {
      httpOnly: true,
      sameSite: "lax",
      secure: process.env.NODE_ENV === "production",
      maxAge: 60 * 60 * 24 * 365,
      path: "/",
    });
  }

  return response;
}

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

Setting the cookie on both the request and the response is the part people skip. response.cookies.set() alone only sends a Set-Cookie header, so the render that happens on that very first visit still sees no cookie and falls back to anonymous. Mutating request.cookies and passing the request into NextResponse.next({ request }) is what makes the value readable in the same pass.

Reading it back is a two line helper. Note that cookies() is async in Next.js 16, so it needs an await.

// lib/flags/identity.ts
import "server-only";
import { cookies } from "next/headers";

export async function getDistinctId(userId?: string): Promise<string> {
  if (userId) return userId;
  const store = await cookies();
  return store.get("ph_distinct_id")?.value ?? "anonymous";
}

Evaluate Once Per Request

The current PostHog Node API is evaluateFlags(), which returns a snapshot you read from. It landed in posthog-node 5.33.0, so pin at least that version. The older getFeatureFlag(), isFeatureEnabled(), and getFeatureFlagPayload() methods are marked deprecated in favour of it. One call gives you every flag, so you are not making a separate evaluation per branch.

The snapshot exposes getFlag() (returns the variant string for multivariate flags, true or false for boolean flags, and undefined when the flag was not returned), isEnabled(), and getFlagPayload(). Merging the workspace overrides on top happens here, in one place, so no call site has to remember the precedence rule.

// lib/flags/evaluate.ts
import "server-only";
import { posthogServer } from "@/lib/posthog/server";
import { FLAG_KEYS, type FlagKey } from "./registry";
import { getWorkspaceOverrides } from "./overrides";

export type FlagValue = boolean | string;
export type FlagMap = Record<FlagKey, FlagValue>;

interface EvaluateArgs {
  distinctId: string;
  workspaceId: string;
  plan: string;
}

export async function evaluateFlags({
  distinctId,
  workspaceId,
  plan,
}: EvaluateArgs): Promise<FlagMap> {
  const [snapshot, overrides] = await Promise.all([
    posthogServer().evaluateFlags(distinctId, {
      groups: { workspace: workspaceId },
      groupProperties: { workspace: { plan } },
      flagKeys: [...FLAG_KEYS],
    }),
    getWorkspaceOverrides(workspaceId),
  ]);

  const result = {} as FlagMap;

  for (const key of FLAG_KEYS) {
    const override = overrides[key];
    result[key] = override !== undefined ? override : snapshot.getFlag(key) ?? false;
  }

  return result;
}

Two details are load bearing. ?? false means a PostHog outage fails closed rather than turning on half finished features for everyone. And overrides win over the rollout, because an override exists precisely to contradict the rollout for one customer.

Do not put "use cache" on this function. The result depends on the current user and their workspace, and a cached per user value is a data leak waiting to happen. Cache the override lookup instead, which is what the next section does.

Percentage Rollouts That Do Not Split Teams

In PostHog you set the rollout percentage on the flag itself, so widening from 10 percent to 50 percent is a dashboard action with no deploy. The part people get wrong is what the percentage is computed against.

By default the hash is computed on the distinct ID, which means individual users. In a multi seat product that is the wrong unit. Two people on the same team open the same screen on a shared call and see different apps. Set the flag's release condition on a group type instead, then pass groups when you evaluate, as the code above does with groups: { workspace: workspaceId }. Now the hash runs on the workspace ID and everyone inside a workspace lands on the same side.

groupProperties lets you layer conditions on top without another deploy. Passing the plan means you can write a release condition in PostHog like "enterprise workspaces only" or "50 percent of workspaces on the pro plan" and change your mind at 2am without touching the repo.

The Kill Switch Lives on the API

A flag that only hides UI is not a kill switch. If the endpoint still answers, a user with devtools open, a stale tab, or a queued retry can still trigger the expensive query you are trying to stop. Put the guard in oRPC middleware so it runs before any handler body.

// server/orpc/base.ts
import { os, ORPCError } from "@orpc/server";
import type { FlagKey } from "@/lib/flags/registry";
import type { FlagMap } from "@/lib/flags/evaluate";

export interface AppContext {
  userId: string;
  workspaceId: string;
  flags: FlagMap;
}

export const base = os.$context<AppContext>();

export const requireFlag = (key: FlagKey) =>
  base.middleware(async ({ context, next }) => {
    const value = context.flags[key];

    if (value === false || value === undefined) {
      throw new ORPCError("FORBIDDEN", {
        message: `Feature "${key}" is not available for this workspace.`,
        data: { flag: key },
      });
    }

    return next();
  });

Attaching it is one line per procedure, and because requireFlag only accepts a FlagKey, a typo never reaches production.

// server/orpc/exports.ts
import { z } from "zod";
import { base, requireFlag } from "./base";

export const startBulkExport = base
  .use(requireFlag("bulk-export"))
  .input(z.object({ format: z.enum(["csv", "json"]) }))
  .handler(async ({ input, context }) => {
    // enqueue the export job for context.workspaceId
    return { queued: true, format: input.format };
  });

Flags get evaluated once where you mount the router, then ride along in context for every procedure in the request.

// app/api/rpc/[[...rest]]/route.ts
import { RPCHandler } from "@orpc/server/fetch";
import { router } from "@/server/orpc/router";
import { evaluateFlags } from "@/lib/flags/evaluate";
import { getSession } from "@/lib/auth";

const handler = new RPCHandler(router);

async function handle(request: Request) {
  const session = await getSession();

  const flags = await evaluateFlags({
    distinctId: session.userId,
    workspaceId: session.workspaceId,
    plan: session.plan,
  });

  const { response } = await handler.handle(request, {
    prefix: "/api/rpc",
    context: {
      userId: session.userId,
      workspaceId: session.workspaceId,
      flags,
    },
  });

  return response ?? new Response("Not found", { status: 404 });
}

export const GET = handle;
export const POST = handle;

Per Workspace Overrides in Supabase

PostHog can target individual users, but a support engineer should not need a PostHog seat to turn one customer's feature off at 9pm. A small table gives you an audited override that your own admin UI can write.

-- supabase/migrations/20260722120000_feature_flag_overrides.sql
create table public.feature_flag_overrides (
  workspace_id uuid not null references public.workspaces (id) on delete cascade,
  flag_key text not null,
  value jsonb not null,
  reason text not null,
  created_by uuid references auth.users (id),
  created_at timestamptz not null default now(),
  primary key (workspace_id, flag_key)
);

alter table public.feature_flag_overrides enable row level security;

create policy "members read their workspace overrides"
  on public.feature_flag_overrides
  for select
  using (
    exists (
      select 1
      from public.workspace_members m
      where m.workspace_id = feature_flag_overrides.workspace_id
        and m.user_id = (select auth.uid())
    )
  );

There is no insert, update, or delete policy on purpose. Writes go through the service role from your admin code, so a compromised browser session cannot grant itself a feature. value is jsonb so it holds true, false, or a variant string with one column. reason is not null because an override with no explanation becomes permanent by accident.

The read runs on every request, so cache it. This is where "use cache" belongs: the data is per workspace rather than per user, and it changes rarely. A cached function cannot read cookies, which is why the workspace ID is an argument and the client is built from the service role key. The directive needs cacheComponents: true in your next.config.ts, and on serverless the default in memory cache does not survive between invocations, so reach for "use cache: remote" if you want the entry shared across requests.

// lib/flags/overrides.ts
import "server-only";
import { cacheLife, cacheTag, updateTag } from "next/cache";
import { createClient } from "@supabase/supabase-js";
import type { FlagKey } from "./registry";

type OverrideMap = Partial<Record<FlagKey, boolean | string>>;

function adminClient() {
  return createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!,
    { auth: { persistSession: false } }
  );
}

export async function getWorkspaceOverrides(
  workspaceId: string
): Promise<OverrideMap> {
  "use cache";
  cacheTag(`flag-overrides:${workspaceId}`);
  cacheLife("minutes");

  const { data, error } = await adminClient()
    .from("feature_flag_overrides")
    .select("flag_key, value")
    .eq("workspace_id", workspaceId);

  if (error) throw error;

  const map: OverrideMap = {};
  for (const row of data ?? []) {
    map[row.flag_key as FlagKey] = row.value as boolean | string;
  }
  return map;
}

export async function setWorkspaceOverride(
  workspaceId: string,
  key: FlagKey,
  value: boolean | string,
  reason: string,
  createdBy: string
) {
  const { error } = await adminClient()
    .from("feature_flag_overrides")
    .upsert({
      workspace_id: workspaceId,
      flag_key: key,
      value,
      reason,
      created_by: createdBy,
    });

  if (error) throw error;

  // Server Action only. From a Route Handler, call
  // revalidateTag(`flag-overrides:${workspaceId}`, "max") instead.
  updateTag(`flag-overrides:${workspaceId}`);
}

Invalidating the tag right after the write is what makes the override land immediately. updateTag is the Next.js 16 read your own writes API: it expires the entry so the next request waits for fresh data instead of being served the stale one. It only runs inside a Server Action. If your admin write lives in a Route Handler, use revalidateTag instead, and note that in Next.js 16 its second argument is a cache profile that is no longer optional, so revalidateTag(tag) on its own is a type error. Passing "max" gives you stale while revalidate. Skip the invalidation entirely and you wait out the cacheLife window while a customer sits on the phone.

Gating a Page and Keeping the UI Honest

In a Server Component the branch happens before any HTML exists, so the disabled variant is never sent to the browser. Route params are promises in Next.js 16, so params needs an await.

// app/(app)/[workspace]/reports/page.tsx
import { evaluateFlags } from "@/lib/flags/evaluate";
import { getSession } from "@/lib/auth";
import { ReportsV2 } from "@/components/reports-v2";
import { ReportsLegacy } from "@/components/reports-legacy";

interface PageProps {
  params: Promise<{ workspace: string }>;
}

export default async function ReportsPage({ params }: PageProps) {
  const { workspace } = await params;
  const session = await getSession();

  const flags = await evaluateFlags({
    distinctId: session.userId,
    workspaceId: workspace,
    plan: session.plan,
  });

  return flags["reports-v2"] ? <ReportsV2 /> : <ReportsLegacy />;
}

Client components read the same values through a provider seeded from the server, which is what removes the flicker. React 19 lets you render a context directly as a provider and read it with use().

// components/flag-provider.tsx
"use client";

import { createContext, use, type ReactNode } from "react";
import type { FlagKey } from "@/lib/flags/registry";
import type { FlagMap } from "@/lib/flags/evaluate";

const FlagContext = createContext<FlagMap | null>(null);

export function FlagProvider({
  value,
  children,
}: {
  value: FlagMap;
  children: ReactNode;
}) {
  return <FlagContext value={value}>{children}</FlagContext>;
}

export function useFlag(key: FlagKey): boolean | string {
  const flags = use(FlagContext);
  if (!flags) throw new Error("useFlag must be used inside FlagProvider");
  return flags[key];
}

Render FlagProvider in your authenticated layout with the flags you already evaluated. One evaluation per request, server and client agreeing on the answer, no loading state.

Sweeping Dead Flags With Claude Code

Every flag you add is a branch someone has to read forever. The reason nobody removes them is that removal is fiddly: a guard here, a ternary there, an import that becomes unused, a component nothing renders anymore. That is a perfect job for a slash command.

<!-- .claude/commands/flag-sweep.md -->
---
description: Remove a shipped feature flag and its dead branch
argument-hint: <flag-key>
allowed-tools: Read, Edit, Grep, Glob, Bash(npx tsc:*), Bash(npm run lint:*), Bash(npm run build:*)
---

Remove the feature flag `$1`. It has shipped to 100 percent and is not coming back.

1. Confirm `$1` exists in lib/flags/registry.ts. If it does not, stop and say so.
2. Grep the whole repo for `$1` and list every file that references it.
3. For each reference:
   - requireFlag("$1") on an oRPC procedure: delete only that .use() line.
   - A server component branch: keep the enabled branch, delete the disabled
     branch, and delete the import that is now unused.
   - A useFlag("$1") call: keep the enabled branch, delete the hook call.
4. Delete the `$1` entry from lib/flags/registry.ts.
5. Add a migration that deletes rows for `$1` from feature_flag_overrides.
6. Delete any component file that is now referenced by nothing.
7. Run npx tsc --noEmit, npm run lint, and npm run build. Fix what breaks.
8. Report: files changed, lines removed, and the PostHog flag key to archive by hand.

Then removal is one line in the session.

/flag-sweep reports-v2

Step 4 is what makes this reliable. Deleting the registry entry turns every missed call site into a type error, so step 7 catches anything the grep missed instead of trusting that the grep was complete. The type checker is the verification pass.

A second command is worth writing for the audit. Point Claude at lib/flags/registry.ts, ask it to list every flag with kind: "release" and a createdAt more than 30 days old, and run it on the first of the month. You get a short list of flags to either sweep or consciously keep, which is the entire discipline.

Quality Gates Before You Ship

Three commands, every time, no exceptions.

npx tsc --noEmit
npm run lint
npm run build

Flag work touches types more than most changes, because adding or removing a registry key ripples through the guard, the evaluator, and every branch. The type check is not a formality here. It is the mechanism.

What an Orchestrated Pipeline Looks Like

A single Claude Code session handles a flag sweep well. Shipping a whole product means running that same discipline on every feature without remembering to. That is what the $29 Code Kit is: a harness on top of Claude Code that plans a feature, builds it, evaluates the output, tests it, and puts it through quality gates before it lands, so zero type errors, zero lint errors, and a clean build are the exit condition rather than something you hope to run. It is $29 one time with no subscription, and it sits on top of Claude Code, which needs its own paid Anthropic plan.


Flags are cheap to add and expensive to keep. Evaluate on the server so the browser never receives what it should not see, guard the API so the switch is real, store the exceptions in your own database with a reason attached, and delete the flag the week it wins.

Posted by @speedy_devv

Continue in Workflow

  • Commerce agentique : comment construire une app que les agents IA peuvent payer
    Un guide en français simple du commerce agentique en 2026 : ce que font x402, ACP et le Machine Payments Protocol, plus un pas-à-pas d'un week-end pour livrer une API payante que les agents IA peuvent acheter.
  • Bonnes pratiques Claude Code
    Cinq habitudes séparent les ingénieurs qui livrent avec Claude Code : les PRDs, les règles CLAUDE.md modulaires, les slash commands personnalisés, les resets /clear, et un état d'esprit d'évolution du système.
  • Le mode auto de Claude Code
    Un second modèle Sonnet examine chaque appel d'outil Claude Code avant qu'il s'exécute. Ce que le mode auto bloque, ce qu'il autorise, et les règles d'autorisation qu'il place dans tes paramètres.
  • Channels, Routines, Teleport, Dispatch
    Les quatre fonctionnalités Claude Code livrées par Anthropic en mars et avril 2026 qui transforment le CLI en une couche de coordination orientée événements, entre téléphone, web et desktop.
  • Claude Code 1M Context in Practice: When Bigger Isn't Better
    The 1M-token context window is GA at flat pricing, but bigger isn't always better. A decision framework, token-cost math, and when to use /compact, subagents, and dynamic workflows instead.
  • How to Build an Admin Dashboard With Claude Code
    Ship an internal admin panel with Claude Code: role-gated routes, a searchable users and orders table with pagination, impersonation-safe RLS, and metrics tiles pulled through a type-safe API.

More from Handbook

  • Principes de base de l'agent
    Cinq façons de construire des agents spécialisés dans le code Claude : Sous-agents de tâches, .claude/agents YAML, commandes slash personnalisées, personas CLAUDE.md, et invites de perspective.
  • L'ingénierie du harness agent
    Le harness, c'est toutes les couches autour de ton agent IA sauf le modèle lui-même. Découvre les cinq leviers de contrôle, le paradoxe des contraintes, et pourquoi le design du harness détermine les performances de l'agent bien plus que le modèle.
  • Patterns d'agents
    Orchestrateur, fan-out, chaîne de validation, routage par spécialiste, raffinement progressif, et watchdog. Six formes d'orchestration pour câbler des sub-agents Claude Code.
  • Meilleures pratiques des équipes d'agents
    Patterns éprouvés pour les équipes d'agents Claude Code. Prompts de création riches en contexte, tâches bien calibrées, propriété des fichiers, mode délégué, et correctifs v2.1.33-v2.1.45.

Arrête de tout configurer. Place à la construction.

Des templates SaaS avec orchestration IA.

Découvrez ce que nous construisons pour les entreprises →

Production Error Tracking

Wire Sentry into Next.js 16 the way it should be wired: instrumentation files, source maps that survive the Vercel build, ad-blocker tunneling, user and workspace tags on every event, and a Claude Code triage loop that opens the fix PR.

GitHub Actions

Wire Claude Code into GitHub Actions with real .github/workflows YAML: PR review on @claude mention, a scheduled review, secrets table, and the security gotchas.

On this page

What You Are Actually Building
Install and Wire the Server Client
A Typed Registry Is the Whole Trick
Give Every Request a Stable Identity
Evaluate Once Per Request
Percentage Rollouts That Do Not Split Teams
The Kill Switch Lives on the API
Per Workspace Overrides in Supabase
Gating a Page and Keeping the UI Honest
Sweeping Dead Flags With Claude Code
Quality Gates Before You Ship
What an Orchestrated Pipeline Looks Like

Arrête de tout configurer. Place à la construction.

Des templates SaaS avec orchestration IA.

Découvrez ce que nous construisons pour les entreprises →