This guide covers how to use the Optimizely React SDK with Next.js for server-side rendering (SSR), static site generation (SSG), and React Server Components.
- Prerequisites
- SSR with Pre-fetched Datafile
- Next.js App Router
- Next.js Pages Router
- Using Feature Flags in Client Components
- Static Site Generation (SSG)
- Limitations
Install the React SDK:
npm install @optimizely/react-sdkYou will need your Optimizely SDK key, available from the Optimizely app under Settings > Environments.
Server-side rendering requires a pre-fetched datafile. The SDK cannot fetch the datafile asynchronously during server rendering, so you must fetch it beforehand and pass it to createInstance.
There are several ways to pre-fetch the datafile on the server. Below are two common approaches you could follow.
In the App Router, fetch the datafile in an async server component (e.g., your root layout) and pass it as a prop to a client-side provider.
Option A: Using the SDK's built-in datafile fetching (Recommended)
Create a module-level SDK instance with a polling config manager and use a notification listener to detect when the datafile is ready. This approach benefits from the SDK's built-in polling and caching, making it suitable when you want automatic datafile updates across requests.
// src/data/getDatafile.ts
import { createInstance, createPollingProjectConfigManager, NOTIFICATION_TYPES } from '@optimizely/react-sdk';
const pollingInstance = createInstance({
projectConfigManager: createPollingProjectConfigManager({
sdkKey: process.env.NEXT_PUBLIC_OPTIMIZELY_SDK_KEY || '',
}),
});
const configReady = new Promise<void>((resolve) => {
pollingInstance.notificationCenter.addNotificationListener(NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE, () =>
resolve()
);
});
export function getDatafile(): Promise<string | undefined> {
return configReady.then(() => pollingInstance.getOptimizelyConfig()?.getDatafile());
}Option B: Direct CDN fetch
Fetch the datafile directly from CDN.
// src/data/getDatafile.ts
const CDN_URL = `https://cdn.optimizely.com/datafiles/${process.env.NEXT_PUBLIC_OPTIMIZELY_SDK_KEY}.json`;
export async function getDatafile() {
const res = await fetch(CDN_URL);
if (!res.ok) {
throw new Error(`Failed to fetch datafile: ${res.status}`);
}
return res.json();
}Since OptimizelyProvider uses React Context (a client-side feature), it must be wrapped in a 'use client' component:
// src/providers/OptimizelyProvider.tsx
'use client';
import {
OptimizelyProvider,
createInstance,
createStaticProjectConfigManager,
createPollingProjectConfigManager,
createBatchEventProcessor,
OptimizelyDecideOption,
} from '@optimizely/react-sdk';
import { ReactNode, useState } from 'react';
export function OptimizelyClientProvider({ children, datafile }: { children: ReactNode; datafile: object }) {
const isServerSide = typeof window === 'undefined';
const [optimizely] = useState(() =>
createInstance({
projectConfigManager: isServerSide
? createStaticProjectConfigManager({ datafile })
: createPollingProjectConfigManager({
sdkKey: process.env.NEXT_PUBLIC_OPTIMIZELY_SDK_KEY || '',
datafile,
}),
eventProcessor: isServerSide ? undefined : createBatchEventProcessor(),
defaultDecideOptions: isServerSide ? [OptimizelyDecideOption.DISABLE_DECISION_EVENT] : [],
disposable: isServerSide,
})
);
return (
<OptimizelyProvider client={optimizely} user={{ id: 'user123', attributes: { plan_type: 'premium' } }}>
{children}
</OptimizelyProvider>
);
}You can also create the client at module level to avoid recreating the instance on re-renders. The trade-off is that the datafile must be resolved before the provider module is evaluated. One approach is to use globalThis to bridge the datafile between server and client: the server fetches the datafile, sets it on globalThis for server rendering, and injects a <script> tag so the client has it at module evaluation time.
Note: This is one approach to module-level client creation. Other strategies are also possible depending on your setup.
Both examples below reuse the getDatafile helper from Option A or Option B above.
App Router (layout.tsx)
The layout fetches the datafile, sets globalThis for the server render, and injects a <script> tag for the client.
// src/app/layout.tsx
import { getDatafile } from '@/data/getDatafile';
export default async function RootLayout({ children }: Readonly<{ children: ReactNode }>) {
const datafile = await getDatafile();
const serialized = JSON.stringify(datafile ?? '');
// Set on globalThis so the provider module can read it during server rendering
globalThis.__OPTIMIZELY_DATAFILE__ = datafile ?? '';
return (
<html lang="en">
<head>
<script
dangerouslySetInnerHTML={{
__html: `globalThis.__OPTIMIZELY_DATAFILE__ = ${serialized};`,
}}
/>
</head>
<body>{children}</body>
</html>
);
}Pages Router (_document.tsx)
In the Pages Router, use _document.tsx for the same purpose — fetch the datafile in getInitialProps, set globalThis for the server render, and inject the <script> tag.
// pages/_document.tsx
import Document, { Html, Head, Main, NextScript } from 'next/document';
import { getDatafile } from '@/data/getDatafile';
export default function MyDocument({ datafile }: { datafile: string }) {
const serialized = JSON.stringify(datafile ?? '');
return (
<Html>
<Head>
<script
dangerouslySetInnerHTML={{
__html: `globalThis.__OPTIMIZELY_DATAFILE__ = ${serialized};`,
}}
/>
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
MyDocument.getInitialProps = async (ctx) => {
const initialProps = await Document.getInitialProps(ctx);
const datafile = await getDatafile();
globalThis.__OPTIMIZELY_DATAFILE__ = datafile;
return { ...initialProps, datafile };
};Provider (module-level)
With globalThis.__OPTIMIZELY_DATAFILE__ available, the provider can create the client at module level with immediate datafile readiness:
// src/providers/OptimizelyProvider.tsx
'use client';
import {
OptimizelyProvider,
createInstance,
createPollingProjectConfigManager,
createBatchEventProcessor,
} from '@optimizely/react-sdk';
import { ReactNode } from 'react';
const optimizely = createInstance({
projectConfigManager: createPollingProjectConfigManager({
sdkKey: process.env.NEXT_PUBLIC_OPTIMIZELY_SDK_KEY || '',
datafile: globalThis.__OPTIMIZELY_DATAFILE__,
}),
eventProcessor: createBatchEventProcessor(),
});
export function OptimizelyClientProvider({ children }: { children: ReactNode }) {
return (
<OptimizelyProvider client={optimizely} user={{ id: 'user123', attributes: { plan_type: 'premium' } }}>
{children}
</OptimizelyProvider>
);
}See Server-Side Rendering in the README for an explanation of each option.
If you are using the module-level alternative, the layout already handles datafile injection — just wrap
{children}with<OptimizelyClientProvider>(nodatafileprop needed).
// src/app/layout.tsx
import { OptimizelyClientProvider } from '@/providers/OptimizelyProvider';
import { getDatafile } from '@/data/getDatafile';
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const datafile = await getDatafile();
return (
<html lang="en">
<body>
<OptimizelyClientProvider datafile={datafile}>{children}</OptimizelyClientProvider>
</body>
</html>
);
}If your project uses ODP audience segments, you can pre-fetch them server-side using getQualifiedSegments and pass them to the provider via the qualifiedSegments prop.
// src/app/layout.tsx
import { getQualifiedSegments } from '@optimizely/react-sdk';
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const datafile = await getDatafile();
const { segments } = await getQualifiedSegments('user-123', datafile);
return (
<html lang="en">
<body>
<OptimizelyClientProvider datafile={datafile} qualifiedSegments={segments}>
{children}
</OptimizelyClientProvider>
</body>
</html>
);
}Caching recommendation: The ODP segment fetch adds latency to initial page loads. Consider caching the result per user to avoid re-fetching on every request.
In the Pages Router, fetch the datafile server-side and pass it as a prop. There are three data-fetching strategies depending on your needs.
Same as the App Router provider above (without the 'use client' directive, which is not needed in Pages Router).
Choose the data-fetching strategy that best fits your use case:
Fetches the datafile for every page via _app.tsx. Useful when you want Optimizely available globally across all pages.
// pages/_app.tsx
import { OptimizelyClientProvider } from '@/providers/OptimizelyProvider';
import type { AppProps, AppContext } from 'next/app';
import { getDatafile } from '@/data/getDatafile';
export default function App({ Component, pageProps }: AppProps) {
return (
<OptimizelyClientProvider datafile={pageProps.datafile}>
<Component {...pageProps} />
</OptimizelyClientProvider>
);
}
App.getInitialProps = async (appContext: AppContext) => {
const appProps = await App.getInitialProps(appContext);
const datafile = await getDatafile();
return { ...appProps, pageProps: { ...appProps.pageProps, datafile } };
};Similar to the App Router example, if you have ODP enabled and want to pre-fetch segments, you can do the following:
import { getQualifiedSegments } from '@optimizely/react-sdk';
App.getInitialProps = async (appContext: AppContext) => {
const appProps = await App.getInitialProps(appContext);
const datafile = await getDatafile();
const { segments } = await getQualifiedSegments('user-123', datafile);
return { ...appProps, pageProps: { ...appProps.pageProps, datafile, segments } };
};Fetches the datafile per request on specific pages. Useful when only certain pages need feature flags.
// pages/index.tsx
export async function getServerSideProps() {
const datafile = await getDatafile();
return { props: { datafile } };
}Fetches the datafile at build time and revalidates periodically. Best for static pages where per-request freshness is not critical.
// pages/index.tsx
export async function getStaticProps() {
const datafile = await getDatafile();
return {
props: { datafile },
revalidate: 60, // re-fetch every 60 seconds
};
}Once the provider is set up, use the useDecide hook in any client component:
'use client';
import { useDecide } from '@optimizely/react-sdk';
export default function FeatureBanner() {
const { decision, isLoading } = useDecide('banner-flag');
if (isLoading) return <h1>Loading...</h1>;
return decision.enabled ? <h1>New Banner</h1> : <h1>Default Banner</h1>;
}For statically generated pages, the SDK cannot make decisions during the build because there is no per-user context at build time. Instead, use the SDK as a regular client-side React library — the static HTML serves a default or loading state, and decisions resolve on the client after hydration.
'use client';
import { useState } from 'react';
import {
OptimizelyProvider,
createInstance,
createPollingProjectConfigManager,
createBatchEventProcessor,
useDecide,
} from '@optimizely/react-sdk';
export function App() {
const [client] = useState(() =>
createInstance({
projectConfigManager: createPollingProjectConfigManager({ sdkKey: 'YOUR_SDK_KEY' }),
eventProcessor: createBatchEventProcessor(),
})
);
return (
<OptimizelyProvider client={client} user={{ id: 'user123' }}>
<FeatureBanner />
</OptimizelyProvider>
);
}
function FeatureBanner() {
const { decision, isLoading } = useDecide('banner-flag');
if (isLoading) return <h1>Loading...</h1>;
return decision.enabled ? <h1>New Banner</h1> : <h1>Default Banner</h1>;
}SSR with sdkKey alone (without a pre-fetched datafile) is not supported because it requires an asynchronous network call that cannot complete during synchronous server rendering. If no datafile is provided, decisions will fall back to defaults.
To handle this gracefully, render a loading state and let the client hydrate with the real decision:
'use client';
import { useDecide } from '@optimizely/react-sdk';
export default function MyFeature() {
const { decision, isLoading } = useDecide('flag-1');
if (isLoading) return <h1>Loading...</h1>;
return decision.enabled ? <h1>Feature Enabled</h1> : <h1>Feature Disabled</h1>;
}User Promise is not supported. You must provide a resolved user object to OptimizelyProvider. If user information must be fetched asynchronously, resolve the promise before rendering the Provider:
// Supported
<OptimizelyProvider client={optimizely} user={{ id: 'user123', attributes: { plan: 'premium' } }} />
// NOT supported
<OptimizelyProvider client={optimizely} user={fetchUserPromise} />ODP (Optimizely Data Platform) audience segments require fetching segment data via an async network call, which is not available during server rendering. To include segment data during SSR, pass pre-fetched segments via the qualifiedSegments prop on OptimizelyProvider:
<OptimizelyProvider
client={optimizely}
user={{ id: 'user123' }}
qualifiedSegments={['segment1', 'segment2']}
skipSegments={true}
>
{children}
</OptimizelyProvider>This enables synchronous ODP-based decisions during server rendering. If qualifiedSegments is not provided, decisions will be made without audience segment data — in that case, consider deferring the decision to the client using the loading state fallback pattern described above, where ODP segments are fetched automatically when ODP is enabled via createOdpManager.