Implementing Feature Flags Without a Service
What We’re Building
A lightweight feature flag system that doesn’t require a third-party service. Good for small teams, side projects, or when you want full control.
Prerequisites
- TypeScript
- Zod (optional but recommended)
The Approach
- Define flags in a JSON config
- Create a typed flag reader
- Support environment-based overrides
- Add percentage rollouts
Step 1: Define Your Flags
Create config/features.json:
{
"newDashboard": {
"enabled": false,
"description": "Redesigned dashboard UI"
},
"darkMode": {
"enabled": true,
"description": "Dark mode support"
},
"betaFeatures": {
"enabled": false,
"percentage": 10,
"description": "Gradual rollout of beta features"
}
}
Step 2: Create the Type-Safe Reader
Create src/features.ts:
import { z } from 'zod';
import flags from '../config/features.json';
const flagSchema = z.object({
enabled: z.boolean(),
description: z.string().optional(),
percentage: z.number().min(0).max(100).optional(),
});
const flagsSchema = z.record(flagSchema);
type FlagName = keyof typeof flags;
type FlagConfig = z.infer<typeof flagSchema>;
const validatedFlags = flagsSchema.parse(flags);
export function isEnabled(flagName: FlagName, userId?: string): boolean {
const flag = validatedFlags[flagName];
if (!flag) return false;
if (!flag.enabled) return false;
if (flag.percentage !== undefined && userId) {
return hashToPercentage(userId) < flag.percentage;
}
return true;
}
function hashToPercentage(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash) + str.charCodeAt(i);
hash = hash & hash;
}
return Math.abs(hash % 100);
}
Step 3: Add Environment Overrides
export function isEnabled(flagName: FlagName, userId?: string): boolean {
// Environment override takes precedence
const envOverride = process.env[`FEATURE_${flagName.toUpperCase()}`];
if (envOverride !== undefined) {
return envOverride === 'true';
}
const flag = validatedFlags[flagName];
// ... rest of logic
}
Now you can override in production:
FEATURE_NEWDASHBOARD=true node app.js
Step 4: Use in Your Code
import { isEnabled } from './features';
if (isEnabled('newDashboard')) {
return <NewDashboard />;
}
// With percentage rollout
if (isEnabled('betaFeatures', user.id)) {
showBetaUI();
}
Step 5: Add React Hook (Optional)
import { createContext, useContext } from 'react';
const FeatureContext = createContext<{ userId?: string }>({});
export function useFeature(flagName: FlagName): boolean {
const { userId } = useContext(FeatureContext);
return isEnabled(flagName, userId);
}
// Usage
function Component() {
const showNewUI = useFeature('newDashboard');
return showNewUI ? <New /> : <Old />;
}
The Result
- Type-safe flag names with autocomplete
- Percentage rollouts for gradual releases
- Environment overrides for testing
- Zero external dependencies (other than optional Zod)
What I’d Do Differently
Add a simple admin UI early. Editing JSON files works, but non-developers can’t toggle flags without a deploy.
This scales surprisingly well. I’ve used this pattern for apps with 50+ flags before considering a paid service.