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.
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.
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
@openfeature/server-sdk) ≥ 1.x installed@openfeature/web-sdk) ≥ 1.x installednamespace.service.featureschema (e.g.,web.checkout.express-pay)
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.
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:
// 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
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.
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.
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.
// 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.
// 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.
# 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.
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. Scope the bootstrap to the flags the page actually reads; do not dump the entire flag catalog into every response. 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.