SignaKitdocs
SDKs

Node

Full API reference for @signakit/flags-node — server-side feature flags with local evaluation for Node.js, Next.js, Express, Fastify, and NestJS.

Node.js SDK

Package: @signakit/flags-node
Registry: npm
Minimum Node version: 18+

The Node.js SDK fetches your flag configuration from the SignaKit CDN once on startup, then evaluates all flags locally on each request — no network call per evaluation. Exposure and conversion events are fired asynchronously and do not block your request path.


Installation

npm install @signakit/flags-node

Set your SDK key as an environment variable:

SIGNAKIT_SDK_KEY=sk_dev_yourOrgId_yourProjectId_random

Initialization

Create the client once at module level — not inside a request handler. A module-level singleton reuses the fetched config across all requests within the same process lifetime and ensures in-memory exposure deduplication works correctly.

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

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

export { client }

Anti-pattern: client per request

// ❌ Never do this — bypasses dedup, re-fetches config on every request
export async function handler(req, res) {
  const client = createInstance({ sdkKey: process.env.SIGNAKIT_SDK_KEY! })
  await client.onReady()
  // ...
}

Initialize once at module level and import the singleton.

createInstance(config)

OptionTypeDefaultDescription
sdkKeystringrequiredYour SignaKit SDK key (sk_dev_… or sk_prod_…)
timeoutnumber3000Config fetch timeout in milliseconds
retriesnumber3Number of fetch retries on failure

Waiting for the client to be ready

Call onReady() before evaluating flags. It resolves once the config is fetched from the CDN — or immediately if the config is already cached.

const { success, reason } = await client.onReady()

if (!success) {
  console.error('SignaKit failed to load config:', reason)
  // Fall back to defaults — decide() returns null when not ready
}

In a serverless environment (Lambda, Vercel Functions), call onReady() outside your handler so it runs on cold start:

// Runs once per Lambda cold start — resolved by the time a request arrives
const readyPromise = client.onReady()

export async function handler(event) {
  await readyPromise
  // ...
}

Creating a user context

A UserContext represents a specific user. Create one per request with the user's ID and any attributes you want to use in targeting rules.

const userContext = client.createUserContext('user-123', {
  plan: 'pro',
  country: 'US',
  betaTester: true,
})

createUserContext(userId, attributes?)

ParameterTypeDescription
userIdstringUnique, stable identifier for the user. Used for deterministic bucketing — the same ID always produces the same variation.
attributesRecord<string, string | number | boolean>Key-value pairs matched against your audience targeting rules in the dashboard.

$userAgent attribute — pass the user's User-Agent string as $userAgent to enable bot detection. Bots receive the off variation and do not fire exposure events.

const userContext = client.createUserContext(userId, {
  $userAgent: req.headers['user-agent'],
  plan: 'pro',
})

Evaluating a single flag

const decision = userContext.decide('new-checkout')

if (decision?.enabled) {
  // Show the new checkout experience
  console.log(decision.variationKey) // e.g. "treatment"
}

decide(flagKey)

Returns a Decision object, or null if the flag does not exist, the client is not ready, or the user does not match any targeting rule.

FieldTypeDescription
flagKeystringThe flag key you passed in
enabledbooleanWhether the flag is on for this user
variationKeystringWhich variation the user is in ("control", "treatment", or your custom key)
ruleKeystringThe targeting rule that matched

Always null-check the return value. A null result means the flag was not found or the user did not match any rule — treat it as the disabled/control state.

const decision = userContext.decide('my-flag')
// decision may be null if the flag is off or the user is not targeted
const showFeature = decision?.enabled ?? false

Evaluating all flags at once

const decisions = userContext.decideAll()

if (decisions['new-checkout']?.enabled) {
  // ...
}
if (decisions['redesigned-nav']?.enabled) {
  // ...
}

decideAll()

Returns a Record<string, Decision> — an object keyed by flag key. Only flags where the user matches a targeting rule are included.

Use decideAll() selectively

Calling decideAll() evaluates every flag in your project and fires an $exposure event for each one the user is bucketed into. In middleware that runs on every request, only evaluate the flags your route actually needs. Using decideAll() in middleware is one of the most common causes of unexpected event volume spikes.


Tracking events

Track a conversion or goal event that you've defined in the SignaKit dashboard.

await userContext.trackEvent('purchase_completed', {
  value: 99.99,
  plan: 'pro',
})

trackEvent(eventName, properties?)

ParameterTypeDescription
eventNamestringThe event name, matching an event definition in your project
propertiesRecord<string, string | number | boolean>Optional metadata sent with the event

Returns: Promise<void> — fire and forget. Errors are logged but do not throw.


TypeScript types

import type {
  SignaKitClient,
  SignaKitUserContext,
  SignaKitDecision,
  SignaKitDecisions,
} from '@signakit/flags-node'

// Decision shape
type SignaKitDecision = {
  flagKey: string
  enabled: boolean
  variationKey: string
  ruleKey: string
}

// decideAll() return type
type SignaKitDecisions = Record<string, SignaKitDecision>

Targeted delivery

When a flag is configured as a targeted rollout (not an experiment), decide() returns enabled: true for users who match the audience rule. No primary metric is needed.

const betaAccess = userContext.decide('beta-dashboard')

if (betaAccess?.enabled) {
  return redirect('/dashboard/beta')
}

The user gets the same result on every call because bucketing is deterministic — the same userId always produces the same outcome for the same flag configuration.


A/B testing

When a flag is running as an experiment, decide() returns the assigned variation. The SDK automatically fires an $exposure event the first time a user is evaluated for that flag within the SDK instance's lifetime.

const checkout = userContext.decide('checkout-redesign')

switch (checkout?.variationKey) {
  case 'control':
    return <LegacyCheckout />
  case 'treatment-a':
    return <CheckoutV2 />
  case 'treatment-b':
    return <CheckoutV3 />
  default:
    return <LegacyCheckout /> // null — flag off or user not targeted
}

Track your primary metric when the user converts:

// After the user completes the purchase
await userContext.trackEvent('purchase_completed', { value: order.total })

Exposure deduplication

The Node.js SDK keeps an in-memory Set of already-exposed userId:flagKey pairs within the SDK instance's lifetime. $exposure events are only fired once per user per flag per instance — subsequent calls to decide() for the same user and flag are silent.

In practice this means:

  • Module-level singleton (recommended): dedup lasts for the process lifetime
  • Lambda / serverless: dedup lasts for the cold-start lifetime, reset on each cold start
  • Per-request client (anti-pattern): dedup provides no protection — every request fires an exposure

Error handling

onReady() never throws. If the config fetch fails after all retries, it resolves with { success: false, reason: string }. After that, decide() and decideAll() return null — your code should treat this as the default/off state.

const { success, reason } = await client.onReady()
if (!success) {
  logger.warn('SignaKit unavailable, using defaults', { reason })
}

// This is always safe — decide() returns null gracefully
const decision = userContext.decide('my-flag')
const enabled = decision?.enabled ?? false

Anti-patterns

PatternProblemFix
createInstance() inside a request handlerRe-fetches config on every request; dedup is per-instance so every request gets a fresh exposureCreate the client once at module level
decideAll() in middlewareEvaluates every flag on every request; N flags × all traffic = high event volumeUse decide('specific-flag') in middleware
Evaluating a flag inside a loop (per list item)One exposure per item per renderEvaluate once per user, apply the result to all items
Not awaiting onReady() before the first decide()decide() may return null if config hasn't loaded yetAwait onReady() at cold start

Next.js App Router

The most common integration pattern. Evaluate flags in a server component and pass results as props to client components.

app/checkout/page.tsx
import { client } from '@/lib/signakit'
import { cookies } from 'next/headers'

export default async function CheckoutPage() {
  const cookieStore = await cookies()
  const visitorId = cookieStore.get('visitor_id')?.value ?? 'anonymous'

  const userCtx = client.createUserContext(visitorId, {
    // pass any user attributes available server-side
  })

  const checkout = userCtx?.decide('checkout-redesign')

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

See the Next.js App Router guide → for the full integration including middleware and conversion tracking.


Last updated on

On this page