# Feature flags

> Feature flags per tenant, per user and per custom context such as a brand, with targeting rules, overrides and percentage rollouts, evaluated the same way in RLS, in an OpenFeature provider and in the Flags SDK.

Source: https://bettersupabase.com/docs/blocks/feature-flags

The `feature-flags` block stores feature flags in Postgres and evaluates them in
three places: `flag_enabled()` in policies and functions, an OpenFeature
provider, and a [Flags SDK](/docs/flags-sdk) adapter in the app. All of them
use the same rules and the same SHA-256 bucketing, so a user who sees a
feature in the UI also passes the policy behind it.

Flags decide what a tenant or user sees while a feature rolls out. What a
tenant pays for stays in [entitlements](/docs/blocks/entitlements); a flag
rule can still target plans.

```bash
better-supabase add feature-flags   # adds tenant as well
```

| Table            | Holds                                                                                                                                |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `flags`          | `key`, `type`, `description`, `variants`, `default_variant`, `enabled`, `archived`, `rules`, `rollout_percentage`, `rollout_variant` |
| `flag_overrides` | A variant for one `organization_id`, one `user_id` or one custom context value (`context`, `context_key`)                            |

Only the service role reads and writes both tables. Manage flags from a
migration, the Studio or an admin route that runs as the service role.

## Defining flags [#defining-flags]

`variants` maps a variant name to its value. A boolean flag has the variants
`on` (`true`) and `off` (`false`) by default:

```sql
insert into better_supabase.flags (key, rules, rollout_percentage, rollout_variant) values
  ('new_editor', '[{"variant": "on", "plans": ["pro"]}, {"variant": "on", "roles": ["admin"]}]', 10, 'on');

insert into better_supabase.flags (key, type, variants, default_variant) values
  ('checkout_theme', 'string', '{"classic": "classic", "compact": "compact"}', 'classic');

insert into better_supabase.flag_overrides (flag_key, organization_id, variant)
  values ('new_editor', '8d1c...', 'on');
```

A flag resolves in this order:

1. A disabled flag (`enabled = false`) or an archived one returns its
   default variant.
2. A user override, then a tenant override, then a custom context override
   in the order the contexts are declared.
3. The first rule whose lists all contain the caller. A rule lists any of
   `tenants`, `users`, `roles` (the caller's role in the tenant), `plans`
   (entitlement lookup keys, one match is enough) and `contexts` (a context
   name to its values, every one must match).
4. The rollout: the first 32 bits of SHA-256 of `flag.target`, modulo 10000,
   below `rollout_percentage * 100` returns `rollout_variant`. The target is
   the user id, or the tenant id when there is no user.
5. The default variant.

The reason in each result follows OpenFeature: `DISABLED`,
`TARGETING_MATCH`, `SPLIT` or `DEFAULT`.

### Custom contexts [#custom-contexts]

A custom context targets flags by a value that isn't a tenant or a user,
such as the brand a white-label app serves. Declare each one with the table
and column that hold a tenant's value; `tenant` names the column with the
tenant id and defaults to `id`, for a column on the tenant table:

```ts title="better-supabase.config.ts"
blocks: {
  "feature-flags": {
    options: {
      contexts: {
        brand: { table: "public.organizations", column: "brand_id" },
      },
    },
  },
},
```

Rules and overrides then name the context:

```sql
update better_supabase.flags
  set rules = '[{"variant": "on", "contexts": {"brand": ["acme"]}}]'
  where key = 'new_editor';

insert into better_supabase.flag_overrides (flag_key, context, context_key, variant)
  values ('new_editor', 'brand', 'globex', 'off');
```

`flag_evaluation(key, tenant, user, contexts)` takes the values as
`{"brand": "acme"}`. A declared context it leaves out is read from the
tenant, and only for a flag whose rules or overrides name a context, so
`flag_enabled()` in a policy targets brands without extra arguments. The
OpenFeature provider and the Flags SDK adapter never read the tenant table;
pass the values through `flagContext({ contexts })`, `contextClaims` or the
Flags SDK `identify`.

### Archiving a flag [#archiving-a-flag]

`upsert(key, { archived: true })` archives a flag: it serves its default
variant like a disabled flag, and Flags Explorer no longer lists it. Archive
a flag once no code reads it, and delete it later, so a deploy that still
checks it gets the default instead of an unknown flag.

### Managing flags from an admin page [#managing-flags-from-an-admin-page]

`createFeatureFlags` manages flags for platform staff with `flags.manage` (the
module's `manage` permission, checked with `is_platform()` when the
[access](/docs/blocks/permissions) module is installed) or the service role. It
calls `list_flags`, `save_flag`, `delete_flag` and `set_flag_override`,
which are granted to `authenticated`, so an app that only reaches the
database through the Data API manages flags with `blocks.feature-flags.api`
and `rpcTransport`:

```ts
import {
  createFeatureFlags,
  rpcTransport,
} from "better-supabase/blocks/feature-flags";

const admin = createFeatureFlags({
  transport: rpcTransport(supabase, { schema: "api" }),
});

await admin.upsert("new_editor", {
  rolloutPercentage: 25,
  rolloutVariant: "on",
});
await admin.override("new_editor", { organizationId }, "on");
await admin.override(
  "new_editor",
  { context: "brand", contextKey: "acme" },
  "on",
);
await admin.override("new_editor", { userId }, null); // removes it
const flags = await admin.list().orThrow();
```

`upsert` creates the flag or updates the fields you pass and keeps the rest.
Anyone else gets `FLAGS_FORBIDDEN`.

### Your own fields and hooks [#your-own-fields-and-hooks]

`blocks.feature-flags.options.extraColumns` adds columns to the flags table, such as
an owner or a ticket. `upsert` writes them from `fields`, and `list`, `upsert`
and `flag_definitions()` return them under `fields`. Pass a Standard Schema
as `fields` to type them, parse them on reads and validate them on `upsert`,
and `hooks` to run code around a method (see
[extending blocks](/docs/extending/blocks)):

```ts
import { z } from "zod";

const admin = createFeatureFlags({
  transport: rpcTransport(supabase, { schema: "api" }),
  fields: z.object({ owner: z.string().email() }),
  hooks: {
    upsert: {
      after: (result, [key]) => console.info("flag saved", key, result.ok),
    },
  },
});

await admin.upsert("new_editor", { fields: { owner: "ana@acme.test" } });
```

### Owner and expiry [#owner-and-expiry]

Give every flag an owner and a date by which it should be gone, so release
flags don't pile up. Add the columns as extra columns:

```ts title="better-supabase.config.ts"
blocks: {
  "feature-flags": {
    options: {
      extraColumns: { owner: "text", expires_at: "timestamptz" },
    },
  },
},
```

With an `expires_at` column, [`better-supabase doctor`](/docs/cli/doctor#bs336)
reads the live database and warns about each flag past it that isn't
archived (BS336), naming the owner when the table has one. Remove the
flag's checks from the code, then archive it, or move `expires_at` when the
flag stays. `expiredFlagsQuery(layout, installed)` from
`better-supabase/sql` returns the query doctor runs, for your own report.

## In policies [#in-policies]

`flag_enabled(key, tenant)` is true when the flag resolves to `true` for the
caller in that tenant:

```sql
create policy "drafts_insert" on public.drafts for insert to authenticated
  with check (better_supabase.flag_enabled('new_editor', organization_id));
```

`flag_enabled` evaluates the flag for each row it checks. For a policy that
reads many rows, `tenant_ids_with_flag(key)` returns the caller's tenants
where the flag is on, and Postgres evaluates it once per statement:

```sql
create policy "drafts_read" on public.drafts for select to authenticated
  using (organization_id in (select better_supabase.tenant_ids_with_flag('new_editor')));
```

`flag_evaluation(key, tenant, user)` returns `{ value, variant, reason }` for
any flag type, or null for an unknown flag. It is granted to the service role.

## With OpenFeature [#with-openfeature]

`createFeatureFlagsProvider` returns an object shaped like an OpenFeature server
`Provider`. It loads every flag with `flag_definitions()` (service role only)
and caches them for `ttl` milliseconds (default 30000). This package never
imports OpenFeature; pass its `ErrorCode` enum so the types match:

```ts title="lib/flags.ts"
import { ErrorCode, OpenFeature } from "@openfeature/server-sdk";
import {
  createFeatureFlagsProvider,
  sqlTransport,
} from "better-supabase/blocks/feature-flags";

const provider = createFeatureFlagsProvider({
  transport: sqlTransport(postgres.admin),
  errorCodes: ErrorCode,
});
await OpenFeature.setProviderAndWait(provider);
export const flags = OpenFeature.getClient();
```

A row whose `type` is not one the table allows resolves to the default
with `PARSE_ERROR`, or `GENERAL` when the `errorCodes` map has no
`PARSE_ERROR`.

`flagContext(ctx)` builds the evaluation context from verified claims: the
user id as `targetingKey`, the tenant (`tenant_id`, then
`app_metadata.tenant_id`), its plan features from the `features` claim and
the caller's role from the `memberships` claim:

```ts
import { flagContext } from "better-supabase/blocks/feature-flags";

const details = await flags.getBooleanDetails(
  "new_editor",
  false,
  flagContext({ jwtClaims: ctx.jwtClaims }),
);
```

The `memberships` claim can have either shape: an object of tenant id to
role, as the `memberships` module's hook writes, or a list of
`{ scope, id, roles }` entries, as an authorization provider's hook may
write. For a list,
`flagContext` reads the entry whose `id` is the tenant and that has no
`within` (the root scope), or the entry of `membershipScope` when you set it.
A caller with several roles gets the first as `role` and all of them as
`roles`, and a rule's `roles` list matches any of them. For a claim of
another shape, pass `roles: (claims, tenant) => ...`.

```ts
flagContext({ jwtClaims: ctx.jwtClaims }, { membershipScope: "organization" });
```

Without the OpenFeature SDK, `createFlagClient({ transport, errorCodes })`
returns the four `get*Details` methods that `withOpenFeature` calls (see
[middleware](/docs/auth/middleware#feature-flags)). `evaluateFlag` and
`flagBucket` are exported for tests and for flags defined in code
(`createFeatureFlagsProvider({ definitions })`).

## Reference [#reference]

| Export                             | Does                                                                                                                      |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `createFeatureFlagsProvider(opts)` | An OpenFeature-shaped provider over `flag_definitions()`, or fixed `definitions`; `definitions()` returns the cached list |
| `createFlagClient(source)`         | `getBooleanDetails`, `getStringDetails`, `getNumberDetails`, `getObjectDetails`                                           |
| `flagContext(ctx, opts?)`          | The evaluation context from `jwtClaims`; claim names are options                                                          |
| `evaluateFlag(flag, ctx)`          | `{ value, variant, reason }`, as `flag_evaluation()` computes it                                                          |
| `flagBucket(key, target)`          | The rollout bucket, 0 to 9999                                                                                             |
| `createFeatureFlags(opts)`         | `list`, `upsert`, `remove` and `override` for platform staff or the service role                                          |

The provider follows the OpenFeature specification pinned in
`SPEC_PINS.openfeature` (see [standards](/docs/standards)).