Flags SDK
Declare flags with the Vercel Flags SDK and evaluate them from the flags block, per user, per tenant and per custom context such as a brand, with precompute and Flags Explorer.
better-supabase/flags-sdk connects the Flags SDK
to the flags block. You declare flags with flag()
from flags/next, and the adapter answers them from the flags table with
the same rules, overrides and rollout buckets as flag_enabled() in your
policies.
pnpm add flags
pnpm better-supabase sql add flagsflags is an optional peer (>=4.2 <5). The entry types the SDK
structurally and never imports it, so it adds nothing to a bundle that
doesn't use it, and a type test fails when a new flags release changes the
Adapter shape. The entry imports no Node built-ins, so it runs in
proxy.ts.
| Export | Does |
|---|---|
betterSupabaseAdapter | an adapter factory over the flags block, with one definitions cache for every flag |
createIdentify | an identify that returns the user, the tenant and custom contexts from verified claims |
getProviderData | Flags Explorer data for every flag that isn't archived |
flagOptions | a flag's variants as options, which a precomputed flag needs |
flagContextOf | the evaluation context the adapter builds from the entities |
The Next.js example wires all of them.
Declaring flags
betterSupabaseAdapter takes the options of createFlagsProvider: a
transport (with schema for the Data API) or fixed definitions, and a
ttl (30 seconds by default). It loads every flag with
flag_definitions(), which only the service role may call, so give it a
transport on the secret key. Never prefix that key with NEXT_PUBLIC_.
import "server-only";
import { createClient } from "@supabase/supabase-js";
import { rpcTransport } from "better-supabase/blocks";
import {
betterSupabaseAdapter,
createIdentify,
type FlagsSdkEntities,
} from "better-supabase/flags-sdk";
import { dedupe, flag } from "flags/next";
const service = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SECRET_KEY!,
{ auth: { persistSession: false, autoRefreshToken: false } },
);
export const bsFlags = betterSupabaseAdapter({
transport: rpcTransport(service),
schema: "api",
origin: "https://admin.acme.test/flags/{key}",
});
const identify = dedupe(
createIdentify({
claims: async ({ cookies }) => readVerifiedClaims(cookies),
contexts: ({ headers }) => ({ brand: brandOf(headers.get("host")) }),
}),
);
export const newEditor = flag<boolean, FlagsSdkEntities>({
key: "new_editor",
adapter: bsFlags(),
defaultValue: false,
identify,
});Every adapter the factory makes shares one cache and one adapterId, so
evaluate([...]) decides a batch from one load. A flag that is missing,
has another type or fails to load answers the declaration's
defaultValue, so a database outage turns features off instead of
breaking the page. Without a defaultValue, decide throws.
origin tells Flags Explorer where each flag is managed: a URL in which
{key} is replaced by the flag key, or a function of the key.
Identifying the caller
createIdentify builds on flagContext.
It returns these entities:
| Entity | Holds |
|---|---|
user | { id }, the user id from the claims |
organization | { id, plans, roles }, the active tenant with its features and roles |
brand, ... | { id } for each custom context, named as in blocks.flags.options.contexts |
claims must return claims you verified locally, such as from
getClaims() or bs.context(); identify runs for every flag and never
calls the Auth server. tenant picks the active tenant when the claims
don't carry it, and contexts adds custom context values from a header,
the host or a cookie. contextClaims from flagContext also works, such as
{ brand: "brand_id" }. Wrap the result in dedupe so it runs once per
request.
The entity names are the ones Vercel Flags targets (user.id,
organization.id, brand.id), so one identify serves both adapters. Move
a flag to Vercel Flags by changing its
adapter:
import { vercelAdapter } from "@flags-sdk/vercel";
export const checkoutTheme = flag<string, FlagsSdkEntities>({
key: "checkout_theme",
adapter: vercelAdapter(),
defaultValue: "classic",
identify,
});The flags block is still what policies check, so keep a flag that guards data in the flags block.
Precompute
A precomputed flag
is evaluated once in proxy.ts, and the request is rewritten to a static
page for that combination of values. List the flag's options so
generatePermutations knows every combination; flagOptions(definition)
builds them from a flag's variants.
import { precompute } from "flags/next";
import { pricingFlags } from "./flags";
if (path === "/pricing") {
const code = await precompute(pricingFlags);
return NextResponse.rewrite(new URL(`/pricing/${code}`, request.url));
}import { generatePermutations } from "flags/next";
import { pricingBanner, pricingFlags } from "@/flags";
export async function generateStaticParams() {
const codes = await generatePermutations(pricingFlags);
return codes.map((code) => ({ code }));
}
export default async function Pricing({
params,
}: PageProps<"/pricing/[code]">) {
const { code } = await params;
const banner = await pricingBanner(code, pricingFlags);
// ...
}precompute signs the code with FLAGS_SECRET, a random 32-byte base64url
string. Set it in every environment (openssl rand -base64url 32).
Precompute only flags with a few values per visitor: a brand, a plan, a rollout bucket. A per-user flag multiplies the permutations, and a static page built for one user's variant can reach another user. Read those flags dynamically.
Dashboard pages
On a signed-in page, read the flag at request time, as in the dashboard pages guide:
const showEditor = await newEditor();The flag only decides what the page shows. Keep the data behind it in a
policy with flag_enabled() or tenant_ids_with_flag(), so a request that
skips the page still meets the same flag:
create policy "drafts_insert" on public.drafts for insert to authenticated
with check (better_supabase.flag_enabled('new_editor', organization_id));The adapter and flag_evaluation() agree, with one difference: the adapter
never reads a custom context from the tenant table, so pass it through
identify when a rule targets it.
Flags Explorer
Flags Explorer in
the Vercel Toolbar reads the flags from a discovery route. getProviderData
lists every flag in the flags block that isn't archived, with its
description, options, origin and last change; merge it with the declared
flags:
import { getProviderData } from "better-supabase/flags-sdk";
import { mergeProviderData } from "flags";
import {
createFlagsDiscoveryEndpoint,
getProviderData as getDeclared,
} from "flags/next";
import { bsFlags, newEditor, pricingBanner } from "@/flags";
export const GET = createFlagsDiscoveryEndpoint(async () =>
mergeProviderData([
getDeclared({ newEditor, pricingBanner }),
await getProviderData(bsFlags),
]),
);The route answers only requests signed with FLAGS_SECRET. A failed load
shows as a hint in Explorer instead of an error. Exclude
/.well-known/vercel/ from the proxy matcher. An override set in the
toolbar applies before the adapter's decide.
Reporting exposures
flag() reports each value to Vercel Analytics. To send evaluations
elsewhere, wrap the adapter. This one sends PostHog's
$feature_flag_called event:
import type { FlagsSdkAdapter } from "better-supabase/flags-sdk";
function reported<V>(adapter: FlagsSdkAdapter<V>): FlagsSdkAdapter<V> {
return {
...adapter,
async decide(params) {
const value = await adapter.decide(params);
posthog.capture({
distinctId: params.entities?.user?.id ?? "anonymous",
event: "$feature_flag_called",
properties: {
$feature_flag: params.key,
$feature_flag_response: value,
},
});
return value;
},
};
}For OpenTelemetry, record a feature_flag.evaluation event with
feature_flag.key and feature_flag.result.value on the active span in the
same place.
Not built in
These are left to the app or to other tools for now:
- Reusable segments, such as a list of beta customers that many rules share. A rule lists its tenants, users, plans, roles and contexts itself.
- Self-serve previews that a tenant admin turns on. Build them as a flag with tenant overrides that your settings page writes.
- Scheduled changes. Run
flags.savefrom a job. - Approval before a change. A
savehook can require one (see extending blocks). - Experiments and metrics. Use PostHog or Statsig through their own Flags SDK adapters.
- Brands as a table of their own. A brand is a custom context read from your tenant table (see custom contexts).
Last updated on
eve on Supabase
Run eve agents on Supabase with the Workflow World on Postgres, Supabase sign-in on eve routes, connections over credential providers, memory, sessions in the chat tables and the inbox as a channel.
Build a ChatGPT clone
The modules, routes and components behind the example's /assistant and /knowledge pages, from a stored chat to tenant keys and metering.