Feature Flags in Node.js: A Practical Guide
Feature flags on the server are different from the browser. There's no component tree to wrap in a provider, no loading state to render, and no hydration to worry about. But you do need to think about how flags flow through a request, how to avoid a waterfall of sequential API calls, and how to keep flag logic from scattering across your codebase.
This guide covers those problems using the @supashiphq/javascript-sdk. See the full Node.js SDK reference for all configuration options.
Installation
pnpm add @supashiphq/javascript-sdk
Add your API key to your environment:
SUPASHIP_API_KEY=your-server-api-key
NODE_ENV=production
Use a Server API Key (not the public key) for backend applications - it's found in your project settings under API Keys.
Creating a client
Create the client once and reuse it across your application. SupaClient is stateless per request, so a single module-level instance is the right pattern.
// lib/features.ts
import { SupaClient, FeaturesWithFallbacks } from '@supashiphq/javascript-sdk'
export const FEATURE_FLAGS = {
'new-api-endpoint': false,
'rate-limiting': {
maxRequests: 100,
windowMs: 60_000,
},
'beta-access': false,
'maintenance-mode': false,
} satisfies FeaturesWithFallbacks
export const featureClient = new SupaClient({
apiKey: process.env.SUPASHIP_API_KEY!,
environment: process.env.NODE_ENV!,
features: FEATURE_FLAGS,
context: {},
})
Two things to notice:
satisfies FeaturesWithFallbackspreserves exact literal types, sogetFeaturereturns the right type for each flag -booleanfor booleans, the exact object shape for objects.- Fallback values are returned when the flag API is unreachable, so choose safe defaults that preserve your current behaviour.
Getting a single flag
import { featureClient } from './lib/features'
const isEnabled = await featureClient.getFeature('new-api-endpoint')
// Type: boolean
if (isEnabled) {
return handleV2(req)
}
return handleV1(req)
Pass a per-request context to evaluate flags against the current user:
const isEnabled = await featureClient.getFeature('beta-access', {
context: {
userId: req.user.id,
email: req.user.email,
plan: req.user.plan,
},
})
Batching multiple flags
Every getFeature call is a network request. If you need several flags, use getFeatures to fetch them all in one go:
// ❌ Three separate requests
const newEndpoint = await featureClient.getFeature('new-api-endpoint')
const betaAccess = await featureClient.getFeature('beta-access')
const maintenance = await featureClient.getFeature('maintenance-mode')
// ✅ One request
const features = await featureClient.getFeatures(
['new-api-endpoint', 'beta-access', 'maintenance-mode'],
{ context: { userId: req.user.id, plan: req.user.plan } },
)
Object-valued flags for configuration
Boolean flags answer "is this on?". Object-valued flags let the server carry how something should behave without a code change:
const FEATURE_FLAGS = {
'rate-limiting': {
maxRequests: 100,
windowMs: 60_000,
},
} satisfies FeaturesWithFallbacks
const rateLimitConfig = await featureClient.getFeature('rate-limiting')
// Type: { maxRequests: number; windowMs: number }
app.use(
rateLimit({
max: rateLimitConfig.maxRequests,
windowMs: rateLimitConfig.windowMs,
}),
)
Update the values in the Supaship dashboard and they propagate on the next request - no redeploy needed.
Express.js: flag middleware
The cleanest Express pattern is a middleware that fetches all flags once per request and attaches them to req. Downstream handlers read from req.features without touching the SDK directly.
// middleware/featureFlags.ts
import { Request, Response, NextFunction } from 'express'
import { featureClient } from '../lib/features'
declare global {
namespace Express {
interface Request {
features: {
'new-api-endpoint': boolean
'beta-access': boolean
'maintenance-mode': boolean
'rate-limiting': { maxRequests: number; windowMs: number }
}
}
}
}
export async function featureFlagsMiddleware(
req: Request,
res: Response,
next: NextFunction,
) {
req.features = await featureClient.getFeatures(
['new-api-endpoint', 'beta-access', 'maintenance-mode', 'rate-limiting'],
{
context: {
userId: req.user?.id,
email: req.user?.email,
plan: req.user?.plan,
userAgent: req.get('User-Agent'),
},
},
)
next()
}
// app.ts
import express from 'express'
import { featureFlagsMiddleware } from './middleware/featureFlags'
const app = express()
app.use(featureFlagsMiddleware)
app.get('/api/data', (req, res) => {
if (req.features['maintenance-mode']) {
return res.status(503).json({ message: 'Down for maintenance' })
}
if (req.features['new-api-endpoint']) {
return res.json({ data: 'enhanced-data', version: 'v2' })
}
return res.json({ data: 'basic-data', version: 'v1' })
})
This keeps flag evaluation in one place and avoids scattering getFeature calls across your route handlers.
Fastify: preHandler hook
The Fastify equivalent uses a preHandler hook and TypeScript declaration merging to add features to the request type:
import Fastify, { FastifyRequest, FastifyReply } from 'fastify'
import { SupaClient, FeaturesWithFallbacks } from '@supashiphq/javascript-sdk'
const FEATURE_FLAGS = {
'new-api-endpoint': false,
'premium-support': false,
'advanced-metrics': false,
} satisfies FeaturesWithFallbacks
declare module 'fastify' {
interface FastifyRequest {
features: {
'new-api-endpoint': boolean
'premium-support': boolean
'advanced-metrics': boolean
}
}
}
const fastify = Fastify({ logger: true })
const featureClient = new SupaClient({
apiKey: process.env.SUPASHIP_API_KEY!,
environment: process.env.NODE_ENV!,
features: FEATURE_FLAGS,
context: {},
})
fastify.addHook(
'preHandler',
async (request: FastifyRequest, reply: FastifyReply) => {
request.features = await featureClient.getFeatures(
['new-api-endpoint', 'premium-support', 'advanced-metrics'],
{
context: {
userId: request.user?.id,
plan: request.user?.plan ?? 'free',
},
},
)
},
)
fastify.get('/api/dashboard', async request => {
const data: Record<string, unknown> = { version: 'v1' }
if (request.features['new-api-endpoint']) {
data.version = 'v2'
data.charts = ['revenue', 'users', 'engagement']
}
if (request.features['advanced-metrics']) {
data.metrics = { realtime: true, historical: true }
}
return data
})
Serverless functions
In serverless environments there's no persistent in-memory state between invocations, so the pattern is simpler - create the client outside the handler (it's reused across warm invocations) and fetch flags inside:
// api/process-order.ts (Vercel / Netlify function)
import { SupaClient, FeaturesWithFallbacks } from '@supashiphq/javascript-sdk'
const FEATURE_FLAGS = {
'new-checkout-flow': false,
'fraud-detection-v2': false,
} satisfies FeaturesWithFallbacks
// Created once per cold start, reused across warm invocations
const featureClient = new SupaClient({
apiKey: process.env.SUPASHIP_API_KEY!,
environment: process.env.ENVIRONMENT ?? 'production',
features: FEATURE_FLAGS,
context: {},
})
export default async function handler(req: Request) {
const { userId, orderId } = await req.json()
const features = await featureClient.getFeatures(
['new-checkout-flow', 'fraud-detection-v2'],
{ context: { userId } },
)
if (features['fraud-detection-v2']) {
await runFraudCheckV2(orderId)
}
if (features['new-checkout-flow']) {
return Response.json(await processOrderV2(orderId))
}
return Response.json(await processOrderV1(orderId))
}
Updating context at runtime
If you create the client with a shared context and need to change it later - for example after resolving a user from a token - use updateContext:
// Merge new fields into the existing context
featureClient.updateContext({ userId: '456', plan: 'enterprise' })
// Replace the entire context
featureClient.updateContext({ userId: '456' }, false)
// Read it back
const ctx = featureClient.getContext()
In a request-scoped scenario you'll typically pass the context as an override to getFeatures rather than mutating the shared client state - that's safer in concurrent environments.
Handling errors gracefully
getFeature / getFeatures return fallback values on network errors by default, so your application keeps working even if the flag API is temporarily unreachable. Still, log errors so you know when flags aren't being evaluated dynamically:
try {
const features = await featureClient.getFeatures(['new-api-endpoint'], {
context: { userId: req.user.id },
})
// use features
} catch (err) {
logger.error('Feature flag evaluation failed, using fallbacks', { err })
// getFeatures already returned fallbacks - code below still runs correctly
}
Network and retry configuration
For latency-sensitive services, tune the SDK's network behaviour:
const featureClient = new SupaClient({
apiKey: process.env.SUPASHIP_API_KEY!,
environment: process.env.NODE_ENV!,
features: FEATURE_FLAGS,
context: {},
networkConfig: {
requestTimeoutMs: 3_000, // abort after 3 s (default: 10 s)
retry: {
enabled: true,
maxAttempts: 2, // fewer retries on hot paths
backoff: 500, // 500 ms base backoff
},
},
})
PII and sensitive context
If your context includes personally identifiable information (emails, user IDs), hash it on the client before it's sent to the edge:
const featureClient = new SupaClient({
apiKey: process.env.SUPASHIP_API_KEY!,
environment: process.env.NODE_ENV!,
features: FEATURE_FLAGS,
context: {
userId: user.id,
email: user.email,
},
sensitiveContextProperties: ['email', 'userId'],
})
Supaship hashes the listed properties before the request leaves your server. Targeting still works - the same value always hashes to the same result - but the raw data never reaches the flag API.
Organising flags at scale
As your application grows, keep flag definitions in one place:
// lib/features.ts - the single source of truth
import {
SupaClient,
FeaturesWithFallbacks,
InferFeatures,
} from '@supashiphq/javascript-sdk'
export const FEATURE_FLAGS = {
// API behaviour
'new-api-endpoint': false,
'rate-limiting': { maxRequests: 100, windowMs: 60_000 },
// Access control
'beta-access': false,
'maintenance-mode': false,
// Background jobs
'async-email-queue': false,
'nightly-report-v2': false,
} satisfies FeaturesWithFallbacks
export type AppFeatures = InferFeatures<typeof FEATURE_FLAGS>
export const featureClient = new SupaClient({
apiKey: process.env.SUPASHIP_API_KEY!,
environment: process.env.NODE_ENV!,
features: FEATURE_FLAGS,
context: {},
})
Import featureClient wherever you need it. The AppFeatures type is useful if you pass flag values between layers and want TypeScript to validate them end-to-end.
Removing stale flags
A flag that's been fully rolled out is just dead code wrapped in an if statement. When you enable a flag globally, schedule time to:
- Remove the flag check from your code
- Delete the flag in the Supaship dashboard
- Remove the flag from your
FEATURE_FLAGSdefinition
This keeps the codebase clean and avoids the confusion of encountering a flag that's always true.
Getting started with Supaship
All patterns above use Supaship: percentage rollouts, user and plan targeting, and a dashboard to change flag values instantly without a redeploy.
Ready to ship with confidence? Start for free and have your first server-side flag live in minutes. Free forever up to 1M events/month. Pro plan is $30/month with unlimited projects. See the full Node.js SDK reference and targeting docs.
Related framework guides: Feature Flags in Next.js · Feature Flags in React · Feature Flags in Vue
Comparing platforms? Supaship vs LaunchDarkly · Supaship vs Statsig · Supaship vs ConfigCat · Best Feature Flag Platforms 2026
Feedback
Got thoughts on this?
We're constantly learning how developers actually use these tools. Ideas, use cases, integration requests — every bit of feedback makes the platform better for everyone.
Thanks for being part of the journey — Supaship