launchflag
v0.5.0
Published
LaunchFlag feature flags client. One key, one function, edge-safe, fails safe.
Maintainers
Readme
launchflag
One env key, one import, one line. Edge-safe, no dependencies, fails safe.
npx launchflag init --key lf_dev_… --url https://launchflag.devUsing pnpm? Run pnpm dlx launchflag init instead of npx.
One command: it installs this package, writes LAUNCHFLAG_KEY into .env.local, downloads the agent skill and
configures the MCP server. Leave --key and --url off and it asks. --no-install skips the install step.
This package is both the SDK and the launchflag CLI. launchflag-mcp is a separate package you only need if
you want the MCP server running locally instead of hosted.
import { flag } from "launchflag";
if (await flag("checkout_v2", user.id)) return <CheckoutV2 />;
return <CheckoutV1 />;Flags register themselves. Write the if, ship it dark, flip it on the dashboard.
The first time this code runs, checkout_v2 appears on your dashboard, off in dev, staging and prod, fail
closed. Until you turn it on, flag() returns false.
Manual setup
Rather do it by hand: npm i launchflag, then set LAUNCHFLAG_KEY (and LAUNCHFLAG_URL if you self-host)
yourself. Keys are on the dashboard Install page.
JSX
An async server component. React 19+, optional peer dependency.
import { Flag } from "launchflag/react";
<Flag name="checkout_v2" user={user.id} fallback={<CheckoutV1 />}><CheckoutV2 /></Flag>;Variants
A multivariate flag serves one arm per user, stable forever and split by the weights you set on the dashboard.
import { variant } from "launchflag";
const arm = await variant("pricing_test", user.id); // "a" | "b" | nullnull means the flag is off for this user, unknown, or boolean. Pass a third argument for a different fallback:
await variant("pricing_test", user.id, "a"). Same snapshot and cache as flag(), so it costs no extra request.
import { Variant } from "launchflag/react";
<Variant name="pricing_test" user={user.id} cases={{ a: <PriceA />, b: <PriceB /> }} fallback={<PriceA />} />;LaunchFlag serves the arm and records nothing else. Measure it wherever you already measure things.
Local evaluation
By default the SDK fetches one document, /api/config, and decides every flag in your process. One request per
ttlMs for the whole server, not per user, and it revalidates with If-None-Match so an unchanged config costs
nothing. First N targeting is the one exception: admission lives in our database, so those flags call /api/eval
and the answer is cached 60 seconds per user. Counts are buffered and posted to /api/counts every 10 seconds, or
on flush(). Pass mode: "remote" to evaluate server-side instead, one snapshot per user.
Explicit config
import { createLaunchFlag } from "launchflag";
const lf = createLaunchFlag({ key: process.env.LAUNCHFLAG_KEY!, baseUrl: "https://flags.acme.com", defaults: { checkout_v2: false } });
await lf.flag("checkout_v2", user.id);
await lf.flag("new_nav", user.id, true); // third argument: used only when nothing is known
// lf.variant("pricing_test", user.id) → the arm · lf.all(user.id) → every flag
// lf.why("checkout_v2", user.id) → { enabled, reason, variant } · lf.flush() · lf.clear()Client components
If you have a server, it already knows the answers. Evaluate there and hand the result down: no key in the browser, no request from the browser, nothing to flicker.
// app/layout.tsx, a server component
import { snapshot } from "launchflag";
import { LaunchFlagProvider } from "launchflag/client";
export default async function Layout({ children }) {
const flags = await snapshot(["checkout_v2", "pricing_test"], user.id);
return <LaunchFlagProvider snapshot={flags}>{children}</LaunchFlagProvider>;
}"use client";
import { useFlag, useVariant } from "launchflag/client";
export function Checkout() {
const on = useFlag("checkout_v2"); // second argument is the fallback, false by default
const arm = useVariant("pricing_test", "a");
return on ? <CheckoutV2 arm={arm} /> : <CheckoutV1 />;
}snapshot(keys, user) returns { flags, variants }, plain JSON, evaluated from the same cached config as
flag(). Name the keys and only those reach the browser; leave keys off for every flag in the project.
With no provider mounted, useFlag returns its fallback and useVariant returns null. A missing provider
in one corner of an app is not worth a white screen.
This works anywhere React renders on a server: Next App Router and Pages Router, Remix, TanStack Start,
Astro React islands. The two async server components in launchflag/react are unchanged and still work.
Without React
The snapshot is plain data on purpose. Svelte, Vue and everything else do the same thing in a few lines, so there is no package from us to install.
// server: evaluate, then serialise it into the page however your framework passes data down
const flags = await snapshot(["checkout_v2"], user.id);// browser: read it back
const flags = JSON.parse(document.getElementById("lf")!.textContent!);
if (flags.flags.checkout_v2) …That is the whole contract: { flags: Record<string, boolean>, variants: Record<string, string | null> }.
Static sites and SPAs
A static build has no server at runtime, so it evaluates in the browser against a publishable key. Not at build time: flipping a flag would need a rebuild, and a kill switch you have to rebuild is not a kill switch.
import { createBrowserClient } from "launchflag/browser";
export const lf = createBrowserClient({
key: import.meta.env.VITE_LAUNCHFLAG_PUBLIC_KEY, // lfpub_prod_…, the only key that may be in browser code
user: currentUserId, // optional, the bucketing seed
defaults: { checkout_v2: false }, // what renders until the document arrives
});
lf.flag("checkout_v2"); // synchronous, never throws
lf.variant("pricing_test", "a");
lf.identify(user.id); // after sign-in, re-evaluates locally with no requestIt fetches GET /api/config/public?key=… once on start, evaluates every flag locally with the same engine
the server runs, keeps the last good document in localStorage, and revalidates on a TTL (60s by default)
and whenever the tab comes back into view. Nothing here is awaited on the render path.
With React, hand it to the same provider and the hooks re-render when the document lands:
<LaunchFlagProvider source={lf}>{children}</LaunchFlagProvider>createBrowserClient is also a getSnapshot / subscribe pair, which is all a Svelte store or a Vue ref
needs. npx launchflag init --public-key lfpub_prod_… writes the key with the prefix your bundler needs.
Three things to know before you expose a flag
1. An exposed flag is published. Its key, whether it is on, and its rules all go into a document any visitor can read. Exposure is per flag and off by default: a flag appears in the public document only once you turn its browser exposure on in the dashboard. Do not expose a flag whose name gives away something you have not announced.
2. User targeting is spoofable in a browser. The user id comes from the page, so anyone can claim to be
anyone. Percentage rollout is fine, because bucketing is deterministic and the seed is not secret, and a
spoofer only moves themselves. Everything else is a request, not a fact: a client-side flag is a UI hint
and never an authorisation boundary. Gate the API, not the button. Allowlist flags cannot be exposed at
all, because the addresses would have to ship with them, and first_n flags evaluate to off in a browser
because admission lives in our database. Both stay server-side.
3. There is a first paint before the config arrives. On a first visit the browser paints before the
fetch resolves, so it renders defaults (and false for anything not in defaults). Then the document
lands and anything that changed re-renders: that is the flash people ship by accident. Three ways out, in
order of preference. Set defaults to the state you would rather show, which is usually the old one, so
the flash only hits users who are actually in the rollout. Return visits are already covered, because the
cached document answers before the first paint. Or, if a flag decides the whole page, hold the render on
await lf.ready and show your normal loading state, but never do that for a flag inside a list or a nav.
The fail-safe story
- No call at all, most of the time. The rule set is cached for
ttlMsand shared by every user, so a render costs zero requests. Concurrent calls share one fetch. - A slow API never blocks you. Requests abort at
timeoutMs. - A down API never flips a flag. A failed fetch keeps serving the last known config; with nothing cached,
flag()returnsdefaults[key], then its third argument, elsefalse.flag(),variant(),snapshot()andflush()never throw. all()andwhy()do throw, and only when they could not read the API. They report what the server holds, so an empty answer from them would be indistinguishable from a project with no flags, which is the reading that sends people off to register everything twice. Nothing on the eval path is affected.- A
truefallback also creates the flag on. The first time we see a key,flag(key, user, true)(ordefaults: { key: true }) creates it enabled in every environment. That is what you want when the code you are wrapping is already live: born off means the flag appearing is what switches your shipped feature off. It applies only to a key we have never seen; a flag already on your dashboard keeps whatever a person set. LAUNCHFLAG_KEYis a secret. Callflag()from Server Components, route handlers or middleware only. Importinglaunchflagfrom browser code resolves to a stub that throws and tells you which entry you wanted. For client components uselaunchflag/client, and with no server at alllaunchflag/browser.- The browser client fails the same way. A dead or blocked API serves the last good document, then
defaults, then the fallback you pass. Reads are synchronous and never throw, so nothing it does can break a render.
