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:
- Using the
FeatureEnabled
Wrapper Component - 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 thefeatureName
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?
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.