Next.js Integration
Table of Contents
- Quickstart
- Toolbar Setup
- App Router
- Pages Router
- Evaluation Context
- Dedupe
- Precompute
- Dashboard Pages
- Marketing Pages
- Proxy (Middleware)
- Suspense Fallbacks
Quickstart
pnpm i flagsDeclare a flag in flags.ts:
import { flag } from 'flags/next';
export const exampleFlag = flag({
key: 'example-flag',
decide() {
return Math.random() > 0.5;
},
});Toolbar Setup
- Install
@vercel/toolbar:
pnpm i @vercel/toolbar- Add Next.js plugin:
// next.config.ts
import type { NextConfig } from 'next';
import createWithVercelToolbar from '@vercel/toolbar/plugins/next';
const nextConfig: NextConfig = {};
const withVercelToolbar = createWithVercelToolbar();
export default withVercelToolbar(nextConfig);- Render toolbar in root layout:
// app/layout.tsx
import { VercelToolbar } from '@vercel/toolbar/next';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
// On Vercel, the toolbar is auto-injected in preview deployments.
// This manual injection is only needed for local development.
const shouldInjectToolbar = process.env.NODE_ENV === 'development';
return (
<html lang="en">
<body>
{children}
{shouldInjectToolbar && <VercelToolbar />}
</body>
</html>
);
}App Router
Call the flag function from any async server component or proxy:
// app/page.tsx
import { exampleFlag } from '../flags';
export default async function Page() {
const example = await exampleFlag();
return <div>{example ? 'Flag is on' : 'Flag is off'}</div>;
}Pages Router
Pass req to the flag in getServerSideProps:
// pages/index.tsx
import type { InferGetServerSidePropsType, GetServerSideProps } from 'next';
import { exampleFlag } from '../flags';
export const getServerSideProps = (async ({ req }) => {
const example = await exampleFlag(req);
return { props: { example } };
}) satisfies GetServerSideProps<{ example: boolean }>;
export default function Page({
example,
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
return <div>{example ? 'Flag is on' : 'Flag is off'}</div>;
}Evaluation Context
Use identify to establish who the request is for. The returned entities are passed to decide:
import { flag, dedupe } from 'flags/next';
import type { ReadonlyRequestCookies } from 'flags';
interface Entities {
user?: { id: string };
}
const identify = dedupe(
({ cookies }: { cookies: ReadonlyRequestCookies }): Entities => {
const userId = cookies.get('user-id')?.value;
return { user: userId ? { id: userId } : undefined };
},
);
export const myFlag = flag<boolean, Entities>({
key: 'my-flag',
identify,
decide({ entities }) {
return entities?.user?.id === 'user1';
},
});identify receives normalized headers and cookies that work across App Router, Pages Router, and Proxy.
Custom evaluation context
Override identify at call site (use sparingly):
await exampleFlag.run({ identify: { user: { id: 'user1' } } });
await exampleFlag.run({ identify: () => ({ user: { id: 'user1' } }) });Dedupe
Wrap functions in dedupe to run them once per request within the same runtime:
import { dedupe } from 'flags/next';
const identify = dedupe(({ cookies }) => {
return { user: { id: cookies.get('uid')?.value } };
});Use cases:
- Prevent duplicate
identifycalls across multiple flags - Generate consistent random IDs for anonymous visitor experiments
Not available in Pages Router.
Precompute
Keep pages static while using feature flags. Proxy evaluates flags and encodes results into the URL.
Prerequisites
Ensure FLAGS_SECRET is configured using FLAGS_SECRET. Reuse existing values; generate one only for an environment where it is absent.
Step 1: Create flag group
// flags.ts
import { flag } from 'flags/next';
export const showSummerSale = flag({
key: 'summer-sale',
decide: () => false,
});
export const showBanner = flag({
key: 'banner',
decide: () => false,
});
export const marketingFlags = [showSummerSale, showBanner] as const;Step 2: Precompute in proxy
// proxy.ts
import { type NextRequest, NextResponse } from 'next/server';
import { precompute } from 'flags/next';
import { marketingFlags } from './flags';
export const config = { matcher: ['/'] };
export async function proxy(request: NextRequest) {
const code = await precompute(marketingFlags);
const nextUrl = new URL(
`/${code}${request.nextUrl.pathname}${request.nextUrl.search}`,
request.url,
);
return NextResponse.rewrite(nextUrl, { request });
}Step 3: Read precomputed values in page
// app/[code]/page.tsx
import { marketingFlags, showSummerSale, showBanner } from '../../flags';
type Params = Promise<{ code: string }>;
export default async function Page({ params }: { params: Params }) {
const { code } = await params;
const summerSale = await showSummerSale(code, marketingFlags);
const banner = await showBanner(code, marketingFlags);
return (
<div>
{banner && <p>welcome</p>}
{summerSale ? <p>summer sale live</p> : <p>summer sale soon</p>}
</div>
);
}Step 4: Enable ISR & build time prerendering
// app/[code]/layout.tsx
import { generatePermutations } from 'flags/next';
export async function generateStaticParams() {
const codes = await generatePermutations(marketingFlags);
return codes.map((code) => ({ code }));
}
export default async function Layout({ children }) {
return children;
}Declaring options
Options enable efficient URL encoding and Flags Explorer display:
export const greetingFlag = flag<string>({
key: 'greeting',
options: ['Hello world', 'Hi', 'Hola'],
decide: () => 'Hello world',
});Or with labels:
export const greetingFlag = flag<string>({
key: 'greeting',
options: [
{ label: 'Hello world', value: 'Hello world' },
{ label: 'Hi', value: 'Hi' },
],
decide: () => 'Hello world',
});Multiple groups
Avoid unnecessary permutations by creating separate flag groups per page:
export const rootFlags = [navigationFlag, bannerFlag];
export const pricingFlags = [discountFlag];File tree:
app/[rootCode]/
page.tsx
pricing/[pricingCode]/
page.tsxPages Router precompute
// pages/[code]/index.tsx
import { generatePermutations } from 'flags/next';
export const getStaticPaths = (async () => {
const codes = await generatePermutations(marketingFlags);
return {
paths: codes.map((code) => ({ params: { code } })),
fallback: 'blocking',
};
}) satisfies GetStaticPaths;
export const getStaticProps = (async (context) => {
if (typeof context.params?.code !== 'string') return { notFound: true };
const example = await exampleFlag(context.params.code, marketingFlags);
return { props: { example } };
}) satisfies GetStaticProps<{ example: boolean }>;Dashboard Pages
For authenticated dashboard pages, use identify to read user context from cookies/JWTs:
import type { ReadonlyRequestCookies } from 'flags';
import { flag, dedupe } from 'flags/next';
interface Entities {
user?: { id: string };
}
const identify = dedupe(
({ cookies }: { cookies: ReadonlyRequestCookies }): Entities => {
const userId = cookies.get('dashboard-user-id')?.value;
return { user: userId ? { id: userId } : undefined };
},
);
export const dashboardFlag = flag<boolean, Entities>({
key: 'dashboard-flag',
identify,
decide({ entities }) {
if (!entities?.user) return false;
const allowedUsers = ['user1'];
return allowedUsers.includes(entities.user.id);
},
});Usage in a page:
export default async function DashboardPage() {
const dashboard = await dashboardFlag();
return <div>{dashboard ? 'New Dashboard' : 'Old Dashboard'}</div>;
}Marketing Pages
For static marketing pages with A/B tests, combine precompute with visitor ID generation:
Visitor ID in proxy
// proxy.ts
import { precompute } from 'flags/next';
import { type NextRequest, NextResponse } from 'next/server';
import { marketingFlags } from './flags';
import { getOrGenerateVisitorId } from './get-or-generate-visitor-id';
export async function marketingProxy(request: NextRequest) {
const visitorId = await getOrGenerateVisitorId(
request.cookies,
request.headers,
);
const code = await precompute(marketingFlags);
return NextResponse.rewrite(
new URL(`/examples/marketing-pages/${code}`, request.url),
{
headers: {
'Set-Cookie': `marketing-visitor-id=${visitorId}; Path=/`,
'x-marketing-visitor-id': visitorId,
},
},
);
}Deduplicated visitor ID generation
import { nanoid } from 'nanoid';
import { dedupe } from 'flags/next';
import type { ReadonlyHeaders, ReadonlyRequestCookies } from 'flags';
const generateId = dedupe(async () => nanoid());
export const getOrGenerateVisitorId = async (
cookies: ReadonlyRequestCookies,
headers: ReadonlyHeaders,
) => {
const cookieVisitorId = cookies.get('marketing-visitor-id')?.value;
if (cookieVisitorId) return cookieVisitorId;
const headerVisitorId = headers.get('x-marketing-visitor-id');
if (headerVisitorId) return headerVisitorId;
return generateId();
};Flag using visitor ID
const identify = dedupe(
async ({ cookies, headers }: {
cookies: ReadonlyRequestCookies;
headers: ReadonlyHeaders;
}): Promise<Entities> => {
const visitorId = await getOrGenerateVisitorId(cookies, headers);
return { visitor: visitorId ? { id: visitorId } : undefined };
},
);
export const marketingAbTest = flag<boolean, Entities>({
key: 'marketing-ab-test-flag',
identify,
decide({ entities }) {
if (!entities?.visitor) return false;
return /^[a-n0-5]/i.test(entities.visitor.id);
},
});Proxy (Middleware)
Use flags in proxy to rewrite requests to static page variants:
// proxy.ts
import { type NextRequest, NextResponse } from 'next/server';
import { myFlag } from './flags';
export const config = { matcher: ['/example'] };
export async function proxy(request: NextRequest) {
const active = await myFlag();
const variant = active ? 'variant-on' : 'variant-off';
return NextResponse.rewrite(new URL(`/example/${variant}`, request.url));
}For multiple flags on one page, use the precompute pattern instead.
Suspense Fallbacks
Combine precomputed flags with Partial Prerendering to serve matching skeletons:
async function Example() {
const hasAuth = await hasAuthCookieFlag();
return (
<Suspense fallback={hasAuth ? <AuthedSkeleton /> : <UnauthedSkeleton />}>
<Dashboard />
</Suspense>
);
}The hasAuthCookieFlag checks cookie existence without authenticating. Two shells get prerendered — one for each auth state — served statically with no layout shift.
Flags Explorer (Next.js)
The Flags Explorer is part of the Vercel Toolbar. Before adding the discovery endpoint below, make sure the toolbar is set up by following the Toolbar Setup steps first.
App Router
// app/.well-known/vercel/flags/route.ts
import { getProviderData, createFlagsDiscoveryEndpoint } from 'flags/next';
import * as flags from '../../../../flags';
export const GET = createFlagsDiscoveryEndpoint(async () => {
return getProviderData(flags);
});Pages Router
Requires a rewrite in next.config.js:
module.exports = {
async rewrites() {
return [
{
source: '/.well-known/vercel/flags',
destination: '/api/vercel/flags',
},
];
},
};// pages/api/vercel/flags.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import { verifyAccess } from 'flags';
export async function handler(req: NextApiRequest, res: NextApiResponse) {
const access = await verifyAccess(req.headers.authorization);
if (!access) return res.status(401).json(null);
const providerData = { /* ... */ };
return res.status(200).json(providerData);
}