announcements

Introducing the Zenmanage Next.js SDK

The Zenmanage Next.js SDK evaluates flags in Server Components, route handlers, and middleware, then hands the results to Client Components so the first paint is already right.

The Zenmanage Next.js SDK is on npm as @zenmanage/nextjs. Evaluate flags on the server in the App Router or the Pages Router, decide things in middleware before a page renders, and hand the results to your Client Components so they show the right value on the first paint.

A feature flag in a Next.js app has a where problem. A request passes through middleware, then a server render, then hydration in the browser, and your flag code can run in all three. Read the flag the way you would in a single-page app, with a fetch in an effect, and the server renders the default while the browser swaps in the real value a moment later. Your users watch the old checkout button turn into the new one.

The usual fix is to evaluate on the server and pass the answer down. That works until it reaches a Client Component, which can't hold your server key, while the key you can safely expose can't be the one you evaluate with on the server. The Next.js SDK handles that handoff, so you don't write it by hand and then maintain it.

What it is

@zenmanage/nextjs is a Next.js layer over two packages we've already shipped. The JavaScript SDK evaluates flags on the server and at the edge, and the React SDK provides the provider and hooks for the browser. This package adds the parts that are specific to Next.js: server helpers, a bootstrap payload that carries server-evaluated values into the page, and middleware helpers. It supports Next.js 13.4 through 16 on both routers, with React 18.2 or 19.

Getting started

Install the package from npm, along with the two packages it builds on:

npm install @zenmanage/nextjs @zenmanage/react @zenmanage/sdk

You'll use two keys, and they never swap places. Put the server key (srv_) in a server-only environment variable and use it in Server Components, route handlers, getServerSideProps, and middleware. Put the client key (cli_) in a NEXT_PUBLIC_ variable for the browser provider:

ZENMANAGE_ENVIRONMENT_TOKEN=srv_your_server_key_here
NEXT_PUBLIC_ZENMANAGE_ENVIRONMENT_TOKEN=cli_your_client_key_here

The provider enforces the split. NextFlagsProvider throws if you hand it a server key, because anything you pass to a Client Component ends up in the page.

Evaluate on the server

In a Server Component, read a flag with getServerFlag. No provider, no loading state, and the answer is there before the HTML is sent:

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

If Zenmanage can't be reached, you get defaultValue instead of an error, so a flag outage doesn't take the page down. The context decides who the flag is evaluated for, and a percentage rollout buckets by the context's identifier, so use one that stays the same for a user across requests.

The server helpers share one client per configuration and keep your rules in memory for 60 seconds by default. A page that reads ten flags makes one fetch, not ten, and a change you publish reaches your server within a minute. Set cacheTtl if you want a different window.

Route handlers

A route handler uses the same helper, so there's nothing extra to learn. This one decides per request from a header:

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

Client Components, without the flash

Client Components render on the server too, and they can't read your server key. So the server evaluates the flags, and the provider shows those values until the browser has loaded its own. Here's the sequence:

  1. getBootstrapPayload evaluates your flags on the server and returns plain JSON.
  2. You pass that payload to NextFlagsProvider as a prop.
  3. useFlag from @zenmanage/nextjs/client returns the server's value on the server render and on the first client render. Both sides see the same value, so React hydrates cleanly: no mismatch, and no flash of the default.
  4. The browser loads its own rules, and the live value replaces the server's when they arrive. If they never arrive, because of a blocked CDN or no network, the server's value stays. The page doesn't flip to the default in your code.

App Router

Evaluate in the root layout, which is a Server Component, and pass the payload to a small client wrapper. Swap the hard-coded user for whoever is signed in. keys limits the payload to the flags your Client Components read, and includeContext lets the browser evaluate for the same user the server did:

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

Anywhere below that, a Client Component reads the flag with a hook that looks like the one in the React SDK:

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

The same flow works in getServerSideProps. The payload is plain JSON, so Next.js serializes it as is:

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

Middleware and the edge

Some decisions belong before a page renders: send beta users to a different route, or hold a section back for everyone else. evaluateMiddlewareFlag builds a context from the request and evaluates one flag:

// 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*'] };

It runs in middleware.ts on the Edge runtime and in proxy.ts on Node.js (Next.js 16), with a server key in both. The context it builds comes from the zenmanage_user_id cookie, the path, the user agent, and geo from request.geo or the Vercel headers. An IP address is personal data, so it's left out unless you set includeIp.

Two things to know before you ship

The bootstrap payload is public

The payload is written into the page, so anyone can read it. If you leave out keys, every flag in the environment is sent, names and values included. Pass the keys your Client Components read, and leave out includeContext if the context holds anything you don't want in the page source.

FlagGate and withFlag wait for the browser

FlagGate and withFlag from the React SDK work inside NextFlagsProvider, but they don't use the bootstrap. They wait for the browser's own value, so the first paint shows their loading fallback instead of the server's answer. Use useFlag and useVariant from @zenmanage/nextjs/client for anything above the fold.

What's next

@zenmanage/nextjs 1.0.0 is on npm now. The source is on GitHub, with runnable examples for both routers and middleware, and a smoke-test app we build against Next.js 13.4, 14, 15, and 16. The Next.js quickstart walks through setup end to end, and the SDK overview lists every language we support.

If something in the package gets in your way, tell us at hello@zenmanage.com or open an issue on GitHub.

Enjoyed this article?

Share it with your network.