Skip to content
zap.ts
Esc
navigateopen⌘Jpreview
On this page

Flags

Feature flags in the browser and on the server, decided by PostHog through the Flags SDK.

@zap-ts/flags gives the app feature flags. A feature flag is a switch. You turn a feature on for some people, and off for the rest, without shipping new code.

The package has two export paths. @zap-ts/flags/client gives you four React hooks, to read a flag inside a component. @zap-ts/flags/server gives you an Effect service, to read a flag during a request.

PostHog decides the answer in both cases. The package talks to it through @flags-sdk/posthog, the PostHog adapter of the Flags SDK.

How it relates to the analytics package

Flags and analytics are two packages on one PostHog project. They share POSTHOG_KEY and POSTHOG_HOST. They share nothing else. Neither one depends on the other.

But they do meet in the browser. The four hooks come straight from @posthog/react. They read PostHog from React context. So AnalyticsProvider from @zap-ts/analytics/client must sit above them in the React tree. The same provider that names the user for events also lets PostHog decide a flag for that user. Remove the provider, and the hooks have nobody to ask.

On the server there is no such link. @zap-ts/flags/server builds its own PostHog adapter from the same two variables. It does not need ServerAnalyticsLive.

One simple way to remember the difference: analytics sends facts out, and flags read decisions in.

In a component

import { useFeatureFlagEnabled } from "@zap-ts/flags/client";

const Dashboard = () => {
  const isNew = useFeatureFlagEnabled("new-dashboard");

  return isNew ? <NewDashboard /> : <OldDashboard />;
};

There are four hooks:

  1. useFeatureFlagEnabled gives you true or false.
  2. useFeatureFlagVariantKey gives you the name of the variant. A flag can have more than two answers, and each one is a variant.
  3. useFeatureFlagPayload gives you the extra data attached to the flag.
  4. useFeatureFlagResult gives you the whole result.

The package re-exports these hooks without changing them. So they work exactly as PostHog describes them. That includes one point to watch: a hook returns undefined while the flags are still loading. Treat that as “not on yet”. Do not treat it as a real answer.

On the server

Flags is a tag. A tag is a name that stands for a service you provide later. This one holds the PostHog adapter. FlagsLive is the layer that builds it, with createPostHogAdapter. A layer is the recipe that makes a service real.

import { evaluateFlag, FlagsLive } from "@zap-ts/flags/server";
import { Effect } from "effect";

const program = evaluateFlag({
  key: "new-dashboard",
  defaultValue: false,
  entities: { distinctId: user.id },
  request,
});

const enabled = await Effect.runPromise(program.pipe(Effect.provide(FlagsLive)));

evaluateFlag takes four things:

  1. key is the name of the flag in PostHog.
  2. defaultValue is the answer to use when PostHog gives none back. It also sets the type of the result.
  3. entities says who the flag is for. Put the user id in its distinctId field. PostHog cannot decide a flag until it knows who is asking.
  4. request is the request of the current server function. The package reads its cookies and headers, and passes them to the adapter.

The Flags SDK has a flag() helper, but only for Next.js and SvelteKit. This app uses neither. So the package calls the adapter itself, and reads the cookies and headers from the request. That is why you pass the request in.

Does the call to PostHog fail? Then you get a FlagsError. It carries the original error in its cause field. So an outage is a value your code can handle, not a crash. Note the difference: an empty answer falls back to defaultValue, but a failed call does not.

Every flag is decided by PostHog

The adapter asks PostHog every time. One decision is one call over the network.

That choice is on purpose, and packages/flags/src/server.ts says why. PostHog can also copy the flag rules into memory and decide there. But that copy needs a job that keeps asking PostHog for changes. That is a good trade on a server that runs for days. It is a bad one on compute that starts and stops per request, such as Cloudflare Workers. There, each new instance would start its own job instead of sharing one.

Do you move the app to a long-running server, and do you read flags very often? Then this is the choice to look at again.

Environment

POSTHOG_KEY=
POSTHOG_HOST=https://eu.i.posthog.com

These are the same two variables the analytics package uses. The root .env.schema file declares them once.

POSTHOG_KEY must start with phc_. The server layer reads it with requireEnv. So if PostHog is not set up, the error comes when you read a flag. POSTHOG_HOST is required, and points at the EU cloud by default.

See Environment to learn how varlock turns those declarations into the typed ENV that the code imports.

Last updated on September 22, 2026

Was this page helpful?