Skip to content

Latest commit

 

History

History
526 lines (370 loc) · 19.8 KB

File metadata and controls

526 lines (370 loc) · 19.8 KB

React Integration

React hooks and components for using Confidence feature flags in a modern React (RSC) App, like for instance Next.js.

Overview

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

Flag Structure

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' });

Installation

yarn add @spotify-confidence/openfeature-server-provider-local react

Quick Start (Next.js App Router)

1. Set up the provider (server-side)

Use 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.

2. Wrap your app with ConfidenceProvider

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>
  );
}

3. Use flags in client components

// 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>;
}

Resolve Token Security

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.

Server vs Client: Understanding Exposure

Exposure is the event that tells Confidence a user was shown a particular flag variant. This is critical for accurate experiment analysis.

Server-Side Exposure

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

Client-Side Exposure

When using hooks from react-client, you have two options:

Automatic Exposure (Default)

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;
}

Manual Exposure

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

API Reference

Server Components

ConfidenceProvider

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

getFlag (Server)

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

getFlagDetails (Server)

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' });

Client Components

useFlag (Client)

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 });

useFlagDetails (Client)

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 expose function is always available. When using auto-exposure (the default), calling expose() manually will log a warning in development mode and do nothing.

Type Safety

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' properties

Best Practices

Resolve flags at the layout level

Resolve 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>;
}

Use manual exposure for conditional features

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();
  }
};

Specify flags for better performance

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>

Use server functions for server-only logic

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 />;
}

Next.js Pages Router

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/serverwithConfidence, a getServerSideProps decorator that resolves the flag bundle for the request and merges it into pageProps.
  • pages-router/client<ConfidencePagesProvider>, which reads the bundle from pageProps and exposes it to the useFlag / useFlagDetails hooks (re-used as-is from react-client).
  • pages-router/apiapplyHandler, the /api/confidence/apply POST 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.

1. Resolve flags in getServerSideProps

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' },
}));

2. Wrap your tree with <ConfidencePagesProvider>

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.

3. Mount the apply API route

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="...">.

Resolve token security

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.

What's not in this integration

  • No equivalent to getFlag / getFlagDetails server functions — call OpenFeature.getClient().getXxxValue(...) directly inside getServerSideProps if you need eager-exposure server-only resolution.
  • No getStaticProps support: flag resolution is per-request and depends on evaluation context.

Troubleshooting

"ConfidenceProvider requires a ConfidenceServerProviderLocal"

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);

"useFlagDetails called without a ConfidenceProvider"

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>

Flag value is always the default

Check that:

  1. The flag exists in Confidence and is enabled
  2. The targeting rules match your evaluation context
  3. The flag value type matches your default value type (the hooks validate types)
  4. You're using dot notation to access the correct property (e.g., my-flag.enabled not just my-flag)
  5. Check the output of useFlagDetails for errorCode and errorMessage to help diagnose issues.

Exposure not being logged

  • Client hooks: Make sure the component actually mounts. If using { expose: false }, verify you're calling expose().
  • Server hooks: Exposure is logged immediately on evaluation - check server logs.
  • Check that the provider is properly initialized and connected to Confidence.