Feature flags as a Convex component — backend-evaluated and streamed to every connected client in real time: boolean kill-switches, multivariate variants, percentage rollouts, attribute targeting, and per-subject overrides, with no redeploy and no third-party flag SaaS.
const flags = new Flags(components.flags);
await flags.define(ctx, { key: "new-checkout", value: false });
await flags.enable(ctx, "new-checkout");
const on = await flags.isEnabled(ctx, "new-checkout", { subjectRef: userId }); // reactive in a query- Boolean, multivariate & rollouts — boolean kill-switches, string/number variants, and weighted percentage rollouts with stable per-subject bucketing.
- Attribute targeting — ordered rules (
eq,in,contains,gt, …) over a host-supplied typed context. - Per-subject overrides — force any flag's value for one subject.
- Lifecycle — archive (reversible) distinct from permanent delete.
- Real-time — evaluated in Convex queries, so changes stream to clients over the same reactive subscriptions as your app data.
- Data isolation — definitions and overrides live in the component's own sandboxed tables; no blast radius into your schema.
- Reactive React hooks — optional
./reactentry (useFlag/useFlags); backend-only consumers pull in zero React. - Auth-agnostic & dual-target — the host gates every management call; runs unchanged on Convex Cloud and self-hosted
convex-backend.
npm install @vllnt/convex-flagsRegister the component in your app's convex/convex.config.ts:
import { defineApp } from "convex/server";
import flags from "@vllnt/convex-flags/convex.config";
const app = defineApp();
app.use(flags);
export default app;Peer dependency: convex@^1.36.1. react is an optional peer dependency, needed only for the ./react entry.
// convex/flags.ts — instantiate once, then re-export the public function refs the
// rest of your app (and the React hooks) call. The host owns auth: gate the
// management mutations here before delegating to the component.
import { v } from "convex/values";
import { Flags } from "@vllnt/convex-flags";
import { components } from "./_generated/api";
import { mutation, query } from "./_generated/server";
export const flags = new Flags(components.flags);
// Evaluation queries — reactive; `api.flags.evaluate` is what the React hooks below use.
export const evaluate = query({
args: { key: v.string(), context: v.optional(v.any()), default: v.optional(v.any()) },
handler: (ctx, args) =>
flags.evaluate(ctx, args.key, { context: args.context, default: args.default }),
});
export const all = query({
args: { context: v.optional(v.any()) },
handler: (ctx, args) => flags.all(ctx, args.context),
});
// Management mutations — apply your own auth check before delegating.
export const define = mutation({
args: { key: v.string(), value: v.any() /* + description/variants/rules/rollout */ },
handler: (ctx, args) => {
// assertAdmin(ctx);
return flags.define(ctx, args);
},
});
// Wrap the rest the same way — `update`, `enable`, `disable`, `archive`,
// `restore`, `setOverride`, `clearOverride` — each gated then delegated.// Flags are data — create them from your own authorized mutations (host owns auth),
// then evaluate inside any query, mutation, or action.
import { query } from "./_generated/server";
import { flags } from "./flags";
// A targeted rollout: pro users get the new checkout; everyone else is a 50/50 split.
export const setup = async (ctx) => {
await flags.define(ctx, {
key: "new-checkout",
value: false,
rules: [{ conditions: [{ attribute: "plan", op: "eq", values: ["pro"] }], value: true }],
rollout: { splits: [{ value: true, weight: 50 }, { value: false, weight: 50 }] },
});
};
export const checkout = query({
args: {},
handler: async (ctx) =>
(await flags.isEnabled(ctx, "new-checkout", { subjectRef: "user-123", attributes: { plan: "free" } }))
? loadNewCheckout(ctx)
: loadLegacyCheckout(ctx),
});| Method | Kind | Result |
|---|---|---|
isEnabled(ctx, key, context?) |
query | boolean (value === true) |
evaluate(ctx, key, options?) |
query | FlagEvaluation (value + reason) |
variant(ctx, key, options?) |
query | the resolved value |
get(ctx, key) / list(ctx) |
query | FlagDoc | null / FlagDoc[] |
all(ctx, context?) |
query | Record<string, FlagEvaluation> (reactive bootstrap) |
define(ctx, definition) |
mutation | Create or fully replace a flag (value, variants, rules, rollout) |
update(ctx, key, patch) |
mutation | Partially update a flag — only supplied fields change, the rest are kept |
enable(ctx, key) / disable(ctx, key) |
mutation | Toggle a boolean flag (sets value true/false) |
archive(ctx, key) / restore(ctx, key) |
mutation | Reversibly retire / re-activate a flag |
setOverride(ctx, key, subjectRef, value) / clearOverride(ctx, key, subjectRef) |
mutation | Force / clear a per-subject value |
remove(ctx, key) |
mutation | Hard-delete a flag and its overrides |
evaluate resolves in order: override → archived → targeting rules → fallthrough rollout → flag
value; an unknown key serves the caller default or false. enable/disable are boolean
convenience (they set the value to true/false) — use define/update for multivariate flags.
Full reference, types, operators, and the React hooks: docs/API.md.
Optional reactive hooks via the ./react entry (react is an optional peer dependency). Pass the
evaluate query you exported from convex/flags.ts above — api.flags.evaluate:
import { useFlag } from "@vllnt/convex-flags/react";
import { api } from "../convex/_generated/api";
// api.flags.evaluate is the query exported in convex/flags.ts (Usage, above).
const checkout = useFlag(api.flags.evaluate, "new-checkout", { context: { subjectRef: userId } });
if (checkout?.value === true) { /* render the new checkout */ }| Hook | Returns |
|---|---|
useFlag(query, key, options?) |
FlagEvaluation | undefined |
useFlags(query, context?) |
Record<string, FlagEvaluation> | undefined |
- Auth-agnostic — all management mutations are internal; wrap them in your own authorized mutations and apply auth there.
- Flag keys are opaque host-chosen strings; the component never interprets or validates their structure.
- Tables are sandboxed — the component never reads host or sibling tables.
See docs/API.md.
pnpm test # single run
pnpm test:coverage # enforced 100% on covered filesTests run against the real component runtime via convex-test (@edge-runtime/vm), not mocks.
See CONTRIBUTING.md.
Built by bntvllnt · bntvllnt.com · X @bntvllnt
Part of the @vllnt Convex component fleet — vllnt.com
If this is useful, sponsor the work.
MIT