Next.js App Router
Integrate SignaKit feature flags into a Next.js 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: 14+ (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.
Version compatibility
This guide is written for Next.js v15 and v16 (Turbopack). If you are on v14, two things differ:
- Import: Use
@signakit/flags-node(base import) instead of@signakit/flags-node/next. The/nextsubpath relies onafter()fromnext/server, which does not exist in v14. The base import works fine — exposure events are fire-and-forget, which is reliable with webpack (v14's bundler). instrumentation.ts: Requires an opt-in flag innext.config.ts:experimental: { instrumentationHook: true }.
The globalThis singleton pattern, await signakitReady, and all flag evaluation code are identical across all versions.
Setup
Install the SDK
npm install @signakit/flags-nodeAdd your SDK key to .env.local:
SIGNAKIT_SDK_KEY=sk_dev_yourOrgId_yourProjectId_randomCreate a module-level singleton
Use the /next subpath so exposure events are scheduled with Next.js's after() — sent after the response is flushed rather than aborted by the request lifecycle. The globalThis guard prevents Turbopack from discarding the pre-warmed client when it re-evaluates the module on requests.
import { createInstance } from '@signakit/flags-node/next'
import type { SignaKitClient } from '@signakit/flags-node'
const globalForSignaKit = globalThis as unknown as {
signakit: SignaKitClient
signakitReady: Promise<{ success: boolean; reason?: string }>
}
if (!globalForSignaKit.signakit) {
const client = createInstance({
sdkKey: process.env.SIGNAKIT_SDK_KEY!,
})
if (!client) {
throw new Error('[SignaKit] Failed to create client — check your SIGNAKIT_SDK_KEY')
}
globalForSignaKit.signakit = client
globalForSignaKit.signakitReady = client.onReady()
}
export const signakit = globalForSignaKit.signakit
export const signakitReady = globalForSignaKit.signakitReadyDo 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.
Pre-warm the client with instrumentation
Next.js's after() handles exposure events post-response, but the config fetch itself must also complete outside any request's async context — otherwise Turbopack's dev server can abort it during cold start. instrumentation.ts runs once before any requests arrive, making it the right place to warm the client.
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
const { signakitReady } = await import('./lib/signakit')
await signakitReady
}
}Place instrumentation.ts at the project root (same level as package.json). The NEXT_RUNTIME === 'nodejs' guard ensures it only runs in the Node.js runtime, not the Edge runtime used by proxy.ts.
Next.js v14: opt in to instrumentation
instrumentation.ts is stable in v15+. On v14, add this to your next.config.ts to enable it:
const nextConfig = {
experimental: {
instrumentationHook: true,
},
}
export default nextConfigRead the visitor ID cookie
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.
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
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.
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>
)
}'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.
'use server'
import { signakitReady, signakit } from '@/lib/signakit'
import { getVisitorId } from '@/lib/visitor'
export async function trackPurchase(amount: number) {
await signakitReady
const visitorId = await getVisitorId()
const userCtx = signakit.createUserContext(visitorId)
await userCtx?.trackEvent('purchase_completed', { value: amount })
}'use client'
import { trackPurchase } from '@/app/actions'
export function PurchaseButton({ amount }: { amount: number }) {
return (
<button onClick={() => trackPurchase(amount)}>
Complete purchase
</button>
)
}Anti-patterns
| Pattern | Problem | Fix |
|---|---|---|
'use server' at the top of lib/signakit.ts | Marks every export as a server action — class instances and Promises are not valid server action exports; Next.js throws at startup | Remove the directive entirely; server components are already server-side |
createInstance() inside page.tsx | Re-fetches config on every request; loses per-instance dedup | Create the client once in lib/signakit.ts |
No globalThis guard in lib/signakit.ts | Turbopack re-evaluates modules on requests, creating a fresh uninitialized client each time | Wrap createInstance in the globalThis guard shown in the setup step |
No instrumentation.ts | Config fetch runs inside the first request's async context and can be aborted before it completes | Add instrumentation.ts to pre-warm the client before any requests arrive |
Importing from @signakit/flags-node instead of @signakit/flags-node/next | Exposure events are fire-and-forget within the request and aborted when the response is sent | Use @signakit/flags-node/next — it wraps sends with after() automatically |
decide() inside a 'use client' component | Requires bundling the server SDK client-side | Evaluate in the server component, pass result as prop |
decideAll() in proxy.ts | Evaluates every flag on every request | Use decide('specific-flag') with a narrow matcher |
Not awaiting signakitReady | decide() returns null if config hasn't loaded yet | await signakitReady at the top of every evaluating server component and middleware function |
Not using optional chaining on createUserContext() | Returns null if the client isn't ready — calling .decide() directly throws | Use userCtx?.decide(...) or check for null |
Related
Last updated on
Swift
Full API reference for SignaKitFlags — local-evaluation feature flags for iOS, macOS, tvOS, and watchOS with async/await and Swift concurrency.
Next.js Pages Router
Integrate SignaKit feature flags into a Next.js Pages Router application — evaluate flags in getServerSideProps, gate static pages with client-side hydration, and track A/B test conversions via API routes.