From Basics to Advanced: A Step-by-Step Guide to Implementing Feature Flags Without External Services


Introduction

Environment-driven feature flags offer a cost-effective, reliable, and scalable method for managing application features. By leveraging environment variables, you can toggle features across different environments—such as development, staging, and production—to support controlled rollouts, A/B testing, seamless deployments, and iterative development. This approach optimizes performance and ensures consistency.

While this guide focuses on implementing feature flags using Typescript in React and Next.js projects, the concepts and strategies discussed can be adapted to other frameworks and technologies, making it a versatile resource for any developer.

Key benefits include:

  • Optimize Performance: Disabled features are stripped from the final build, enhancing load times and security.
  • Environment-Specific Configurations: Tailor features for production, staging, or testing environments without code changes.
  • Eliminate Costs: Remove the need for external services or runtime API calls—everything is built into your environment.
  • Ensure Consistency: Deterministic behavior reduces runtime errors and simplifies debugging.
  • Support Advanced Use Cases: Extend flags for A/B testing, role-based features, and percentage rollouts.
  • Unified Context: Seamlessly work across server and client components.

This guide covers the foundational setup for environment-driven feature flags and advanced configurations for progressive rollouts, A/B testing, and role-based access control.


Handling Feature Flags in Server and Client Components

Feature flag implementations differ between client and server components due to the environment and rendering context. Below are the recommended approaches for each:

In Client Components: Use a Context Provider and Custom Hook

In client components, feature flags should be passed through a provider and accessed via a custom hook. This ensures reliable and centralized management, avoiding issues with accessing environment variables directly in the browser.

Set Up the Feature Flag Context and Hook:

'use client';

import React, { createContext, useContext } from 'react';

export type FeatureFlags = Record<string, boolean | string>;

const FeatureFlagContext = createContext<FeatureFlags | undefined>(undefined);

interface FeatureFlagProviderProps {
  children: React.ReactNode;
  featureFlags: FeatureFlags;
}

export const FeatureFlagProvider: React.FC<FeatureFlagProviderProps> = ({
  children,
  featureFlags,
}) => {
  return (
    <FeatureFlagContext.Provider value={featureFlags}>
      {children}
    </FeatureFlagContext.Provider>
  );
};

export function useCheckIsFeatureEnabled(): (featureName: string) => boolean {
  const featureFlags = useContext(FeatureFlagContext);
  if (featureFlags === undefined) {
    throw new Error(
      'useCheckIsFeatureEnabled must be used within a FeatureFlagProvider',
    );
  }

  return (featureName) => {
    const flagValue = featureFlags[featureName];
    return flagValue === 'true' || flagValue === true;
  };
}

Providing Feature Flags in the App:

import React from 'react';
import { FeatureFlagProvider } from './FeatureFlags';
import { Dashboard } from './Dashboard';

export default function App() {
  const featureFlags = {
    FF_NEW_DASHBOARD: process.env.NEXT_PUBLIC_FF_NEW_DASHBOARD === 'true',
    FF_BETA_FEATURE: process.env.NEXT_PUBLIC_FF_BETA_FEATURE === 'true',
  };

  return (
    <FeatureFlagProvider featureFlags={featureFlags}>
      <Dashboard />
    </FeatureFlagProvider>
  );
}

Usage in Client Components:

'use client';

import React from 'react';
import { useCheckIsFeatureEnabled } from './FeatureFlags';

export function Dashboard() {
  const isFeatureEnabled = useCheckIsFeatureEnabled();

  return (
    <div>
      {isFeatureEnabled('FF_NEW_DASHBOARD') ? (
        <NewDashboard />
      ) : (
        <OldDashboard />
      )}
    </div>
  );
}

In Server Components: Use a Wrapper Component or Direct Method

In server components, you have two options for checking feature flags:

  1. Using the FeatureEnabled Wrapper Component
  2. Using the checkIsFeatureFlagEnabled Method Directly

Option 1: Using the FeatureEnabled Wrapper Component

Implementing the Wrapper Component:

import React from 'react';

interface FeatureEnabledProps {
  featureFlag: string | string[];
  children: React.ReactNode;
}

function checkIsFeatureFlagEnabled(featureFlag: string): boolean {
  return process.env[featureFlag] === 'true' || process.env[featureFlag] === true;
}

export function FeatureEnabled({
  featureFlag,
  children,
}: FeatureEnabledProps) {
  const isEnabled = Array.isArray(featureFlag)
    ? featureFlag.some((flag) => checkIsFeatureFlagEnabled(flag))
    : checkIsFeatureFlagEnabled(featureFlag);

  return isEnabled ? <>{children}</> : null;
}

Usage with the Wrapper Component:

import React from 'react';
import { FeatureEnabled } from './FeatureEnabled';

export function Dashboard() {
  return (
    <div>
      <FeatureEnabled featureFlag="FF_NEW_DASHBOARD">
        <NewDashboard />
      </FeatureEnabled>
      <FeatureEnabled featureFlag="FF_OLD_DASHBOARD">
        <OldDashboard />
      </FeatureEnabled>
    </div>
  );
}

Option 2: Using the checkIsFeatureFlagEnabled Method Directly

Implementing the checkIsFeatureFlagEnabled Function:

export function checkIsFeatureFlagEnabled(featureFlag: string): boolean {
  return (
    process.env[featureFlag] === 'true' || process.env[featureFlag] === true
  )
}

Usage with Direct Method:

import React from 'react';
import { checkIsFeatureFlagEnabled } from './utils';

export function Dashboard() {
  const isNewDashboardEnabled = checkIsFeatureFlagEnabled('FF_NEW_DASHBOARD');
  const isOldDashboardEnabled = checkIsFeatureFlagEnabled('FF_OLD_DASHBOARD');

  return (
    <div>
      {isNewDashboardEnabled && <NewDashboard />}
      {isOldDashboardEnabled && <OldDashboard />}
    </div>
  );
}

Choosing Between the Two Methods:

  • Use the Wrapper Component (FeatureEnabled) when you prefer to wrap components declaratively, making your JSX cleaner and more readable.
  • Use the Direct Method (checkIsFeatureFlagEnabled) when you need more control within the component logic or when conditional rendering is more complex.

Implementing Simple Feature Flags

Defining Feature Flags in Environment Files

Create environment-specific files to define your feature flags:

# .env.production
FF_NEW_DASHBOARD=false

# .env.staging
FF_NEW_DASHBOARD=true

For client-side access, prefix the variables with NEXT_PUBLIC_:

# .env.production
NEXT_PUBLIC_FF_NEW_DASHBOARD=false

Accessing Feature Flags in Code

Server-Side Access:

export function isFeatureEnabled(feature: string): boolean {
  return process.env[feature] === 'true' || process.env[feature] === true
}

Client-Side Access via Provider:

In client components, use the FeatureFlagProvider and useCheckIsFeatureEnabled hook to access feature flags, as described in the previous section.


Advanced Feature Flagging

Extend feature flags to support A/B testing, percentage-based rollouts, and user-based targeting, such as role-based features.

Role-Based Feature Flags

Role-based feature flags allow you to enable or disable features for specific user roles, such as admin, editor, or premiumUser. This approach is useful when you want to grant access to features based on user permissions or subscription levels.

Implementing Role-Based Feature Flags

Defining Feature Configurations:

interface FeatureConfig {
  roles?: string[]
}

const featureFlags: { [key: string]: FeatureConfig } = {
  FF_ADVANCED_DASHBOARD: {
    roles: ['admin', 'manager'],
  },
  FF_PREMIUM_CONTENT: {
    roles: ['premiumUser'],
  },
}

Function to Check Role-Based Access:

function isFeatureEnabledForRole(
  featureName: string,
  userRole: string,
): boolean {
  const featureConfig = featureFlags[featureName]

  if (!featureConfig || !featureConfig.roles) {
    // Feature is not defined or not restricted by roles
    return false
  }

  return featureConfig.roles.includes(userRole)
}

Using in Components

Server Component Example:

import React from 'react';

export function RoleBasedFeature({ userRole }: { userRole: string }) {
  const isEnabled = isFeatureEnabledForRole('FF_ADVANCED_DASHBOARD', userRole);

  return isEnabled ? <AdvancedDashboard /> : <StandardDashboard />;
}

Client Component Example with Provider:

In client components, pass the user role through context or props and use it within your hook or component logic.

'use client';

import React from 'react';
import { useCheckIsFeatureEnabled } from './FeatureFlags';

export function RoleBasedFeature({ userRole }: { userRole: string }) {
  const isFeatureEnabled = useCheckIsFeatureEnabled();

  const isEnabled =
    isFeatureEnabled('FF_ADVANCED_DASHBOARD') &&
    isFeatureEnabledForRole('FF_ADVANCED_DASHBOARD', userRole);

  return isEnabled ? <AdvancedDashboard /> : <StandardDashboard />;
}

Combining Multiple Feature Flagging Strategies

You can combine role-based flags with percentage-based rollouts and A/B testing for more granular control.

Percentage-Based Rollouts

Determining Feature Availability:

import murmurhash from 'murmurhash'

function isFeatureEnabledForUser(
  userId: string,
  featureName: string,
  rolloutPercentage: number,
): boolean {
  if (!userId || !featureName) {
    console.error('Invalid user ID or feature name')
    return false
  }
  const hashInput = `${featureName}-${userId}`
  const hash = murmurhash.v3(hashInput)

  // Compute bucket number between 1 and 100
  const bucket = (hash % 100) + 1 // Adjust to 1-100 range

  return bucket <= rolloutPercentage
}

Important Best Practice: Always include the featureName in your hash functions when assigning users to buckets. This ensures a fair and independent distribution for each feature, preventing overlaps that could skew your rollout or A/B test results.

A/B Testing with User ID and Feature Name Hashing

Assigning Users to Variants:

function getABTestVariant(userId: string, featureName: string): 'A' | 'B' {
  const bucket = hashUserToBucket(userId, featureName, 2)
  return bucket === 0 ? 'A' : 'B'
}

function hashUserToBucket(
  userId: string,
  featureName: string,
  buckets: number,
): number {
  if (!userId || !featureName) {
    console.error('Invalid user ID or feature name')
    return 0
  }
  const hashInput = `${featureName}-${userId}`
  return murmurhash.v3(hashInput) % buckets // Buckets range from 0 to buckets - 1
}

Combining All Strategies

Implement a comprehensive function to check feature availability based on role, percentage rollout, and A/B testing:

function canUserAccessFeature(
  userId: string,
  featureName: string,
  userRole: string,
  rolloutPercentage: number,
): boolean {
  // Check role-based access
  if (isFeatureEnabledForRole(featureName, userRole)) {
    return true
  }

  // Check percentage-based rollout
  return isFeatureEnabledForUser(userId, featureName, rolloutPercentage)
}

Using in Components

Client Component Example with Provider and Combined Strategies:

'use client';

import React from 'react';
import { useCheckIsFeatureEnabled } from './FeatureFlags';

export function CombinedFeature({
  userId,
  userRole,
}: {
  userId: string;
  userRole: string;
}) {
  const isFeatureEnabled = useCheckIsFeatureEnabled();

  const isEnabled =
    isFeatureEnabled('FF_NEW_EXPERIENCE') &&
    canUserAccessFeature(userId, 'FF_NEW_EXPERIENCE', userRole, 30); // 30% rollout

  if (!isEnabled) {
    return <OldExperience />;
  }

  const variant = getABTestVariant(userId, 'FF_NEW_EXPERIENCE');

  return variant === 'A' ? <VariantA /> : <VariantB />;
}

In this example:

  • Role-Based Access: Users with specific roles can access the feature immediately.
  • Percentage Rollout: A certain percentage of users are granted access.
  • A/B Testing: Users with access are further split into variants 'A' and 'B' for testing purposes.

Limitations and Considerations

Environment Variables and Build Time in Next.js

Client-Side Limitations:

  • Static Injection at Build Time: In Next.js, environment variables prefixed with NEXT_PUBLIC_ are statically injected into your client-side code at build time. This means that any changes to these variables require you to rebuild and redeploy your application.
  • Limited Dynamic Toggling: Due to static injection, you cannot dynamically toggle features on the client side without redeployment. If real-time toggling is essential, consider using an API call or real-time configuration service to fetch feature flags at runtime.

Alternatives for Dynamic Client-Side Feature Toggling

  • API Calls: Fetch feature flags from a server or API endpoint at runtime.
  • Real-Time Configuration Services: Use services like Firebase Remote Config or LaunchDarkly for dynamic feature management.

Security Considerations

  • Avoid Exposing Sensitive Flags: Use prefixes like NEXT_PUBLIC_ cautiously to prevent leaking sensitive data.
  • Secure Environment Variables: Keep sensitive variables on the server side and do not expose them to the client.
  • Version Control Practices: Exclude .env files from version control to protect secrets.
  • User Role Verification: Ensure that user roles are securely verified and cannot be tampered with on the client side.

Best Practices

  • Incorporate featureName in Hash Functions: Always include the featureName when hashing to assign users to buckets. This ensures fair and independent distribution across different features.
  • Use Secure Methods to Determine User Roles: Fetch user roles from a secure source (e.g., server-side authentication) rather than trusting client-provided data.
  • Logging and Monitoring: Implement logging to track feature flag usage and changes.
  • Graceful Fallbacks: Ensure your application behaves correctly if feature flags are misconfigured.
  • Testing: Rigorously test feature flag logic to prevent unauthorized access or feature exposure.
  • Documentation: Keep clear documentation of your feature flags and their configurations to aid in maintenance and onboarding.

Final Thoughts

Environment variable-driven feature flags provide a robust, scalable, and cost-effective way to manage features across different environments. By starting with simple flags and extending to advanced use cases like role-based access, A/B testing, and percentage rollouts—with the critical step of incorporating the featureName into your hash functions—you can:

  • Enhance Deployment Flexibility: Confidently deploy features across various user segments.
  • Improve User Experience: Tailor features to specific user groups.
  • Reduce Costs: Eliminate the need for external feature flagging services.

However, it's crucial to understand the limitations of environment variables, especially in Next.js. Since client-side environment variables are injected at build time, dynamic toggling on the client without redeployment isn't feasible. For real-time feature management on the client side, consider integrating an API or a real-time configuration service.

By integrating the user role-based feature flag mechanism, you can control feature access based on user permissions, roles, or subscription levels. Combining this with percentage-based rollouts and A/B testing allows for highly granular control over how and to whom features are deployed.


Looking for a Simplified Solution?

SSK-Pro

If you want to implement feature flags without the hassle of setting up everything from scratch, consider using SSK. It provides built-in support for environment-driven feature flags, helping you save time while ensuring scalability and reliability for your projects.

With SSK, you get:

  • Preconfigured tools for managing feature flags across environments.
  • Easy integration with Next.js for both client and server components.
  • Documentation and examples to kickstart your project.

Explore how SSK can simplify your development workflow and bring your feature management to the next level!

Cheers,
Tómas


Note: This guide demonstrates how to implement feature flags effectively in both client and server components by using context providers and custom hooks in client components and either wrapper components or direct methods in server components. Adjust the implementation details according to your specific framework and project requirements.