diff --git a/content/docs/web-analytics/configuration.mdx b/content/docs/web-analytics/configuration.mdx
index 0f3c980..6cdfb66 100644
--- a/content/docs/web-analytics/configuration.mdx
+++ b/content/docs/web-analytics/configuration.mdx
@@ -25,7 +25,8 @@ to the core and all built-in extensions.
## Extensions
-Error tracking, Web Vitals, and session replay are separate, tree-shakeable
+Error tracking, Web Vitals, session replay, and
+[feature flags](/web-analytics/feature-flags) are separate, tree-shakeable
imports. Outbound link tracking is part of the core and needs no setup. With React or JavaScript, create each extension and add
it to the `extensions` array.
diff --git a/content/docs/web-analytics/feature-flags.mdx b/content/docs/web-analytics/feature-flags.mdx
new file mode 100644
index 0000000..b1fb4ba
--- /dev/null
+++ b/content/docs/web-analytics/feature-flags.mdx
@@ -0,0 +1,137 @@
+---
+title: Feature Flags
+description: Evaluate FastStats feature flags in the browser
+icon: Flag
+---
+
+
+ Feature Flags are still in **Private Preview** please contact staff if you
+ would like to try them out.
+
+
+The feature flags extension loads flags for the visitor the analytics client
+tracks. After they load, flags are read synchronously from memory.
+
+## Enable Feature Flags
+
+Create the extension, keep a reference to it, and add it to `extensions`.
+
+```ts title="flags.ts"
+import { init } from "@faststats/web";
+import { featureFlags } from "@faststats/web/flags";
+
+export const flags = featureFlags({
+ flags: ["new_checkout", "theme", "max_items"],
+});
+
+init({
+ siteKey: "your_site_key",
+ extensions: [flags],
+});
+```
+
+Flags load as soon as tracking starts. They load again when the visitor is
+identified or logs out, and when consent switches between `"anonymous"` and
+`"granted"`. With consent `"denied"`, no flags are loaded and every flag returns
+its default.
+
+## Options
+
+| Option | Type | Default | Description |
+| ------------ | ------------------------- | ----------------------------- | ------------------------------------------------- |
+| `flags` | `string[]` | all flags | Flag keys this app reads |
+| `userId` | `string` | none | Your own ID for a signed-in user |
+| `attributes` | `Record` | `{}` | Values that targeting rules can match on |
+| `endpoint` | `string` | `https://flags.faststats.dev` | Base URL of the feature flags service |
+| `timeoutMs` | `number` | `3000` | Aborts a slow load so flags serve their defaults |
+| `fetch` | `typeof fetch` | global `fetch` | Custom fetch implementation, for example in tests |
+
+Every loaded flag is recorded as an exposure. List the keys your app reads in
+`flags` so flags the app never shows stay out of your analytics.
+
+## Read Flags
+
+`get` returns the flag's value, or `defaultValue` while flags are loading, when
+the flag does not exist or is archived, or when its type differs from the
+default's type.
+
+```ts
+import { flags } from "./flags";
+
+if (flags.isEnabled("new_checkout")) {
+ // Show the new checkout.
+}
+
+const theme = flags.get("theme", "light");
+const maxItems = flags.get("max_items", 10);
+```
+
+`isEnabled(key)` is the same as `get(key, false)`. Use `details(key)` to see
+why a flag has its value. It returns `undefined` for a flag that is not loaded.
+
+```ts
+const result = flags.details("new_checkout");
+// { value: true, variant: "on", reason: "rule_match" }
+```
+
+| Reason | Meaning |
+| --------------------- | ---------------------------------------------- |
+| `disabled` | The flag is turned off |
+| `prerequisite_failed` | A prerequisite flag did not match |
+| `target_match` | The visitor is targeted directly |
+| `rule_match` | A targeting rule matched |
+| `fallthrough` | No target or rule matched, so the default rule |
+
+## Wait for Flags
+
+Flags start out empty, so reads return their defaults until the first load
+finishes. Wait for `ready()` when a value must be correct before you render.
+
+```ts
+await flags.ready();
+renderCheckout(flags.isEnabled("new_checkout"));
+```
+
+`ready()` never rejects. Check `status` to tell a successful load from a failed
+one.
+
+| Status | Meaning |
+| --------- | -------------------------------------------------- |
+| `idle` | The client has not started tracking yet |
+| `loading` | The first load for the current visitor |
+| `ready` | Flags are loaded |
+| `error` | The load failed, so every flag returns its default |
+
+A failed load is logged when `debug` is on. Call `reload()` to try again.
+`reload()` never rejects either.
+
+## React to Changes
+
+`subscribe` calls a listener whenever the flags or `status` change, and returns a
+function that removes it.
+
+```ts
+const unsubscribe = flags.subscribe(() => {
+ updateCheckout(flags.isEnabled("new_checkout"));
+});
+```
+
+## Users and Attributes
+
+Rollouts follow the visitor's anonymous identifier. Once you call
+[`identify`](/web-analytics/identify), flags reload with the external ID as
+`userId`, so rollouts follow a signed-in user across devices. `logout` clears it
+again.
+
+Use `setContext` to change the user or attributes yourself. It loads the flags
+again and returns a promise that resolves when the load settles.
+
+```ts
+await flags.setContext({
+ userId: "user_123",
+ attributes: { plan: "pro", country: "DE" },
+});
+```
+
+`attributes` replaces the attributes set before. Pass `userId: null` to go back
+to the anonymous visitor.
diff --git a/content/docs/web-analytics/meta.json b/content/docs/web-analytics/meta.json
index b9d8e8f..27b8929 100644
--- a/content/docs/web-analytics/meta.json
+++ b/content/docs/web-analytics/meta.json
@@ -17,6 +17,7 @@
"error-tracking",
"sourcemaps",
"web-vitals",
- "session-replay"
+ "session-replay",
+ "feature-flags"
]
}