Meta-framework SDKs

Next.js SDK

Evaluate configs and feature flags in the browser, in server routes, and during SSR with the Next.js SDK

Introduction

The Next.js SDK combines a client and a server SDK to provide configs to the browser, to server routes, and to SSR. It is intended to be used by Next.js web applications running on modern web browsers. All modern web browsers on popular platforms should be supported.

The minimum supported version of Next.js is 14.0.

Browser SDKs are tested on the latest versions of Chrome, Firefox, Safari, and Edge.

Telemetry collection within the browser uses Web Workers to aggregate and send telemetry data. If the SDK runs on older browsers without Web Workers support, config evaluation will continue to work but no telemetry data will be collected for that session.

Installation

The SDK can be installed from NPM: https://www.npmjs.com/package/@configdirector/nextjs-sdk

npm install --save @configdirector/nextjs-sdk

Configure the SDK

Set up the server instance to initialize at startup

The Next.js SDK maintains a singleton server SDK instance used in server routes and server side rendering (SSR). In order to register this instance at startup, you can make use of the Next.js instrumentation.ts file:

instrumentation.ts
export async function register() {
  const { register } = await import("@configdirector/nextjs-sdk/server");
  await register({
    // The server SDK key is a secret value, do not commit it to your source code repository.
    // In this example, we are assuming the environment variable CONFIGDIRECTOR_SERVER_SDK_KEY
    // will be populated at runtime with your server SDK key in the server environment
    serverSdkKey: process.env["CONFIGDIRECTOR_SERVER_SDK_KEY"]!,
  });
}

If you are using a version of Next.js older than 15, instrumentation is an experimental feature that must be enabled explicitly:

next.config.js
module.exports = {
  experimental: {
    instrumentationHook: true,
  },
};

Configure the client provider

The ConfigDirector hooks are only available within a ConfigDirectorProvider. The simplest approach is to wrap your application in a ConfigDirectorProvider in your layout. There are a couple of options for this.

Using a dynamic layout and a server-side environment variable for the client key:

app/layout.tsx
import { ConfigDirectorProvider } from "@configdirector/nextjs-sdk/server";
import type { ReactNode } from "react";

export const dynamic = "force-dynamic";

export default async function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>
        <ConfigDirectorProvider sdkKey={process.env["CONFIGDIRECTOR_CLIENT_SDK_KEY"]!}>
          <main>{children}</main>
        </ConfigDirectorProvider>
      </body>
    </html>
  );
}

force-dynamic makes the layout read the environment variable on every request, so one build can be deployed to several environments. The trade-off is that the pages lose static generation. If you build once per environment instead, you can leave force-dynamic out: the environment variable set at build time is baked into that build's static output.

Do not use a NEXT_PUBLIC_ environment variable for the SDK key, even in a static layout. Next.js replaces every NEXT_PUBLIC_ reference with its build-time value, which freezes the key into the build and exposes it to all browser code. A plain environment variable reaches the browser only through the provider.

Additionally, you can configure the provider with a user context. In this example, we retrieve the userId from a cookie:

app/layout.tsx
import { ConfigDirectorProvider } from "@configdirector/nextjs-sdk/server";
import type { ReactNode } from "react";
import { cookies } from "next/headers";

export const dynamic = "force-dynamic";

export default async function RootLayout({ children }: { children: ReactNode }) {
  const userId = (await cookies()).get("userId")?.value;
  return (
    <html lang="en">
      <body>
        <ConfigDirectorProvider
          sdkKey={process.env["CONFIGDIRECTOR_CLIENT_SDK_KEY"]!}
          context={{ id: userId }}>
          <main>{children}</main>
        </ConfigDirectorProvider>
      </body>
    </html>
  );
}

Additional configuration options

appName and appVersion

These metadata options allow you to provide your application's name and version. These values can be used in targeting rules conditionals. For example, if a certain feature should only be enabled starting with a certain version of your application.

instrumentation.ts
export async function register() {
  const { register } = await import("@configdirector/nextjs-sdk/server");
  await register({
    serverSdkKey: process.env["CONFIGDIRECTOR_SERVER_SDK_KEY"]!,
    metadata: {
      appName: "YOUR-APP-NAME",
      appVersion: "1.0.2",
    },
  });
}

The appName and appVersion are automatically propagated to the client SDK instance via the ConfigDirectorProvider RSC. However, if needed they can be overridden to a different value when configuring the provider as well:

app/layout.tsx
import { ConfigDirectorProvider } from "@configdirector/nextjs-sdk/server";
import type { ReactNode } from "react";

export default async function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>
        <ConfigDirectorProvider
          sdkKey={process.env["CONFIGDIRECTOR_CLIENT_SDK_KEY"]!}
          appName="A-DIFFERENT-APP-NAME"
          appVersion="1.0.3">
          <main>{children}</main>
        </ConfigDirectorProvider>
      </body>
    </html>
  );
}

logger (server only)

By default, the SDK on the server side logs to the console and it is set to log warnings and errors only. You can configure a logger by either creating a ConfigDirector console logger with a different log level, or by implementing the ConfigDirectorLogger interface to provide your own logger. The interface can be used to create an adapter to another logging library.

Configure the ConfigDirector console logger to a different level:

instrumentation.ts
export async function register() {
  const { register, createConsoleLogger } = await import("@configdirector/nextjs-sdk/server");
  await register({
    serverSdkKey: process.env["CONFIGDIRECTOR_SERVER_SDK_KEY"]!,
    logger: createConsoleLogger("info"),
  });
}

Implement your own logger adapter:

instrumentation.ts
import type { ConfigDirectorLogger } from "@configdirector/nextjs-sdk/server";

export async function register() {
  const { register } = await import("@configdirector/nextjs-sdk/server");
  const myLogger: ConfigDirectorLogger = {
    debug: function (message: string, ...args: any): void {
      // your specific logging library implementation here
    },
    info: function (message: string, ...args: any): void {
      // your specific logging library implementation here
    },
    warn: function (message: string, ...args: any): void {
      // your specific logging library implementation here
    },
    error: function (message: string, ...args: any): void {
      // your specific logging library implementation here
    },
  };

  await register({
    serverSdkKey: process.env["CONFIGDIRECTOR_SERVER_SDK_KEY"]!,
    logger: myLogger,
  });
}

logLevel (client only)

The SDK on the client side logs to the browser console. You can adjust the ConfigDirector's SDK logging level for the client side via the logLevel option (defaults to warn):

app/layout.tsx
import { ConfigDirectorProvider } from "@configdirector/nextjs-sdk/server";
import type { ReactNode } from "react";

export default async function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>
        <ConfigDirectorProvider
          sdkKey={process.env["CONFIGDIRECTOR_CLIENT_SDK_KEY"]!}
          logLevel="info">
          <main>{children}</main>
        </ConfigDirectorProvider>
      </body>
    </html>
  );
}

mode and pollingInterval (client only)

The connection mode of the client-side provider, which can be either streaming or polling. It is recommended to use the default of streaming unless you have a specific need to use polling instead. pollingInterval is the interval, in seconds, between requests for config updates when mode is polling, and has no effect in streaming mode. It defaults to 60 seconds, and a value below the minimum of 30 seconds is raised to 30 seconds and a warning is logged. See Polling intervals.

app/layout.tsx
import { ConfigDirectorProvider } from "@configdirector/nextjs-sdk/server";
import type { ReactNode } from "react";

export default async function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>
        <ConfigDirectorProvider
          sdkKey={process.env["CONFIGDIRECTOR_CLIENT_SDK_KEY"]!}
          mode="polling"
          pollingInterval={120}>
          <main>{children}</main>
        </ConfigDirectorProvider>
      </body>
    </html>
  );
}

Additional server configuration

The register function accepts the same configuration values as the Node.js server SDK. You can refer to the Node.js SDK for a full list of those configuration options. The server instance uses the server SDK polling defaults: connection.pollingInterval defaults to 300 seconds with a minimum of 60 seconds.

Retrieve config values

In your client components, retrieve a config value with the useConfigValue hook. On the server side, getConfigClient returns the global server instance of the client (an instance of the Node.js SDK).

Retrieving config values in client components:

YourComponent.tsx
"use client";

import { useConfigValue } from "@configdirector/nextjs-sdk/client";

function YourComponent() {
  const { value } = useConfigValue("my-config-key", false);

  return (<p>my-config-key: {value}</p>);
}

export default YourComponent;

Retrieving config values in server routes:

route.ts
import { getConfigClient } from "@configdirector/nextjs-sdk/server";

export async function GET() {
  const client = getConfigClient();
  const value = client.getValue("my-config-key", false);
  return Response.json({ "my-config-key": value });
}

SSR config evaluation

Evaluation of config values during server side rendering (SSR) utilizes the global server instance of the ConfigDirector Node.js client. That means there are no additional network requests that take place since the server SDK evaluates targeting rules locally for any given user context.

For SSR to work correctly, configs need to be made available to both, the client and the server, in the ConfigDirector dashboard.

Update the user context

The user context can be updated with the useContext hook:

YourComponent.tsx
"use client";

import { useEffect } from "react";
import { useConfigValue, useContext } from "@configdirector/nextjs-sdk/client";

function YourComponent() {
  const { value } = useConfigValue("my-config-key", false);
  const { updateContext } = useContext();

  useEffect(() => {
    updateContext({
      id: "654321",
      name: "Another User",
      traits: {
        region: "Australia",
      },
    });
  }, []);

  return <p>my-config-key: {value}</p>;
}

export default YourComponent;

On server routes, the user context is provided at evaluation time as the third argument to getValue. Unlike client SDKs, the server SDKs are able to evaluate targeting rules for the given user context locally without additional network calls.

route.ts
import { getConfigClient } from "@configdirector/nextjs-sdk/server";

export async function GET() {
  const client = getConfigClient();
  const value = client.getValue("my-config-key", false, {
    id: "654321",
    name: "Another User",
    traits: {
      region: "Australia",
    },
  });
  return Response.json({ "my-config-key": value });
}
Updating the context on the client side re-establishes a new connection to ConfigDirector servers with the new context. While the new connection is in flight, config values will continue to evaluate to the currently cached values from the prior user context.

Test your code

The SDK ships two testing entry points. @configdirector/nextjs-sdk/client/testing is for Client Components that read your configs and flags, and @configdirector/nextjs-sdk/server/testing is for server code that uses getConfigClient(), generateSsrConfigSet(), or the server ConfigDirectorProvider. Each creates a test client: the SDK's real client connected to an in-memory server that your test controls. Only the connection to ConfigDirector is replaced. Everything else is the same code that runs in production, so the code under test behaves exactly as it does against ConfigDirector, and no network connection is opened and no telemetry is sent.

Client Components

Render the production client ConfigDirectorProvider with its client prop:

Checkout.test.tsx
import { act, render, screen } from "@testing-library/react";
import { ConfigDirectorProvider } from "@configdirector/nextjs-sdk/client";
import { createTestClient } from "@configdirector/nextjs-sdk/client/testing";
import { Checkout } from "./Checkout";

test("shows the new checkout when the flag is on", async () => {
  const testClient = createTestClient({ values: { "new-checkout": true } });

  render(
    <ConfigDirectorProvider client={testClient.client}>
      <Checkout />
    </ConfigDirectorProvider>,
  );

  expect(await screen.findByText("New checkout")).toBeInTheDocument();

  act(() => testClient.setValue("new-checkout", false));
  expect(screen.getByText("Classic checkout")).toBeInTheDocument();
});

Until the client is ready, the hooks show initialConfigs when the provider was given them, or the in-code default value, so the first assertion uses findBy… rather than getBy…; a test client whose client was initialized before rendering is ready on the first render. Wrap setValue, removeValue, and replaceValues in act().

For a component that renders the client provider itself with an sdkKey, installTestClient makes every provider mounted without a client prop use the test client instead, until the returned function uninstalls it:

App.test.tsx
import { createTestClient, installTestClient } from "@configdirector/nextjs-sdk/client/testing";

let uninstallTestClient: () => void;

beforeEach(() => {
  uninstallTestClient = installTestClient(createTestClient({ values: { "new-checkout": true } }));
});
afterEach(() => uninstallTestClient());

While installed, the provider's construction props (sdkKey, url, mode, timeout, pollingInterval, logLevel, appName, appVersion) are ignored; context, hooks, and initialConfigs apply as they do with a client prop. A provider given a client never disposes it, removes its handlers when it unmounts, does not call initialize on a client that is ready or initializing, updates the context only when the context prop is given and differs from the client's context, compares contexts by deep equality, and ignores a client prop that changes after mount with a warning.

Server code

Server code reads the singleton that register() creates in instrumentation.ts through getConfigClient(). register() never runs in unit tests, so installServerTestClient puts a server test client's client in its place and returns a function that restores the previous one. It does not initialize the client; the test does, as register() would:

flags.test.ts
import { getConfigClient } from "@configdirector/nextjs-sdk/server";
import { createTestClient, installServerTestClient } from "@configdirector/nextjs-sdk/server/testing";

let uninstallServerTestClient: () => void;

beforeEach(async () => {
  const testClient = createTestClient({ values: { "new-checkout": true } });
  uninstallServerTestClient = installServerTestClient(testClient);
  await testClient.client.initialize();
});
afterEach(() => uninstallServerTestClient());

test("serves the flag to every user", () => {
  expect(getConfigClient().getValue("new-checkout", false, { id: "user-1" })).toBe(true);
});

generateSsrConfigSet() and the server ConfigDirectorProvider then serve the test client's values. The server provider is an async Server Component, which React Testing Library cannot render; call it as an async function and assert on the initialConfigs prop of the element it returns. Every value is served as an unconditional config, so every context receives the same value.

Test client controls

values, setValue, removeValue, and replaceValues take native values, and the config type follows from each value: a boolean, an integral number (integer), any other number (float), a string, or a plain object or array (json). holdInitialization, completeInitialization, and failInitialization drive initialize; the client test client also holds, completes, or fails updateContext and records every context in contextUpdates. The JavaScript SDK's testing section and the Node.js SDK's testing section describe every control and what to expect from each client.