Server-Side Rendering Flag Consistency
This guide is part of the Frontend Integration & Client-Side Rendering overview. When a Next.js or Remix page renders on the server, flag values are resolved at that moment and baked into the HTML. The client SDK, loading asynchronously milliseconds later, must resolve the same values — or React will detect a mismatch between the server’s DOM and the client’s virtual DOM and either throw a warning, discard the server markup entirely, or flip the UI mid-paint. Preventing that requires discipline at each stage of the render pipeline.
The window in which this can go wrong is small but real: it spans from the moment the browser starts parsing the HTML to the moment hydration attaches event handlers to the existing DOM — typically 50–400ms depending on bundle size, device, and network. Within that window the page is visible but not yet interactive, and any flag value the client resolves differently from the server is a live inconsistency the user may see. The entire discipline below exists to guarantee that during this window the client answers every flag read with the server’s answer, byte for byte, and defers its own opinion until the DOM is stable.
Problem Framing: What Goes Wrong and When
The mismatch scenario plays out like this: the server evaluates web.checkout.express-pay as true for a user in the enterprise segment and renders the Express Pay button. The HTML arrives in the browser. React begins hydration. The client SDK hasn’t finished initializing — it is still fetching its configuration — so it returns the default value false. React compares the hydrated tree (no Express Pay button) against the server-rendered DOM (button present) and finds a mismatch. The behavior from that point depends on the React version and render mode: older versions silently discard the server HTML and re-render from scratch; newer versions throw a recoverable error and attempt to patch the DOM.
Either outcome is wrong. The discard path gives users a visible layout jump and wastes the server render entirely. The patch path leaves the page in an indeterminate state between the two variants for the duration of the patching.
It is worth being precise about which React versions do what, because the failure signature changes with them. Under React 17 and earlier, a hydration mismatch is fatal for the subtree: React throws away the server HTML for that entire tree and re-renders from scratch on the client, which is why a single mismatched flag deep in the page can blank and repaint a large region. React 18’s concurrent hydration is more forgiving — it logs a recoverable error and reconciles the differing nodes — but “recoverable” does not mean “invisible”: the user still sees the server variant flash and then snap to the client variant, and in <Suspense> boundaries the mismatch can force a full client re-render of the boundary. Neither version gives you a mismatch that is truly free, so the goal is always zero mismatches, not merely survivable ones.
There is also a subtler class of mismatch that no console warning will catch. If the server and client agree on the flag value but disagree on something derived from it — a formatted timestamp, a randomized experiment bucket, a Math.random() seed used for a rollout percentage — React sees identical markup and stays quiet, yet the two renders diverged for a reason that will bite you the next time the derived value changes. Treat the flag snapshot as the single source of any branch that affects markup, and derive nothing on the client that the server did not also derive.
This guide covers the mechanics of keeping those two evaluations in agreement. It does not cover the client SDK’s own update cycle after hydration (see React hooks for feature flag state) or the payload security model (see securely passing flags to the browser).
Prerequisites
Core Concept & Architecture
The consistency guarantee rests on a single invariant: the client SDK must be seeded from the same resolved values the server used, not from a fresh evaluation. A fresh client-side evaluation will almost always agree with the server for simple boolean flags — but only if the control plane returns the same targeting decision, the user context is identical, and no flags changed between the server render and the client evaluation. All three conditions can fail independently in production.
The bootstrap pattern eliminates the dependency on those conditions entirely. It works like this:
- The server evaluates every flag the page needs, for the authenticated user’s context.
- The resolved map (
{ "web.checkout.express-pay": true, "web.dashboard.new-nav": false }) is serialized into the HTML as an inlined JSON script tag. - The client SDK reads that map synchronously during initialization — before any component renders in the browser.
- Hydration proceeds with the SDK’s local state identical to what the server produced.
- The SDK begins its update cycle (polling or streaming) only after hydration completes.
Step 5 is important: delaying the first live fetch until after hydration means the SDK’s state cannot change mid-hydration. Any flag update that arrives during the hydration window is applied cleanly after the DOM is stable.
The reason this invariant holds where a fresh client evaluation does not is worth spelling out, because it is the whole argument for the extra bytes on the wire. A fresh evaluation reproduces the server’s answer only if three things are simultaneously true: the control plane returns the same targeting decision the server received, the evaluation context the client assembles is byte-identical to the server’s, and no flag was toggled in the interval between the two evaluations. In practice all three are fragile. The targeting decision can differ if the client and server hit different replicas of an eventually-consistent control plane. The context can differ if the client reconstructs the user from a cookie the server read from a session store, and the two disagree by even one attribute — a plan that was upgraded, a country inferred from a different signal. And the toggle can land in the sub-second window between renders during an active rollout, which is exactly when you are watching most closely. The bootstrap snapshot sidesteps all three by making the client replay a recorded answer rather than compute a fresh one.
A useful mental model is that the snapshot is a cache with a scope of exactly one render. It is authoritative for the duration of hydration and then deliberately discarded in favor of live values. You are not trying to keep the client frozen on the server’s answer forever — a flag that flips five seconds after load should absolutely update the UI — you are only trying to make the transition happen on a stable DOM, as an ordinary state update, rather than as a hydration collision.
Flag evaluation context on the server
The evaluation context must be fully assembled before the first flag read. For a Next.js App Router server component, that means reading the session, extracting the attributes the targeting rules need, and constructing the context object once. The single most common source of SSR flag drift is a context that is not deterministic — an attribute that is present on the server but absent (or differently typed) on the client. Anything you feed a targeting rule must be reconstructable on both sides from the same source of truth, which in practice means the cookie or the serialized session, never an in-memory value that only the server process holds.
Pay particular attention to the targetingKey. Percentage rollouts and consistent-bucketing rules hash this key to decide which variant a user gets, so if the server keys on a stable session_id and the client accidentally keys on undefined (because the cookie is httpOnly and unreadable from JavaScript), the two sides land in different buckets and a boolean flag flips even though the control plane and the rule are identical. When the identifier the server uses is httpOnly, the clean fix is to let the server own every rollout-gated evaluation and pass the resolved snapshot down — the client never needs the key because it never re-evaluates.
// lib/flag-context.ts
import { cookies } from 'next/headers';
import { EvaluationContext } from '@openfeature/server-sdk';
export async function buildFlagContext(): Promise<EvaluationContext> {
const cookieStore = cookies();
const sessionId = cookieStore.get('session_id')?.value ?? 'anon';
const plan = cookieStore.get('plan')?.value ?? 'free';
return {
targetingKey: sessionId,
plan,
// Never include raw PII — use a stable, opaque identifier
};
}
Resolving and serializing the snapshot
// app/layout.tsx — server component
import { OpenFeature } from '@openfeature/server-sdk';
import { buildFlagContext } from '@/lib/flag-context';
async function resolveBootstrapFlags() {
const client = OpenFeature.getClient('web');
const ctx = await buildFlagContext();
// Resolve every flag the page tree may read
const [newNav, expressPay, betaSearch] = await Promise.all([
client.getBooleanValue('web.dashboard.new-nav', false, ctx),
client.getBooleanValue('web.checkout.express-pay', false, ctx),
client.getStringValue('web.search.beta-mode', 'off', ctx),
]);
return { 'web.dashboard.new-nav': newNav, 'web.checkout.express-pay': expressPay, 'web.search.beta-mode': betaSearch };
}
Step-by-Step Implementation
The four steps enforce the invariant in order: resolve server-side, embed the snapshot, seed the client before hydration, and gate re-evaluation until after.
Step 1 — Resolve all page flags server-side before rendering
In the root server component (or getServerSideProps in the Pages Router), call the flag evaluation functions and collect the results into a plain object. Do not spread flag reads across individual components — a single resolution point guarantees a consistent snapshot.
// app/layout.tsx
import { OpenFeature } from '@openfeature/server-sdk';
import { FlagBootstrapProvider } from '@/components/flag-bootstrap-provider';
import { buildFlagContext } from '@/lib/flag-context';
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const ctx = await buildFlagContext();
const client = OpenFeature.getClient('web');
const flags = {
'web.dashboard.new-nav': await client.getBooleanValue('web.dashboard.new-nav', false, ctx),
'web.checkout.express-pay': await client.getBooleanValue('web.checkout.express-pay', false, ctx),
};
return (
<html lang="en">
<body>
<FlagBootstrapProvider flags={flags}>{children}</FlagBootstrapProvider>
</body>
</html>
);
}
Pitfall: resolving flags inside individual server components (rather than once at the root) allows different components to see different snapshots if a flag update lands between renders. Resolve once, at the root, and pass the result down.
A related trap in the App Router is that server components can suspend and resume independently. If two sibling components each call getBooleanValue and a streaming flag update lands between their resolutions, the page’s own server render is already internally inconsistent — before the client is even involved. Root-level resolution collapses the entire page tree to a single evaluation instant, which is the property you actually want: every branch on the page reflects the same moment in the flag configuration’s history. Use Promise.all for the reads so they resolve against one provider fetch rather than serializing several round-trips and widening the window during which a toggle could land.
Step 2 — Embed the resolved snapshot in the HTML
The FlagBootstrapProvider component renders a <script> tag with the serialized map, then makes the values available via React context. The script tag must appear before any flag-gated component.
// components/flag-bootstrap-provider.tsx
'use client';
import { createContext, useContext, useRef } from 'react';
import { OpenFeature } from '@openfeature/web-sdk';
type FlagMap = Record<string, boolean | string | number>;
const FlagContext = createContext<FlagMap>({});
export function FlagBootstrapProvider({
flags,
children,
}: {
flags: FlagMap;
children: React.ReactNode;
}) {
const initialized = useRef(false);
if (!initialized.current) {
// Initialize the client SDK from the snapshot synchronously — runs on both server and client
OpenFeature.setContext({ bootstrapFlags: flags });
initialized.current = true;
}
return (
<>
{/* Inline the snapshot so the client can read it even before JS executes */}
<script
id="__flag_bootstrap__"
type="application/json"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: JSON.stringify(flags) }}
/>
<FlagContext.Provider value={flags}>{children}</FlagContext.Provider>
</>
);
}
export function useBootstrapFlag<T extends boolean | string | number>(key: string, defaultValue: T): T {
const flags = useContext(FlagContext);
return (flags[key] as T) ?? defaultValue;
}
Pitfall: do not call JSON.stringify on the flags object inside a dangerouslySetInnerHTML attribute unless you have sanitized the values. All flag values must be primitive — booleans, strings, or numbers — never objects derived from user input. The specific hazard is a string flag value containing the sequence </script>, which the browser’s HTML parser will honor even inside a type="application/json" block, terminating the script tag early and letting whatever follows execute as markup. The standard mitigation is to escape < as < in the serialized output (JSON.stringify(flags).replace(/</g, '\\u003c')), which is safe inside JSON and inert inside HTML. The securely passing flags to the browser guide covers the full serialization threat model, including why you should never let a string flag carry user-controlled content into the snapshot at all.
Step 3 — Initialize the client SDK from the snapshot before hydration
The web SDK provider must receive the bootstrap values synchronously so its first getBooleanValue call returns the server-evaluated result. Providers that accept a bootstrap option (such as the flagd web provider) handle this directly. The word “synchronously” is load-bearing here: if the provider only becomes ready after an await that resolves on a later microtask, React may already have started hydrating flag-gated components against a not-yet-ready SDK, and those components will read defaults. This is why the bootstrap map is read from the inline <script> element with a synchronous document.getElementById and JSON.parse rather than fetched — the values are already in the DOM by the time any client code runs, so no asynchronous gap exists between “page parsed” and “flags available.”
If your provider does not expose a bootstrap option, you can achieve the same effect by seeding the OpenFeature evaluation context or an in-memory provider with the snapshot before calling setProvider, so that reads are served from memory until the real provider’s first fetch resolves. The mechanism differs by vendor; the requirement does not. What you must guarantee is that between first paint and hydration completion, every getBooleanValue returns the snapshot value with no await in the path.
// providers/client-flag-provider.ts
import { OpenFeature } from '@openfeature/web-sdk';
import { FlagdWebProvider } from '@openfeature/flagd-web-provider';
export async function initClientProvider() {
// Read the inline snapshot placed by FlagBootstrapProvider
const scriptEl = document.getElementById('__flag_bootstrap__');
const bootstrap = scriptEl ? JSON.parse(scriptEl.textContent ?? '{}') : {};
const provider = new FlagdWebProvider({
host: 'flagd.internal',
port: 8013,
bootstrap, // SDK serves from this map until the first live fetch completes
});
// setProvider returns before the first fetch — bootstrap keeps evaluation deterministic
await OpenFeature.setProvider('web', provider);
}
Step 4 — Gate re-evaluation until after hydration
Any flag update that arrives during the hydration window can cause a mid-hydration state change. Defer the live update subscription until React signals that hydration is complete. The useEffect hook is the correct signal because React guarantees effects run only in the browser and only after the committed DOM matches the server output — it is, by construction, the earliest point at which a state change can no longer collide with hydration. Do not be tempted to start streaming in a module-level side effect or a useLayoutEffect; useLayoutEffect fires before paint and, in some concurrent scenarios, before hydration has fully settled, which reopens the window you just closed.
// components/post-hydration-flag-sync.tsx
'use client';
import { useEffect } from 'react';
import { OpenFeature } from '@openfeature/web-sdk';
export function PostHydrationFlagSync() {
useEffect(() => {
// useEffect only runs in the browser, after hydration
OpenFeature.getProvider('web').then((provider) => {
provider.startStreaming(); // begin live updates now that hydration is done
});
}, []);
return null;
}
Verification & Testing
After wiring the bootstrap pattern, confirm that no hydration warning appears in the browser console and that the server- and client-rendered HTML match. A reliable way to force the failure mode during development is to throttle the network to “Slow 3G” in the browser devtools and reload: this stretches the hydration window wide enough that a missing bootstrap seed becomes a visible flash rather than a sub-perceptual blip. If the UI is rock-steady under throttling, it is steady on fast connections too. The inverse is the trap — a page that looks fine on a fast local network can be flashing badly for real users on mobile, because the mismatch is real either way and only the duration changes.
# Capture the server-rendered HTML and extract flag-gated content
curl -s http://localhost:3000/ | grep -o 'data-flag-variant="[^"]*"'
# Compare against a client-side evaluation
node -e "
const flags = JSON.parse(require('fs').readFileSync('/tmp/bootstrap.json'));
console.log(flags['web.checkout.express-pay']);
"
In automated tests, assert that the __flag_bootstrap__ script tag is present in every SSR response and that its contents match the evaluated values:
// __tests__/ssr-consistency.test.ts
import { render } from '@testing-library/react';
import RootLayout from '@/app/layout';
it('embeds bootstrap flags in the server response', async () => {
const html = await renderToString(<RootLayout>{null}</RootLayout>);
const match = html.match(/<script id="__flag_bootstrap__"[^>]*>([^<]+)<\/script>/);
expect(match).not.toBeNull();
const flags = JSON.parse(match![1]);
expect(typeof flags['web.checkout.express-pay']).toBe('boolean');
});
Troubleshooting & FAQ
React still shows a hydration warning after I added the bootstrap provider. What am I missing?
The most common cause is a flag evaluated inside a client component that does not read from the bootstrap context — it calls the SDK directly and gets the default value before the provider is ready. Audit every getBooleanValue or useFlag call in client components and confirm they read from the bootstrap context (via useBootstrapFlag) rather than the SDK directly during initial render.
The bootstrap payload is correct but the flag still flips on page load. Why?
The client SDK is completing its first live fetch before hydration finishes, and the live value differs from the bootstrap. This usually means the server and the live control plane are seeing different flag configurations — a backend evaluation staleness issue — or that the startStreaming call is not properly deferred to post-hydration.
How do I handle flags that need different values for different users on the same cached page?
A CDN-cached page cannot embed a user-specific bootstrap payload. Either bypass the cache for authenticated pages, use edge-evaluated personalized headers, or evaluate those flags client-side only (accepting that they will not be available during SSR). See edge and CDN flag delivery for the edge-evaluation approach.
Can I use this pattern with the Next.js Pages Router?
Yes. Resolve flags in getServerSideProps, pass them as props.flags, and initialize the client SDK in _app.tsx before the component tree renders. The invariant is the same: resolve once on the server, pass the snapshot to the client, seed the SDK before any component reads a flag.
Does this pattern work with Static Site Generation (SSG) or Incremental Static Regeneration (ISR)?
Only for flags whose value is the same for every visitor of a given cached variant. A statically generated page is rendered at build time (or at revalidation time for ISR), so the bootstrap it embeds is frozen at that moment and shared across all users who receive that cached HTML. That is fine for a flag that gates a feature globally, but it cannot carry a per-user targeting decision. For personalized flags on a static page, evaluate client-side only and accept there is no SSR value, or move to a request-time render. ISR narrows the staleness window to your revalidation interval but does not make the snapshot user-specific.
How do I keep the server SDK and web SDK from evaluating a flag differently in the first place?
Point both at the same flag definitions and make the evaluation logic identical. In the OpenFeature model this usually means both providers read from the same control plane and the same targeting rules, with the web provider simply consuming the server’s resolved snapshot rather than re-running the rules. Divergence creeps in when the two SDKs are different versions with different default-value semantics, or when one is configured with a different environment or namespace than the other. Pin compatible SDK versions and assert in a test that a known context produces the same result from both, so a config drift fails CI rather than a user’s hydration.
What happens if the bootstrap script tag is missing or malformed in the response?
The client seed falls back to whatever default your useBootstrapFlag or provider bootstrap path supplies — usually the flag’s coded default — which means every flag-gated component renders its off state and then flips once the live SDK connects. Because this looks exactly like a first-load flicker, make the failure loud: have the client-side initializer throw or emit a telemetry event when document.getElementById('__flag_bootstrap__') is null, and cover the tag’s presence with the automated test shown above so a refactor that drops it fails the build rather than shipping a silently flickering page.
Should timestamps, locale, or other request-derived values go through the same snapshot mechanism?
Yes, and for the same reason. Any value the server uses to branch markup and the client cannot reproduce deterministically is a hydration mismatch waiting to happen, whether or not it comes from a flag. A server-formatted date in the server’s timezone, a locale resolved from an Accept-Language header the client sees differently, or an experiment bucket seeded from a server-only random source all belong in the serialized snapshot alongside the flags. Treat the snapshot as the page’s complete record of every non-deterministic input the render depended on.
Performance & Scale Considerations
The bootstrap payload adds bytes to every HTML response. For most applications, 10–30 flags at 2–5 bytes each (for boolean values) is negligible — a few hundred bytes that gzip to almost nothing because the keys are repetitive namespace.service.feature strings that compress well. The cost only becomes real when teams treat the snapshot as a convenient dumping ground and inline the entire catalog: a mature product can carry several thousand flags, and serializing all of them into every response turns a trivial payload into tens of kilobytes on the critical rendering path, delaying first paint for a value 99% of routes never read. Scope the bootstrap to the flags the page actually reads; do not dump the entire flag catalog into every response.
The discipline that keeps the payload honest over time is to derive the flag list from the components a route renders rather than maintaining it by hand. A hand-curated list drifts: someone adds a flag read to a component, forgets to add it to the route’s bootstrap set, and reintroduces a default-then-flip flicker for that one flag. If your build can statically extract the flag keys referenced under a route — via a lint rule, a codegen step, or a typed registry — the snapshot stays exactly as wide as the render and no wider, automatically. If different page routes use different subsets of flags, resolve per-route rather than once for the entire app. At the CDN layer, the bootstrap payload is part of the response and therefore affected by cache invalidation strategies — a flag change that is not visible on the CDN-cached response will still be inconsistent even with a perfect client SDK bootstrap.