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.
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 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; a flag rule can still target plans.
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
variants maps a variant name to its value. A boolean flag has the variants
on (true) and off (false) by default:
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:
- A disabled flag (
enabled = false) or an archived one returns its default variant. - A user override, then a tenant override, then a custom context override in the order the contexts are declared.
- 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) andcontexts(a context name to its values, every one must match). - The rollout: the first 32 bits of SHA-256 of
flag.target, modulo 10000, belowrollout_percentage * 100returnsrollout_variant. The target is the user id, or the tenant id when there is no user. - The default variant.
The reason in each result follows OpenFeature: DISABLED,
TARGETING_MATCH, SPLIT or DEFAULT.
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:
blocks: {
"feature-flags": {
options: {
contexts: {
brand: { table: "public.organizations", column: "brand_id" },
},
},
},
},Rules and overrides then name the context:
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
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
createFeatureFlags manages flags for platform staff with flags.manage (the
module's manage permission, checked with is_platform() when the
access 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:
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
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):
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
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:
blocks: {
"feature-flags": {
options: {
extraColumns: { owner: "text", expires_at: "timestamptz" },
},
},
},With an expires_at column, better-supabase doctor
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
flag_enabled(key, tenant) is true when the flag resolves to true for the
caller in that tenant:
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:
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
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:
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:
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) => ....
flagContext({ jwtClaims: ctx.jwtClaims }, { membershipScope: "organization" });Without the OpenFeature SDK, createFlagClient({ transport, errorCodes })
returns the four get*Details methods that withOpenFeature calls (see
middleware). evaluateFlag and
flagBucket are exported for tests and for flags defined in code
(createFeatureFlagsProvider({ definitions })).
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).
Last updated on
Billing
Stripe customers per tenant, Checkout and the customer portal, seat sync from membership events, and Stripe webhook handling that links customers and refreshes sessions.
Comments
Comments on any record as its conversation on the messaging core, with replies, mentions, resolved threads, pins, edit history, anchors, files and comments turned into tasks.