Implementing Feature Flags Without a Service

· 2 min read
typescript patterns configuration

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

  1. Define flags in a JSON config
  2. Create a typed flag reader
  3. Support environment-based overrides
  4. 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.

Available for rescue and re-platforming work

I take over platforms that already exist and are in trouble. Node, TypeScript, React and Laravel, mostly in regulated or high-traffic environments. If something needs rescuing, re-platforming or finishing, my full history is on the CV.

Related Posts

Comments