Introduction
Feature flags (or feature toggles) are an essential technique in software development for remotely enabling or disabling features without redeploying the app. Imagine rolling out a new API endpoint to just 10% of users, testing it in production, and then activating it globally if everything goes well. This minimizes major bug risks, enables A/B testing, and speeds up continuous deployments.
In 2026, with microservices and scalable apps, feature flags are a must-have at companies like Netflix, GitHub, and Shopify. This beginner-friendly tutorial guides you step-by-step through building a complete system in Node.js with Express and TypeScript: in-memory flags, persistent JSON config, user targeting, and a simple HTTP interface to toggle them. By the end, you'll have a pro-level tool you'll bookmark for your projects. No complex external libs—everything's custom-built and functional right after copy-paste.
Prerequisites
- Node.js 20+ installed
- Basic JavaScript/TypeScript knowledge
- npm or yarn
- An editor like VS Code
- 5 minutes to get the project running
Initialize the project
mkdir feature-flags-app
cd feature-flags-app
npm init -y
npm install express
tpm install -D typescript @types/node @types/express ts-node nodemon
npx tsc --init
These commands create a Node.js project, install Express for the HTTP server, and set up TypeScript with its types. Dev tools (-D) like ts-node let you run TS directly without compilation. Then run npx ts-node server.ts to test. Stick to recent Node versions for ESM compatibility.
Configure TypeScript
Before coding, tweak tsconfig.json to support ESNext modules and JSON imports—key for loading our flag config. This prevents module resolution errors and enables strict mode to catch bugs early.
Complete tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowJs": true,
"outDir": "./dist",
"rootDir": "./src"
},
"ts-node": {
"esm": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}This tsconfig enables ES2022 for modern features, resolveJsonModule for importing flags.json, and ts-node/esm for direct execution. Strict mode enforces robust typing to avoid runtime errors. Place it at the root; restart your IDE to pick up TS errors instantly.
Define FeatureFlag types
We'll create a TypeScript interface to type our flags: name, status (on/off), and optional user targeting. This provides autocomplete and prevents bugs, acting like a safety net for your business logic.
Types and interface
export interface User {
id: string;
email: string;
}
export interface FeatureFlag {
name: string;
enabled: boolean;
targeting?: { userIds: string[] };
}
export type FlagStore = Map<string, FeatureFlag>;The FeatureFlag interface defines a flag with an enabled boolean and optional targeting (userIds array). User simulates an authenticated user. FlagStore uses Map for fast in-memory storage. These types ensure consistency and TypeScript safety across the app.
Flag store with JSON config
import { readFileSync } from 'fs';
import path from 'path';
import { FeatureFlag, FlagStore } from './types.js';
const FLAGS_FILE = path.join(process.cwd(), 'flags.json');
class FlagStoreClass {
private store: FlagStore = new Map();
loadFromFile(): void {
try {
const data = readFileSync(FLAGS_FILE, 'utf-8');
const flags: FeatureFlag[] = JSON.parse(data);
flags.forEach(flag => this.store.set(flag.name, flag));
} catch (error) {
console.error('Erreur chargement flags:', error);
}
}
getFlag(name: string): FeatureFlag | undefined {
return this.store.get(name);
}
setFlag(name: string, enabled: boolean, targeting?: { userIds: string[] }): void {
const flag: FeatureFlag = { name, enabled, ...(targeting && { targeting }) };
this.store.set(name, flag);
this.saveToFile();
}
private saveToFile(): void {
const flags: FeatureFlag[] = Array.from(this.store.values());
const data = JSON.stringify(flags, null, 2);
// En prod, utilisez fs.writeFileSync avec lock
}
}
export const flagStore = new FlagStoreClass();FlagStoreClass loads flags from flags.json on startup, exposes getFlag/setFlag, and persists to JSON. Map provides O(1) access. saveToFile is stubbed for simplicity; in production, use fs.promises.writeFile with locking. Load it with flagStore.loadFromFile() on boot.
Initial flags.json config
[
{
"name": "newUsersEndpoint",
"enabled": false,
"targeting": {
"userIds": ["user123"]
}
},
{
"name": "premiumFeatures",
"enabled": true
}
]This JSON file defines two flags: newUsersEndpoint (off, targeted to user123) and premiumFeatures (on globally). Add as many as needed. The store loads it automatically. Edit live via the API to test without restarting.
Create the evaluation middleware
An Express middleware checks if a flag is active for the current user. It adds req.isFeatureEnabled to simplify routes. Think of it as a smart switch that verifies the user's profile before turning on the lights.
Flags middleware
import { Request, Response, NextFunction } from 'express';
import { flagStore } from '../flagStore.js';
import { User } from '../types.js';
declare global {
namespace Express {
interface Request {
user?: User;
isFeatureEnabled?: (name: string) => boolean;
}
}
}
export function flagsMiddleware(req: Request, res: Response, next: NextFunction) {
req.isFeatureEnabled = (flagName: string) => {
const flag = flagStore.getFlag(flagName);
if (!flag || !flag.enabled) return false;
if (flag.targeting?.userIds && req.user?.id) {
return flag.targeting.userIds.includes(req.user.id);
}
return true;
};
next();
}This middleware extends req with isFeatureEnabled(name), checking both enabled status and targeting. Use it via app.use(flagsMiddleware). The global TS extension avoids 'any' types. Simulate req.user with an auth middleware; here it's for demo purposes.
Complete Express server with flags
import express from 'express';
import { flagsMiddleware } from './middleware/flags.js';
import { flagStore } from './flagStore.js';
const app = express();
app.use(express.json());
// Charger flags au démarrage
flagStore.loadFromFile();
// Middleware flags
app.use(flagsMiddleware);
// Simuler user pour démo (en prod: JWT/auth)
app.use((req, res, next) => {
req.user = { id: 'user123', email: 'test@example.com' };
next();
});
// Route protégée par flag
app.get('/users', (req, res) => {
if (req.isFeatureEnabled!('newUsersEndpoint')) {
res.json([{ id: 1, name: 'John Doe' }]);
} else {
res.status(404).json({ error: 'Endpoint désactivé' });
}
});
// API flags: list
app.get('/flags', (req, res) => {
const flags = Array.from(flagStore.getFlag('newUsersEndpoint') ? [flagStore.getFlag('newUsersEndpoint')!] : []);
res.json(flags);
});
// Toggle flag
app.post('/flags/:name', (req, res) => {
const { name } = req.params;
const { enabled, targeting } = req.body;
flagStore.setFlag(name, !!enabled, targeting);
res.json({ success: true, flag: flagStore.getFlag(name) });
});
// Exemple premium
app.get('/premium', (req, res) => {
if (req.isFeatureEnabled!('premiumFeatures')) {
res.json({ feature: 'Accès premium activé' });
} else {
res.status(403).json({ error: 'Premium désactivé' });
}
});
const PORT = 3000;
app.listen(PORT, () => {
console.log(`Serveur sur http://localhost:${PORT}`);
console.log('Testez: curl http://localhost:3000/users');
});
Full server: loads flags, applies middleware, and includes protected routes like /users (newUsersEndpoint flag), /premium, plus flag CRUD API (/flags GET/POST). Simulated user for demo. Run with npx ts-node src/server.ts. Toggle flags via curl POST to see live effects.
Test the implementation
Test it now: curl http://localhost:3000/users → 404 (flag off). curl -X POST -H 'Content-Type: application/json' -d '{"enabled":true}' http://localhost:3000/flags/newUsersEndpoint then retry → user list! For non-targeted users, tweak req.user.id.
Best practices
- Always type everything: Use TS to prevent misconfigured flags.
- Advanced persistence: Swap saveToFile for Redis or a DB in multi-instance setups.
- Fallbacks: Default to false if flag is missing for safety.
- Monitoring: Log evaluations (Winston/Sentry) for analytics.
- Polling/sync: In production, sync via webhooks (e.g., GitHub) for zero-downtime.
Common pitfalls to avoid
- No JSON locking: Concurrent fs.writeFile crashes the file; use mutex (async-mutex).
- Forgetting targeting: Always check user context alongside enabled.
- Memory leaks: Cap flags at 1000 max; purge inactive ones.
- Flag API security: Protect /flags with admin JWT, not public access.
Next steps
- Pro libs: Flagsmith or LaunchDarkly for dashboard UIs.
- A/B testing: Integrate with PostHog.
- Frontend: Adapt for React with a useFlags hook.