React
Use Gates in a React SPA through OpenFeature's client stack: the Lucerna web provider (@lucerna-dev/gates-openfeature-web) plugs into @openfeature/web-sdk, and @openfeature/react-sdk layers hooks over it — no Lucerna-specific React package needed.
The provider follows OpenFeature's static-context paradigm, which is exactly how the Browser SDK underneath works: one identity-resolved decisions snapshot is fetched with the environment's publishable client key, every read answers synchronously from it, and a context change refetches. Targeting rules never reach the browser — only decisions do.
1. Get your client key
In the dashboard, open Settings → API keys and copy the client key (ck_client_…) — publishable, safe to embed, and it pins the environment. Secret keys (ck_srv_…, ck_key_…) are rejected at construction: a secret in a browser bundle is readable by anyone.
2. Install
pnpm add @lucerna-dev/gates-openfeature-web @lucerna-dev/gates-browser @openfeature/web-sdk @openfeature/react-sdknpm install @lucerna-dev/gates-openfeature-web @lucerna-dev/gates-browser @openfeature/web-sdk @openfeature/react-sdkbun add @lucerna-dev/gates-openfeature-web @lucerna-dev/gates-browser @openfeature/web-sdk @openfeature/react-sdk@lucerna-dev/gates-browser and @openfeature/web-sdk are peer dependencies of the provider.
3. Register the provider
Once, at startup. Set the context first so the initial snapshot is already targeted:
import { OpenFeature } from "@openfeature/web-sdk";
import { LucernaWebProvider } from "@lucerna-dev/gates-openfeature-web";
await OpenFeature.setContext({ targetingKey: user.id, plan: user.plan });
OpenFeature.setProvider(
new LucernaWebProvider({
clientKey: import.meta.env.VITE_LUCERNA_CLIENT_KEY,
onError: (error) => console.error("[gates]", error.message),
}),
);targetingKey becomes the Gates userId (the sticky-bucketing unit); other attributes become traits your targeting rules match on — the same context mapping as the server provider. Wire onError: reads never throw, so without it a misconfigured key serves safe defaults ("everything off") silently.
4. Read flags in components
Wrap the tree once, then use hooks — every read is synchronous from the snapshot:
import {
OpenFeatureProvider,
useBooleanFlagValue,
useStringFlagValue,
} from "@openfeature/react-sdk";
function App() {
return (
<OpenFeatureProvider>
<Billing />
</OpenFeatureProvider>
);
}
function Billing() {
const newBilling = useBooleanFlagValue("new_billing", false); // flag → boolean
const variant = useStringFlagValue("checkout_test", "control"); // experiment → variant name
return newBilling ? <NewBilling variant={variant} /> : <ClassicBilling />;
}Components re-render automatically when decisions change — a context switch, or a refetch. Boolean reads answer flags with kill switches folded in (a thrown switch reads false); string reads answer experiment variants.
Prefer no flash of defaults while the first snapshot loads? <OpenFeatureProvider suspendUntilReady> suspends children until the provider is ready — pair it with a <Suspense> boundary. The react-sdk also ships a declarative <FeatureFlag> component and details/suspense hook variants.
5. React to login and logout
Identity lives in the global context. When the user changes, set it again — the provider refetches decisions for the new identity and components re-render when the swap completes:
// login
await OpenFeature.setContext({ targetingKey: user.id, plan: user.plan });
// logout — back to an anonymous context
await OpenFeature.setContext({});Using @lucerna-dev/identity? Feed it into OpenFeature one-directionally and every identify(), trait() and reset() flows into a refetch:
identity.onChange((user) => {
void OpenFeature.setContext({ targetingKey: user.userId, ...user.traits });
});Options
new LucernaWebProvider(options);| Option | Default | What it does |
|---|---|---|
clientKey | — | required; the environment's publishable client key (ck_client_…) |
baseUrl | https://api.uselucerna.app | override for self-hosted or local development |
requestTimeoutMs | 5000 | per-request timeout |
readyTimeoutMs | 10000 | cap on how long initialize waits for the first snapshot before coming up on safe defaults |
onError | — | tap for bootstrap/refresh failures — resolvers never throw |
fetch | platform fetch | override the transport (tests, custom dispatchers) |
storage | — | optional decisions cache (e.g. localStorage) so the next page load reads instantly |
storageKey | lucerna:gates | cache key when storage is set |
Failure semantics
Resolvers never throw. Before the first snapshot — or when bootstrap fails — boolean reads answer safe false and string reads answer your default, while the error surfaces through onError. A rejected key (401/403) fails initialization loudly; a slow or offline network never blocks the app — the provider comes up on safe defaults at readyTimeoutMs and recovers on the next context change.
Decisions are UX hints
Decisions sent to a browser can be tampered with. Use them to show or hide UI — always re-check permissions on your backend.
Next
- OpenFeature introduction — why OpenFeature, and how Gates maps onto it.
- Browser SDK reference — the decisions engine underneath, and its native React bindings.