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.
npm install @zenmanage/nextjs @zenmanage/react @zenmanage/sdk
ZENMANAGE_ENVIRONMENT_TOKEN=srv_your_server_key_here
NEXT_PUBLIC_ZENMANAGE_ENVIRONMENT_TOKEN=cli_your_client_key_here
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.
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.
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/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>
);
}
// 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/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.
// 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.