Bootstrap Client-Side Flags
TL;DR: Pass
bootstrapto the browser or React SDK. Give it an object of values your server already evaluated, or the string'localStorage', and the client is ready before it has made a single request.
A client-side SDK with no bootstrap knows nothing until its first call to the Evaluation API returns. So the page waits out that round trip, or it paints every flag at its default and swaps in the real value a few hundred milliseconds later. Server-rendered pages get it worse. The HTML React hydrates says one thing, the client’s first render says another, and the console logs a hydration warning to go with the flash.
Bootstrapping hands the client its starting values up front. It counts as initialized the moment FeatureflipClient.get() returns, initialize() resolves immediately, and your first render reads real values. Behind that, the SDK fetches and opens its stream exactly as before. Then the server’s values land. They replace the bootstrap set, and every flag whose value moved fires change.
You can bootstrap from your server or from the browser’s own storage:
| Object of values | 'localStorage' | |
|---|---|---|
| Where the values come from | Your server, on this request | This browser, on its previous visit |
| First visit | Correct values | Defaults, as without a bootstrap |
| Server-rendered HTML | Matches the first client render | Can differ from it, so expect hydration warnings |
| Best for | SSR frameworks (Next.js, Remix, Nuxt, SvelteKit, Astro) | Client-rendered apps |
Bootstrap from your server
Section titled “Bootstrap from your server”While your server renders the page, a server SDK evaluates the flags and the page carries the results down to the browser. The example below is the Node SDK inside an Express route. Every server SDK has an equivalent boolVariation-style call that takes a context, so the same shape works in Python, Go or PHP.
import express from 'express';import { FeatureflipClient } from '@featureflip/node';
const flags = FeatureflipClient.get({ sdkKey: process.env.FEATUREFLIP_SERVER_KEY! });await flags.waitForInitialization();
const app = express();
app.get('/', (req, res) => { const context = { user_id: req.userId, plan: req.plan };
// Evaluate locally, in memory. No request leaves the server. const bootstrap = { 'new-checkout': flags.boolVariation('new-checkout', context, false), 'banner-text': flags.stringVariation('banner-text', context, ''), };
res.send(`<!doctype html><html> <body> <div id="app"></div> <script> window.__FLAGS__ = ${JSON.stringify(bootstrap).replace(/</g, '\\u003c')}; window.__FLAG_CONTEXT__ = ${JSON.stringify(context).replace(/</g, '\\u003c')}; </script> <script type="module" src="/main.js"></script> </body></html>`);});The replace(/</g, '\\u003c') call keeps a flag value containing </script> from closing the tag early. If your framework ships its own helper for serializing data into a page, use that.
The browser then gets both objects:
import { FeatureflipClient } from '@featureflip/browser';
const client = FeatureflipClient.get({ clientKey: 'your-client-sdk-key', context: window.__FLAG_CONTEXT__, bootstrap: window.__FLAGS__,});
client.boolVariation('new-checkout', false); // the server's value, before any requestclient.initialize(); // resolves at once; the fetch and stream carry on in the backgroundKeep the two contexts identical
Section titled “Keep the two contexts identical”Evaluate for one user on the server and a different one in the browser, and the browser overwrites the server’s values the moment its own response arrives. Pass the exact context you evaluated with into the page and give it to the client unchanged.
Anonymous visitors take one more step. When the browser SDK gets a context with no user_id, it generates an anonymous ID and keeps it in localStorage, and your server has no way to know that ID. A percentage rollout can then put the same visitor in one bucket on the server and the other bucket in the browser. Set an anonymous ID cookie on the server and use it as user_id on both sides.
Only bootstrap client-visible flags
Section titled “Only bootstrap client-visible flags”A server SDK evaluates every flag in the project. The browser only receives the ones marked client-side visible. Embed a flag outside that set and its value sits in your page source where anyone can read it. It also won’t survive. The server’s response leaves it out, so the SDK drops it and your code falls back to the default.
At the edge
Section titled “At the edge”A Cloudflare Worker can do the server’s half with @featureflip/edge. It evaluates against a copy of your flag config cached inside the Worker, and bootstrapValues(context) returns every client-visible flag in one call, so there’s no hand-kept list of keys to drift out of date.
import { createEdgeClient, type EdgeClient } from '@featureflip/edge';
interface Env { FEATUREFLIP_SERVER_KEY: string;}
let flags: EdgeClient | undefined;
const serialize = (value: unknown) => JSON.stringify(value).replace(/</g, '\\u003c');
export default { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { flags ??= createEdgeClient({ sdkKey: env.FEATUREFLIP_SERVER_KEY });
const cookie = request.headers.get('Cookie')?.match(/(?:^|;\s*)ff_visitor=([^;]+)/)?.[1]; const visitorId = cookie ?? crypto.randomUUID(); const context = { user_id: visitorId };
const snapshot = await flags.load(ctx); const bootstrap = snapshot.bootstrapValues(context);
const html = `<!doctype html><html> <body> <div id="app"></div> <script> window.__FLAGS__ = ${serialize(bootstrap)}; window.__FLAG_CONTEXT__ = ${serialize(context)}; </script> <script type="module" src="/main.js"></script> </body></html>`;
const headers = new Headers({ 'Content-Type': 'text/html; charset=utf-8' }); if (!cookie) { headers.append( 'Set-Cookie', `ff_visitor=${visitorId}; Path=/; Max-Age=31536000; Secure; HttpOnly; SameSite=Lax`, ); } return new Response(html, { headers }); },};The browser side is the same main.ts as in the Node example.
Only flags marked client-side visible make it into bootstrapValues. That enforces the previous section’s rule for you. An Evaluation API old enough to send no visibility marker gets an empty object back, since it can’t say which flags are safe to expose. Failed evaluations are dropped too. For those keys the browser SDK serves its own default.
The ff_visitor cookie is the anonymous ID cookie from keeping the two contexts identical. The edge SDK never invents a visitor key, so the Worker writes one on the first visit and puts the same user_id in the page.
Flag changes reach the Worker within ttlSeconds (60 seconds by default). In that window a first paint can still show the old value, and the browser’s own fetch corrects it a moment later. A cold isolate with nothing cached is the other gap. Its bootstrapValues is empty and the page paints with your defaults. The Cloudflare Workers SDK reference covers both cases.
Bootstrap from the last visit
Section titled “Bootstrap from the last visit”Set bootstrap: 'localStorage' and the SDK saves the values it receives, then serves them on the next page load until fresh ones arrive.
const client = FeatureflipClient.get({ clientKey: 'your-client-sdk-key', context: { user_id: currentUser.id }, bootstrap: 'localStorage',});First visits look the same as with no bootstrap. On the second visit the client starts out ready, holding last time’s values, and the server’s replace them shortly after. The SDK rewrites storage after every update from the server, streamed ones included, so what it saved is the newest set this browser saw.
How the SDK handles storage:
- Values are stored per client key and context. Sign out and back in as someone else and you never see the first user’s values. After
identify(), saves go to the new context’s entry. - Five contexts are kept. Once a sixth is saved, the one used least recently is dropped.
- Context attributes stay out of storage. Each entry is named by a hash of the client key and context. Flag values are stored as received.
- Storage failures are silent. Private browsing, a full quota or a sandboxed iframe each behave like a first visit, and nothing throws.
- Server SDK keys are never persisted. A server key in browser code is already a mistake, so the SDK logs a warning and skips storage entirely.
Avoid 'localStorage' on pages your server renders. The server can’t read the browser’s storage and renders defaults, while the browser’s first render uses what it stored. That mismatch is the hydration warning you were trying to get rid of. Bootstrap those pages from the server.
FeatureflipProvider takes the same option as a bootstrap prop. Give it values and useFeatureFlag returns them on the first render, with useFeatureflipStatus() already reporting isReady: true.
import { FeatureflipProvider, useFeatureFlag } from '@featureflip/react';
export function App({ flags, context }: { flags: Record<string, unknown>; context: Record<string, unknown> }) { return ( <FeatureflipProvider clientKey="your-client-sdk-key" context={context} bootstrap={flags}> <Checkout /> </FeatureflipProvider> );}
function Checkout() { const newCheckout = useFeatureFlag('new-checkout', false); return newCheckout ? <NewCheckout /> : <LegacyCheckout />;}In Next.js, evaluate flags and context in a Server Component or getServerSideProps and pass them to the Client Component that renders the provider. The Next.js guide covers the server half in detail.
On the server, the provider renders with a separate client that serves the bootstrap values and nothing else. It sends no requests. Nothing is shared between requests either, so two users rendered at the same instant can’t see each other’s flags. Outside React, FeatureflipClient.forServerRender(config) returns that same kind of client.
Knowing which values you’re serving
Section titled “Knowing which values you’re serving”Read client.flagSource to see where the current values came from:
| Value | Meaning |
|---|---|
'bootstrap' | The object you passed to bootstrap. The server hasn’t answered yet |
'localStorage' | Values saved on a previous visit. The server hasn’t answered yet |
'server' | The server’s values, from the initial fetch or the stream |
'none' | Nothing yet, so every call returns its default |
The ready event carries the same value as { source }. It fires once per client. With a bootstrap that happens when you call initialize(), and without one it waits for the first fetch.
Some code needs the server’s answer even when bootstrap values exist. For that, pass waitForServer:
await client.initialize({ waitForServer: true });client.flagSource; // 'server', unless the fetch failedReads of bootstrap values get reported to Featureflip like any other read, which keeps stale flag detection accurate about the flags your code uses. Until the server’s values arrive, evaluation inspectors see the reason 'bootstrap' or 'persisted'. A value from a bootstrap object has no variation key at all, since the object holds bare values.