SignaKitdocs
Framework Guides

Next.js App Router

Integrate SignaKit feature flags into a Next.js 15 App Router application — evaluate flags in server components, pass results to client components, and track conversions.

Next.js App Router

SDK: @signakit/flags-node Next.js version: 15+ (App Router)

The recommended pattern for Next.js App Router is to evaluate flags in server components, then pass the result as a prop to any client components that need it. This keeps flag evaluation on the server — zero client bundle cost, no hydration mismatch, no loading state.


Setup

Install the SDK

npm install @signakit/flags-node

Add your SDK key to .env.local:

SIGNAKIT_SDK_KEY=sk_dev_yourOrgId_yourProjectId_random

Create a module-level singleton

Create the client once so it is shared across all requests in the same process. In Next.js this file is evaluated once per server cold start.

lib/signakit.ts
import { createInstance } from '@signakit/flags-node'

const client = createInstance({
  sdkKey: process.env.SIGNAKIT_SDK_KEY!,
})

if (!client) {
  throw new Error('[SignaKit] Failed to create client — check your SIGNAKIT_SDK_KEY')
}

// Kick off config fetch immediately — resolves before the first request arrives
export const signakit = client
export const signakitReady = client.onReady()

Do not add 'use server' to this file

'use server' marks every export as a server action — which must be an async function. Exporting a class instance (signakit) or a Promise (signakitReady) from a 'use server' file is invalid and Next.js will throw an error. This file should have no directive at all. Server components import from it freely since they are already server-side.

SignaKit uses userId as the bucketing key — the same ID always produces the same variation. For anonymous visitors, read a stable visitor_id cookie. For authenticated users, use your auth session's user ID.

lib/visitor.ts
import { cookies } from 'next/headers'
import { cache } from 'react'

export const getVisitorId = cache(async (): Promise<string> => {
  const cookieStore = await cookies()
  return cookieStore.get('visitor_id')?.value ?? 'anonymous'
})

cache() ensures getVisitorId() is called at most once per request even if multiple server components call it.


Evaluating a flag in a server component

app/checkout/page.tsx
import { signakit, signakitReady } from '@/lib/signakit'
import { getVisitorId } from '@/lib/visitor'
import { CheckoutV2 } from './checkout-v2'
import { LegacyCheckout } from './checkout-legacy'

export default async function CheckoutPage() {
  await signakitReady

  const visitorId = await getVisitorId()
  const userCtx = signakit.createUserContext(visitorId)
  const checkout = userCtx?.decide('checkout-redesign')

  return checkout?.variationKey === 'treatment'
    ? <CheckoutV2 />
    : <LegacyCheckout />
}

With auth session attributes — if you have a session, pass its data as attributes for more precise targeting:

const session = await auth()
const userCtx = signakit.createUserContext(session?.user.id ?? visitorId, {
  plan: session?.user.plan ?? 'free',
})
// userCtx is SignaKitUserContext | null — use optional chaining
const decision = userCtx?.decide('my-flag')

Passing flag results to client components

When a client component needs to act on a flag result, evaluate on the server and pass as a prop.

app/dashboard/page.tsx
import { signakit, signakitReady } from '@/lib/signakit'
import { getVisitorId } from '@/lib/visitor'
import { DashboardHeader } from './dashboard-header' // 'use client'

export default async function DashboardPage() {
  await signakitReady
  const visitorId = await getVisitorId()
  const userCtx = signakit.createUserContext(visitorId)
  const newNav = userCtx?.decide('redesigned-nav')

  return (
    <main>
      <DashboardHeader showNewNav={newNav?.enabled ?? false} />
    </main>
  )
}
app/dashboard/dashboard-header.tsx
'use client'

export function DashboardHeader({ showNewNav }: { showNewNav: boolean }) {
  return showNewNav ? <NewNav /> : <LegacyNav />
}

Never evaluate flags inside a client component

// ❌ Requires bundling the server SDK client-side — won't work
'use client'

import { signakit } from '@/lib/signakit'

export function BadComponent() {
  const decision = signakit.createUserContext('user-123').decide('my-flag') // ❌
}

Evaluate in a server component and pass the boolean as a prop.


Tracking conversion events

Track conversions from client components via a server action, keeping the SDK key server-side.

app/actions.ts
'use server'

import { signakit } from '@/lib/signakit'
import { getVisitorId } from '@/lib/visitor'

export async function trackPurchase(amount: number) {
  const visitorId = await getVisitorId()
  const userCtx = signakit.createUserContext(visitorId)
  await userCtx?.trackEvent('purchase_completed', { value: amount })
}
app/checkout/purchase-button.tsx
'use client'

import { trackPurchase } from '@/app/actions'

export function PurchaseButton({ amount }: { amount: number }) {
  return (
    <button onClick={() => trackPurchase(amount)}>
      Complete purchase
    </button>
  )
}

Middleware — route-level flag gating

Use middleware to rewrite or redirect based on a flag before the page renders.

proxy.ts
// Next.js 16 uses proxy.ts instead of middleware.ts
import { NextResponse } from 'next/server'
import { signakit } from '@/lib/signakit'
import type { NextRequest } from 'next/server'

export async function middleware(request: NextRequest) {
  const visitorId = request.cookies.get('visitor_id')?.value ?? 'anonymous'
  const userCtx = signakit.createUserContext(visitorId)
  const homepage = userCtx?.decide('homepage-redesign')

  if (request.nextUrl.pathname === '/' && homepage?.variationKey === 'treatment') {
    return NextResponse.rewrite(new URL('/homepage-v2', request.url))
  }

  return NextResponse.next()
}

export const config = {
  matcher: ['/'], // ← limit to routes that actually need the flag
}

Middleware runs on every matched request — keep the matcher narrow

Use decide('specific-flag') in middleware, never decideAll(). Set a tight matcher so the middleware only runs on routes that need it.

proxy.ts in Next.js 16

Next.js 16 uses proxy.ts at the project root instead of middleware.ts. The API is identical — only the filename changed.


Anti-patterns

PatternProblemFix
'use server' at the top of lib/signakit.tsMarks every export as a server action — class instances and Promises are not valid server action exports; Next.js throws at startupRemove the directive entirely; server components are already server-side
createInstance() inside page.tsxRe-fetches config on every request; loses per-instance dedupCreate the client once in lib/signakit.ts
decide() inside a 'use client' componentRequires bundling the server SDK client-sideEvaluate in the server component, pass result as prop
decideAll() in proxy.tsEvaluates every flag on every requestUse decide('specific-flag') with a narrow matcher
Not awaiting signakitReadydecide() returns null if config hasn't loaded yetawait signakitReady at the top of every evaluating server component
Not using optional chaining on createUserContext()Returns null if the client isn't ready — calling .decide() directly throwsUse userCtx?.decide(...) or check for null

Last updated on

On this page