Quickstart by stack

Next.js Quickstart

Install the Next.js SDK, evaluate flags in Server Components, route handlers, and middleware, and bootstrap Client Components so the first paint is already right.

Prerequisites

  • Next.js 13.4 or newer (up to 16) on the App Router or the Pages Router, with React 18.2 or 19.
  • A server key (prefixed srv_) in a server-only environment variable, and a client key (prefixed cli_) in a NEXT_PUBLIC_ variable for Client Components.
  • A flag key you can test in a non-production environment.
Install bash

npm install @zenmanage/nextjs @zenmanage/react @zenmanage/sdk
Add your keys to .env.local dotenv

ZENMANAGE_ENVIRONMENT_TOKEN=srv_your_server_key_here
NEXT_PUBLIC_ZENMANAGE_ENVIRONMENT_TOKEN=cli_your_client_key_here
Read a flag in a Server Component tsx

import { Context } from '@zenmanage/sdk';
import { getServerFlag } from '@zenmanage/nextjs';

export default async function DashboardPage() {
  const flag = await getServerFlag({
    environmentToken: process.env.ZENMANAGE_ENVIRONMENT_TOKEN!,
    key: 'dashboard-v2',
    defaultValue: false,
    context: Context.single('user', 'user-123', 'Jane'),
  });

  return <main>{flag.asBool() ? <NewDashboard /> : <LegacyDashboard />}</main>;
}

Safe defaults

If Zenmanage can't be reached, you get defaultValue, not an error. The type of the default decides how the flag is read: boolean, string, number, or JSON.

One fetch per page

The server helpers share one client per configuration and cache rules in memory for 60 seconds by default. A page with ten flags makes one fetch, not ten, and a flag change reaches your server within a minute. Set cacheTtl to change that.

Target a user with context tsx

import { Attribute, Context } from '@zenmanage/sdk';
import { getServerFlag } from '@zenmanage/nextjs';

const context = new Context('user', 'Jane Doe', 'user-123', [
  new Attribute('country', ['US']),
  new Attribute('plan', ['pro']),
]);

const flag = await getServerFlag({
  environmentToken: process.env.ZENMANAGE_ENVIRONMENT_TOKEN!,
  key: 'premium-banner',
  defaultValue: false,
  context,
});

A percentage rollout buckets by the context's identifier, so use one that stays the same for a user across requests.

Evaluate in a route handler typescript

import { NextResponse } from 'next/server';
import { Context } from '@zenmanage/sdk';
import { getServerFlag } from '@zenmanage/nextjs';

export async function GET(request: Request) {
  const userId = request.headers.get('x-user-id') ?? 'anonymous';

  const flag = await getServerFlag({
    environmentToken: process.env.ZENMANAGE_ENVIRONMENT_TOKEN!,
    key: 'route-handler-v2',
    defaultValue: false,
    context: Context.single('user', userId),
  });

  return NextResponse.json({ enabled: flag.asBool() });
}

Bootstrap Client Components

Client Components render on the server too, and they can't read your server key. So the server evaluates the flags and hands the results to NextFlagsProvider, which shows those values until the browser has loaded its own. Because the server render and the first client render see the same value, React hydrates cleanly: no mismatch and no flash of the default.

App Router: evaluate in the root layout tsx

// app/layout.tsx
import { Context } from '@zenmanage/sdk';
import { getBootstrapPayload } from '@zenmanage/nextjs';
import { Providers } from './providers';

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const bootstrapPayload = await getBootstrapPayload({
    environmentToken: process.env.ZENMANAGE_ENVIRONMENT_TOKEN!,
    context: Context.single('user', 'user-123'),
    keys: ['new-checkout'],
    includeContext: true,
  });

  return (
    <html lang="en">
      <body>
        <Providers bootstrapPayload={bootstrapPayload}>{children}</Providers>
      </body>
    </html>
  );
}
App Router: wrap the client tree tsx

// app/providers.tsx
'use client';

import type { BootstrapPayload } from '@zenmanage/nextjs';
import { NextFlagsProvider } from '@zenmanage/nextjs/client';

export function Providers({
  bootstrapPayload,
  children,
}: {
  bootstrapPayload: BootstrapPayload;
  children: React.ReactNode;
}) {
  return (
    <NextFlagsProvider
      environmentToken={process.env.NEXT_PUBLIC_ZENMANAGE_ENVIRONMENT_TOKEN!}
      bootstrapPayload={bootstrapPayload}
    >
      {children}
    </NextFlagsProvider>
  );
}
App Router: read the flag in a Client Component tsx

// app/checkout-button.tsx
'use client';

import { useFlag } from '@zenmanage/nextjs/client';

export function CheckoutButton() {
  const { value: enabled } = useFlag('new-checkout', false);

  return <button>{enabled ? 'Checkout (new)' : 'Checkout'}</button>;
}
Pages Router: evaluate in getServerSideProps tsx

// pages/checkout.tsx
import type { GetServerSideProps } from 'next';
import { Context } from '@zenmanage/sdk';
import { getBootstrapPayload, type BootstrapPayload } from '@zenmanage/nextjs';
import { NextFlagsProvider, useFlag } from '@zenmanage/nextjs/client';

export const getServerSideProps: GetServerSideProps<{
  bootstrapPayload: BootstrapPayload;
}> = async () => ({
  props: {
    bootstrapPayload: await getBootstrapPayload({
      environmentToken: process.env.ZENMANAGE_ENVIRONMENT_TOKEN!,
      context: Context.single('user', 'user-123'),
      keys: ['new-checkout'],
      includeContext: true,
    }),
  },
});

function Checkout() {
  const { value: enabled } = useFlag('new-checkout', false);
  return <button>{enabled ? 'Checkout (new)' : 'Checkout'}</button>;
}

export default function CheckoutPage({ bootstrapPayload }: { bootstrapPayload: BootstrapPayload }) {
  return (
    <NextFlagsProvider
      environmentToken={process.env.NEXT_PUBLIC_ZENMANAGE_ENVIRONMENT_TOKEN!}
      bootstrapPayload={bootstrapPayload}
    >
      <Checkout />
    </NextFlagsProvider>
  );
}

The live value wins

The bootstrap value is for the first paint. When the browser loads its own rules, the live value replaces the server's. If they never arrive (a blocked CDN, no network), the server's value stays, so the page doesn't switch to the code default.

What goes to the browser

The payload is written into the page, so anyone can read it. Pass keys to send only the flags your Client Components read, and leave out includeContext if the context holds anything you don't want in the page source.

Evaluate a flag in middleware typescript

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { evaluateMiddlewareFlag } from '@zenmanage/nextjs/middleware';

export async function middleware(request: NextRequest) {
  const beta = await evaluateMiddlewareFlag({
    environmentToken: process.env.ZENMANAGE_ENVIRONMENT_TOKEN!,
    key: 'beta-rewrite',
    defaultValue: false,
    request,
  });

  return beta.asBool() ? NextResponse.rewrite(new URL('/beta', request.url)) : NextResponse.next();
}

export const config = { matcher: ['/dashboard/:path*'] };

Edge and Node.js middleware

evaluateMiddlewareFlag builds a context from the request: the zenmanage_user_id cookie, the path, the user agent, and geo from request.geo or the Vercel headers. It runs in middleware.ts on the Edge runtime and in proxy.ts on Node.js (Next.js 16). IP address is personal data, so it's off unless you set includeIp.

Two keys, never swapped

Use a srv_ key in Server Components, route handlers, getServerSideProps, and middleware. Use a cli_ key in NextFlagsProvider. The provider throws if you give it a server key, because anything you pass to a Client Component ends up in the page. Mobile keys aren't valid here.

Built on the JavaScript and React SDKs

The package is a Next.js layer over @zenmanage/sdk for the server and @zenmanage/react for the browser, so caching, context handling, and defaults behave the way they do in the JavaScript quickstart and the React quickstart.

Tested on Next.js 13.4 to 16

The test suite builds the packed package into a real Next.js app on 13.4, 14, 15, and 16, on both routers, and runs it against a mock API. The same app is in the repository if you want a working project to compare against.

Next step

Take the next integration step in your own stack.

Start with the quickstart that matches your runtime, then return to the reference pages when you need exact request and payload details.

Language and framework names and logos shown above are trademarks or registered trademarks of their respective owners. Their use here is for identification purposes only and does not imply endorsement of, or affiliation with, Zenmanage.