MainSDK
Search⌘K
Interfaces

Developer experience

Feature flags

flags

Turn a feature on for some users, a market or a share of users, and change it while the app runs. Every instance gives the same answer for the same user.

Previewships as a beta APIPhase 1 · Core, public betaSpec flagsFree in your app, Apache-2.0

Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.

Stability The promise an interface ships with.

  • Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
  • Beta Usable in production; may change before it becomes stable.
  • Experimental May change or go away; needs an explicit opt-in in main.yaml.
Stability levels
Updated 2026-10-28

flags turns a feature on for some users, a market or a percentage of everyone, and you change it while the app runs. Reach for it for gradual rollouts, kill switches and per-market features.

At a glance

flags:
  driver: config

Calls

  • flags.enabled(name, ctx)
  • flags.value(name, fallback, ctx)

Guarantees

Whatever the driver.

  • The same answer for the same user on every instance
  • Reads memory only; no network call or disk read per check
  • A change applies with no restart and is audited like any config change

Drivers

4 drivers. Select one to see its line in main.yaml.

main.yamlConfig store (default)
flags:
  driver: config

Change this one line to swap providers; your calls stay the same.Config store integration

Codes

CodeWhere it appearsMeaning
MAIN-FLAGS-0001RuntimeError responses and logsA flag was evaluated with no definition in config; the fallback was served, logged once per name
MAIN-FLAGS-0002RuntimeError responses and logsA pushed flag definition is invalid; it was refused and the last good definition kept
MAIN-FLAGS-0003RuntimeError responses and logsA rule served a value of a different type from the fallback, or enabled got a non-boolean; the fallback was served, logged once per name

Features

Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.

FeaturePhaseMilestoneSpec ticket
Enabled and value from the config storePhase 10.3flags#flags-eval
Stable percentage rollout by userPhase 10.3flags#flags-rollout
Live updates and refusal of invalid definitionsPhase 10.3flags#flags-live
Target by planPhase 10.3entitlements#limits-flags

Quick start

Define the flag in your config store, under flags.<name>. With the files driver, config/flags.json holds it:

config/flags.json
{ "new-checkout": { "enabled": true, "rollout": 25 } }
main.yaml
flags:
  driver: config
app/api/checkout/route.ts
import { auth, flags } from "mainsdk";

export async function GET(req: Request) {
  const session = await auth.require(req);
  const useNew = flags.enabled("new-checkout", { user: session.user });   // synchronous: no await
  return Response.json({ checkout: useNew ? "new" : "classic" });
}

Configuration

main.yaml
flags:
  driver: config   # config (default) | openfeature (planned)

config is the only driver today. A flag's definition is the config value at flags.<name>:

flags.<name> in the config store
{
  "enabled": true,         // required; false turns the flag off for everyone, before any rule
  "rollout": 25,           // percent of users, 0 to 100, up to two decimals; default 100
  "salt": "new-checkout",  // optional; default is the flag name. Change it to reshuffle who is in
  "rules": [               // optional; checked in order, first match wins, before rollout
    { "user": ["u_123"], "serve": true },
    { "attr": "market", "in": ["in"], "serve": true },
    { "attr": "plan", "notIn": ["enterprise"], "serve": false }
  ],
  "value": "blue"          // optional; what flags.value() returns when the flag is on
}

What you get

A flag is a thin layer on config: its definition lives in the same store, arrives through the same change stream, passes the same checks and is audited the same way. flags.enabled() and flags.value() are synchronous and read memory, so a check makes no network call and waits on nothing. Every instance gives the same answer for the same user, so a user never flips between old and new as requests land on different instances.

How a check decides

  1. No definition: the flag is off, and flags.value() returns the fallback. The name is logged once.
  2. enabled: false: off, whatever the rules say.
  3. Rules, in order. The first rule that matches serves its serve value. A rule whose attribute is missing from the context does not match.
  4. Rollout. The user's bucket is the first four bytes of the SHA-256 of <salt>:<user>, modulo 10,000. The flag is on when the bucket is under rollout × 100. With no user, the flag is on only at 100.
  5. On: enabled returns true, and value returns the definition's value or the fallback. Off: false and the fallback.

The bucket depends only on the salt and the user id. Raising rollout keeps everyone who was already on. Changing the salt moves everyone at once.

Examples

Turn a feature on for one market first

flags.<name> in the config store
{ "enabled": true, "rollout": 5, "rules": [{ "attr": "market", "in": ["in"], "serve": true }] }
app/api/checkout/route.ts
import { flags } from "mainsdk";

export function checkoutFor(userId: string, market: string) {
  return flags.enabled("new-checkout", { user: userId, attrs: { market } }) ? "new" : "classic";
}

India gets the new checkout first, then 5% of everyone else. Raise rollout to 100 when it is ready; nobody who was on drops off.

Kill switch in an API route

app/api/imports/route.ts
import { auth, flags } from "mainsdk";

export async function POST(req: Request) {
  const session = await auth.require(req);
  if (!flags.enabled("imports", { user: session.user })) {
    return Response.json({ error: "Imports are paused" }, { status: 503 });
  }
  return new Response(null, { status: 202 });
}

Set flags.imports to { "enabled": false } in your config store and every instance stops accepting imports, with no deploy.

Read a typed value

A flag can carry a value. flags.value() returns it when the flag is on, and the fallback otherwise. The fallback sets the type.

app/lib/limits.ts
import { flags } from "mainsdk";

export function exportRowLimit(userId: string) {
  return flags.value("export.row-limit", 500, { user: userId });
}

Errors and checks

CodeWhenFix
MAIN-FLAGS-0001A flag was checked with no definition; the fallback was served. Logged once per nameDefine flags.<name> in the config store, or check the right name
MAIN-FLAGS-0002A pushed definition is invalid. It is refused, the last good definition stays, and the refusal is auditedFix the definition: enabled a boolean, rollout from 0 to 100 with at most two decimals, and each rule with exactly one of user, in or notIn
MAIN-FLAGS-0003A rule serves a value of another type from the fallback, or enabled gets a non-boolean. The fallback is served. Logged once per nameServe a value of the same type as the fallback

Evaluation writes no audit record per check and logs no user id or attribute. A flag log line carries only the flag name. A flag that is refused keeps its last good definition; see Config.

Swapping drivers

Flags use the config driver you already run, so changing config.driver moves the flag definitions with it. The openfeature driver is planned: it would keep definitions in LaunchDarkly, Flagsmith, Unleash, PostHog or Vercel Flags. Your flags.enabled() and flags.value() calls stay the same.

Testing

Share a MemoryConfigStore with the test app, set definitions at flags.<name>, and assert each answer with app.flags.enabled() or app.flags.value(). Evaluation is synchronous, so only a change needs await app.live.refresh().

app/flags.test.ts
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";
import { MemoryConfigStore } from "mainsdk";

describe("new-checkout", () => {
  it("is on for India and off elsewhere", () => {
    const store = new MemoryConfigStore();
    store.set("flags.new-checkout", { enabled: true, rollout: 0, rules: [{ attr: "market", in: ["in"], serve: true }] });
    const app = createTestApp({ yaml: "schema: 1\nproject: prj_test\nconfig:\n  driver: memory", configDriver: store });
    assert.equal(app.flags.enabled("new-checkout", { user: "u1", attrs: { market: "in" } }), true);
    assert.equal(app.flags.enabled("new-checkout", { user: "u1", attrs: { market: "us" } }), false);
  });
});

See the Testing kit.

Security and privacy

  • Evaluation logs only the flag name. User ids and attributes are never written to a log, an audit record or a provider.
  • Rollout buckets come from a hash of the salt and the user id, so no list of users is stored.
  • Definitions pass the same checks and audit as any config change.

FAQ

Can a flag call a remote service? No. Evaluation reads memory only: no network call, disk read or audit record per check.

Why not await flags.enabled()? It is synchronous. Awaiting it does nothing but add a tick.

How is this different from config? Config holds typed values for the whole app. Flags are on or off, or a value, per user, with rollouts and rules.

Target by plan

Inside a tenant, the context also carries the tenant's tenant id, tenant_kind, region and plan as attributes (attributes you pass win). A rule such as { "attr": "plan", "in": ["pro"], "serve": true } turns a feature on for pro and leaves it off for free without a code change. The plan comes from entitlements. For a feature that is part of a plan, prefer entitlements.require(): it refuses with an upgrade hint, where a flag only hides.

Next

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet