React hooks and components for using Confidence feature flags in a modern React (RSC) App, like for instance Next.js.
This integration provides:
- Server Component (
ConfidenceProvider) - Resolves flags on the server and provides them to client components - Server Functions (
getFlag,getFlagDetails) - Evaluate flags directly in server components with immediate exposure - Client Hooks (
useFlag,useFlagDetails) - Access flag values in client components with automatic or manual exposure logging - Dot notation - Access properties within flag values (e.g.,
my-flag.enabled,my-flag.config.limit) - Manual exposure control - Delay exposure logging until user interaction (client-side only)
- Full flag details - Access variant, reason, and error information
Confidence flags are always structured objects containing one or more properties. Use dot notation to access specific values:
// Flag "checkout-flow" with value: { enabled: true, maxRetries: 3, theme: "dark" }
const enabled = useFlag('checkout-flow.enabled', false);
const maxRetries = useFlag('checkout-flow.maxRetries', 1);
const theme = useFlag('checkout-flow.theme', 'light');
// Or get the entire flag object
const checkoutConfig = useFlag('checkout-flow', { enabled: false, maxRetries: 1, theme: 'light' });yarn add @spotify-confidence/openfeature-server-provider-local reactUse Next.js's instrumentation.ts hook to register the provider once at server startup:
// instrumentation.ts (at the project root, or in src/ if you use a src folder)
export async function register() {
if (process.env.NEXT_RUNTIME !== 'nodejs') return;
const { OpenFeature } = await import('@openfeature/server-sdk');
const { createConfidenceServerProvider } = await import('@spotify-confidence/openfeature-server-provider-local');
const provider = createConfidenceServerProvider({
flagClientSecret: process.env.CONFIDENCE_FLAG_CLIENT_SECRET!,
});
await OpenFeature.setProviderAndWait(provider);
}register runs once when a Next.js server instance boots and is awaited before requests are served. It does not run during next build, so it's safe to perform network setup here.
In your layout or page (Server Component):
// app/layout.tsx
import { ConfidenceProvider } from '@spotify-confidence/openfeature-server-provider-local/react-server';
export default async function RootLayout({ children }: { children: React.ReactNode }) {
// Get user context from session, cookies, etc.
const context = {
targetingKey: 'user-123',
country: 'US',
};
return (
<html>
<body>
<ConfidenceProvider context={context}>{children}</ConfidenceProvider>
</body>
</html>
);
}// components/FeatureButton.tsx
'use client';
import { useFlag } from '@spotify-confidence/openfeature-server-provider-local/react-client';
export function FeatureButton() {
// Access the 'enabled' property of the 'new-feature' flag
const enabled = useFlag('new-feature.enabled', false);
if (!enabled) return null;
return <button>New Feature</button>;
}ConfidenceProvider resolves flags on the server with apply=false and returns a bundle that includes a resolve token (used at apply time to record exposure). The token contains the full evaluation context (targeting key and any attributes you passed in) and the resolved variant for each flag, and it is not encrypted by this provider.
In the React/Next.js flow this provider handles that for you:
- The resolve token is stripped from the bundle before it is serialized to the browser, so it never appears in the RSC payload.
- The
expose()server action keeps the original token in its closure on the server. Next.js 14+ encrypts variables captured in server-action closures, so the token also isn't exposed via the action reference.
If you call provider.resolve(..., apply=false) directly (outside the React integration) and pass the token through your own transport, the security note still applies — see the Integration Guide: Deferred Apply and Resolve Token Security.
Exposure is the event that tells Confidence a user was shown a particular flag variant. This is critical for accurate experiment analysis.
When using getFlag or getFlagDetails from react-server, exposure is logged immediately when the flag is evaluated:
// app/page.tsx (Server Component)
import { getFlag } from '@spotify-confidence/openfeature-server-provider-local/react-server';
export default async function Page() {
// Exposure is logged immediately when this evaluates
const showNewLayout = await getFlag('page-layout.showNewLayout', false, { targetingKey: 'user-123' });
return showNewLayout ? <NewLayout /> : <OldLayout />;
}This is appropriate for server components because:
- The component only renders once (no hydration)
- If the flag value affects what's rendered, the user will see it
- There's no concept of "mounting" - evaluation equals exposure
When using hooks from react-client, you have two options:
Exposure is logged when the component mounts (via useEffect):
'use client';
import { useFlag } from '@spotify-confidence/openfeature-server-provider-local/react-client';
function MyComponent() {
// Exposure logged on mount
const enabled = useFlag('my-feature.enabled', false);
return enabled ? <Feature /> : null;
}Use { expose: false } to control exactly when exposure is logged:
'use client';
import { useFlagDetails } from '@spotify-confidence/openfeature-server-provider-local/react-client';
function MyComponent() {
// No exposure logged automatically
const { value: showPromo, expose } = useFlagDetails('promo-banner.show', false, { expose: false });
const handleClick = () => {
if (showPromo) {
expose(); // Log exposure only when user clicks
openPromoModal();
}
};
return <button onClick={handleClick}>Open Promo</button>;
}Manual exposure is useful when:
- A feature is only shown after user interaction
- You want to avoid counting users who never actually see the feature
- The flag controls something that may not be immediately visible
Resolves flags on the server and provides them to client components via React Context.
import { ConfidenceProvider } from '@spotify-confidence/openfeature-server-provider-local/react-server';
<ConfidenceProvider
context={{ targetingKey: 'user-123' }}
flags={['checkout-flow', 'promo-banner']} // Optional: specific flags to resolve
providerName="my-provider" // Optional: if using named providers
>
{children}
</ConfidenceProvider>;Props:
| Prop | Type | Required | Description |
|---|---|---|---|
context |
EvaluationContext |
Yes | User/session context for flag evaluation |
flags |
string[] |
No | Specific flags to resolve (default: all flags) |
providerName |
string |
No | Named provider if not using the default |
children |
React.ReactNode |
Yes | Child components that will have access to flag values |
Evaluate a flag directly in a server component. Logs exposure immediately.
import { getFlag } from '@spotify-confidence/openfeature-server-provider-local/react-server';
// In an async Server Component - access a specific property
const enabled = await getFlag('checkout-flow.enabled', false, { targetingKey: 'user-123' });
// Or get the entire flag object
const config = await getFlag('checkout-flow', { enabled: false, maxRetries: 1 }, { targetingKey: 'user-123' });Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
flagKey |
string |
Yes | The flag key (supports dot notation) |
defaultValue |
T |
Yes | Default value if flag is not found |
context |
EvaluationContext |
Yes | User/session context for flag evaluation |
providerName |
string |
No | Named provider if not using the default |
Get full flag details in a server component. Logs exposure immediately.
import { getFlagDetails } from '@spotify-confidence/openfeature-server-provider-local/react-server';
const { value, variant, reason } = await getFlagDetails('checkout-flow.enabled', false, { targetingKey: 'user-123' });Simple hook to get a flag value. Automatically logs exposure when the component mounts.
import { useFlag } from '@spotify-confidence/openfeature-server-provider-local/react-client';
// Boolean property
const enabled = useFlag('my-feature.enabled', false);
// String property
const buttonColor = useFlag('ui-theme.buttonColor', 'blue');
// Number property
const maxItems = useFlag('pagination.limit', 10);
// Nested property
const retryLimit = useFlag('api-config.retry.maxAttempts', 3);
// Entire flag object
const config = useFlag('my-feature', { enabled: false, limit: 0 });Hook that returns full flag details including variant, reason, and error information. Also supports manual exposure control.
import { useFlagDetails } from '@spotify-confidence/openfeature-server-provider-local/react-client';
// Auto exposure (default) - logs exposure on mount
const { value, variant, reason } = useFlagDetails('checkout-flow.enabled', false);
// Manual exposure - log when user interacts
const { value: showBanner, expose } = useFlagDetails('promo-banner.show', false, { expose: false });
const handleClick = () => {
if (showBanner) {
expose(); // Log exposure only when user clicks
doSomething();
}
};
// Check for errors
const { value, reason, errorCode } = useFlagDetails('my-feature.enabled', false);
if (errorCode === 'FLAG_NOT_FOUND') {
console.warn('Flag not found, using default');
}Return Type:
interface ClientEvaluationDetails<T> extends EvaluationDetails<T> {
expose: () => void; // Function to manually log exposure (no-op if auto-exposure is enabled)
}
// EvaluationDetails from @openfeature/core
interface EvaluationDetails<T> {
flagKey: string; // The flag key that was requested
flagMetadata: {}; // Reserved for future use
value: T; // The resolved flag value
variant?: string; // The variant name (e.g., 'control', 'treatment')
reason: string; // Resolution reason: 'MATCH', 'NO_SEGMENT_MATCH', 'ERROR', etc.
errorCode?: string; // Error code if resolution failed (e.g., 'FLAG_NOT_FOUND')
errorMessage?: string; // Human-readable error message
}Note: The
exposefunction is always available. When using auto-exposure (the default), callingexpose()manually will log a warning in development mode and do nothing.
The hooks validate that flag values match the type of your default value:
// If the flag value is a string but you expect a number,
// the default value is returned instead
const limit = useFlag('pagination.limit', 10); // Returns 10 if flag value isn't a number
// Object structure is also validated
const config = useFlag('my-feature', { enabled: false, limit: 0 });
// Returns default if flag value doesn't have 'enabled' and 'limit' propertiesResolve flags once in a layout component rather than in each page:
// app/(authenticated)/layout.tsx
export default async function AuthenticatedLayout({ children }) {
const user = await getUser();
return <ConfidenceProvider context={{ targetingKey: user.id, plan: user.plan }}>{children}</ConfidenceProvider>;
}When a feature is only shown after user interaction, use manual exposure to avoid logging exposures for users who never see the feature:
const { value: showPromo, expose } = useFlagDetails('promo-banner.show', false, { expose: false });
const handleOpenModal = () => {
if (showPromo) {
expose(); // Only log when user actually opens the modal
openPromoModal();
}
};If your client is registered for many flags, but only need a few in the frontend, specify them to reduce the bundle size:
<ConfidenceProvider context={context} flags={['checkout-flow', 'promo-banner']}>
{children}
</ConfidenceProvider>If you're making a decision that only affects server rendering and doesn't need client interactivity, use the server functions directly:
// app/page.tsx
import { getFlag } from '@spotify-confidence/openfeature-server-provider-local/react-server';
export default async function Page() {
const showNewLayout = await getFlag('page-layout.useNewDesign', false, { targetingKey: userId });
// This decision is made entirely on the server
return showNewLayout ? <NewLayout /> : <OldLayout />;
}When using the Pages Router, three subexports under ./pages-router/* cover the equivalent of the App Router integration above. The client hooks (useFlag, useFlagDetails) are the same — only the server plumbing differs.
pages-router/server—withConfidence, agetServerSidePropsdecorator that resolves the flag bundle for the request and merges it intopageProps.pages-router/client—<ConfidencePagesProvider>, which reads the bundle frompagePropsand exposes it to theuseFlag/useFlagDetailshooks (re-used as-is fromreact-client).pages-router/api—applyHandler, the/api/confidence/applyPOST handler the client uses to log exposure when a flag is read.
Provider registration is router-agnostic: use the same instrumentation.ts setup shown for the App Router.
withConfidence wraps a single getServerSideProps-shaped function that does your data fetching and returns the evaluation context to use for flag resolution (and optionally a flags allow-list). The decorator resolves the bundle without firing exposure, seals the resolve token, and merges it into pageProps.confidence.
// pages/index.tsx
import { useFlag } from '@spotify-confidence/openfeature-server-provider-local/react-client';
import { withConfidence } from '@spotify-confidence/openfeature-server-provider-local/pages-router/server';
export const getServerSideProps = withConfidence(async ({ req }) => {
const visitorId = req.cookies.uid ?? 'anon';
const data = await fetchSomeData();
return {
props: { data },
context: { visitor_id: visitorId },
flags: ['my-feature'], // optional — defaults to all flags
};
});
export default function Home({ data }: { data: SomeData }) {
const enabled = useFlag('my-feature.enabled', false);
return enabled ? <Feature data={data} /> : null;
}Returning { redirect } or { notFound } short-circuits before flag resolution, just like a normal getServerSideProps. For pages without their own data fetching, the body collapses to a single return:
export const getServerSideProps = withConfidence(async ({ req }) => ({
props: {},
context: { visitor_id: req.cookies.uid ?? 'anon' },
}));Any component that calls useFlag / useFlagDetails must sit under a <ConfidencePagesProvider>. The simplest placement is _app.tsx, which covers every page in one spot — but the wrapper works anywhere in the tree, so per-page wrapping is also fine if you'd rather not touch _app.tsx.
// pages/_app.tsx
import type { AppProps } from 'next/app';
import { ConfidencePagesProvider } from '@spotify-confidence/openfeature-server-provider-local/pages-router/client';
export default function App({ Component, pageProps }: AppProps) {
const { confidence, ...rest } = pageProps;
return (
<ConfidencePagesProvider confidence={confidence}>
<Component {...rest} />
</ConfidencePagesProvider>
);
}Pages whose getServerSideProps doesn't use withConfidence simply have no confidence key on pageProps; the wrapper short-circuits and any useFlag calls in their tree fall back to default values.
When a flag is read on the client (e.g. useFlag firing on mount), the wrapper POSTs { resolveToken, flagName } to the apply route, which opens the sealed token and logs exposure server-side. Mount the handler at /api/confidence/apply:
// pages/api/confidence/apply.ts
import { applyHandler } from '@spotify-confidence/openfeature-server-provider-local/pages-router/api';
export default applyHandler();If you need to mount it elsewhere, pass the same path to <ConfidencePagesProvider apiPath="...">.
In the App Router, the resolve token stays in the encrypted closure of a server action. The Pages Router has no equivalent, so the lib seals the token with AES-256-GCM before it ever reaches the browser. Set a server-only key:
CONFIDENCE_TOKEN_KEY=$(openssl rand -hex 32)The client only ever sees the sealed value; the apply API route opens it server-side.
- No equivalent to
getFlag/getFlagDetailsserver functions — callOpenFeature.getClient().getXxxValue(...)directly insidegetServerSidePropsif you need eager-exposure server-only resolution. - No
getStaticPropssupport: flag resolution is per-request and depends on evaluation context.
Make sure you've initialized the OpenFeature provider before rendering:
import { OpenFeature } from '@openfeature/server-sdk';
import { createConfidenceServerProvider } from '@spotify-confidence/openfeature-server-provider-local';
const provider = createConfidenceServerProvider({
flagClientSecret: process.env.CONFIDENCE_FLAG_CLIENT_SECRET!,
});
await OpenFeature.setProviderAndWait(provider);This warning appears when useFlag or useFlagDetails is called outside of a ConfidenceProvider. Make sure your component tree is wrapped:
<ConfidenceProvider context={context}>
<MyComponent /> {/* useFlag works here */}
</ConfidenceProvider>Check that:
- The flag exists in Confidence and is enabled
- The targeting rules match your evaluation context
- The flag value type matches your default value type (the hooks validate types)
- You're using dot notation to access the correct property (e.g.,
my-flag.enablednot justmy-flag) - Check the output of
useFlagDetailsforerrorCodeanderrorMessageto help diagnose issues.
- Client hooks: Make sure the component actually mounts. If using
{ expose: false }, verify you're callingexpose(). - Server hooks: Exposure is logged immediately on evaluation - check server logs.
- Check that the provider is properly initialized and connected to Confidence.