DEV Community

Aniefon Umanah
Aniefon Umanah

Posted on

Feature Gating: How We Built a Freemium SaaS Without Duplicating Components

The problem with most feature-gating implementations? They end up scattering billing checks throughout your codebase like landmines. You've got if (user.plan === 'pro') everywhere, making it impossible to test components in isolation or understand what features are actually gated.

Here's how we solved it with a FeatureGate component that wraps restricted features instead of modifying them.

The Problem

We needed to add subscription tiers to our analytics dashboard. Some features should show upgrade prompts on free plans, others should be completely hidden. But we didn't want to:

  • Touch every component that needs restrictions
  • Break existing tests
  • Make components aware of billing logic
  • Duplicate UI for "locked" states

The Solution: A Wrapper Component

Instead of modifying components to check access, we wrap them:

// Before: No restrictions
<DeviceBreakdown />

// After: Gated by feature flag
<FeatureGate feature="device_analytics">
  <DeviceBreakdown />
</FeatureGate>
Enter fullscreen mode Exit fullscreen mode

The component itself stays pure. The billing logic lives in one place.

Two Modes of Gating

We built two behaviors into FeatureGate:

Mode 1: Hide completely (default)

<FeatureGate feature="ai_analysis" mode="hide">
  <AIPlayground />
</FeatureGate>
Enter fullscreen mode Exit fullscreen mode

If the user doesn't have access, the component doesn't render. The UI flows naturally without gaps.

Mode 2: Show upgrade prompt

<FeatureGate feature="ab_tests" mode="replace">
  <ABTestBuilder />
</FeatureGate>
Enter fullscreen mode Exit fullscreen mode

This shows a standardized upgrade card with the plan requirement and CTA.

The Hook That Powers It

const { hasAccess, isLoading, planName } = useFeatureAccess('reading_insights');
Enter fullscreen mode Exit fullscreen mode

This hook:

  • Checks the current user's plan
  • Returns whether they have access to the feature
  • Provides loading states
  • Gives us the plan name for messaging

Handling Page-Level Gates

For full pages that require upgrades, we added checks at the route level:

export default function ComparePage() {
  const { hasAccess, isLoading, planName } = useFeatureAccess('document_comparison');

  if (!isLoading && !hasAccess) {
    return (
      <div className="upgrade-prompt">
        <Lock className="h-8 w-8" />
        <h3>Document Comparison</h3>
        <p>Currently on: {planName}</p>
        <Button onClick={() => router.push('/settings/subscription')}>
          Upgrade to Business
        </Button>
      </div>
    );
  }

  // Normal page content...
}
Enter fullscreen mode Exit fullscreen mode

Early return pattern keeps the locked state isolated at the top.

What Changed in This Commit

Looking at the analytics page specifically:

// Before
<DeviceBreakdown />
<LocationBreakdown />
<BrowserBreakdown />
<VisitorTable />

// After
<FeatureGate feature="device_analytics">
  <DeviceBreakdown />
</FeatureGate>
<FeatureGate feature="location_analytics">
  <LocationBreakdown />
</FeatureGate>
<FeatureGate feature="browser_analytics">
  <BrowserBreakdown />
</FeatureGate>
<FeatureGate feature="reading_insights">
  <VisitorTable />
</FeatureGate>
Enter fullscreen mode Exit fullscreen mode

Each analytics widget is independently gated. Free users see basic metrics, paid users see the full breakdown.

Testing Benefits

The biggest win? Our components stay testable:

// Component test - no billing logic
it('renders device breakdown', () => {
  render(<DeviceBreakdown />);
  expect(screen.getByText('Mobile')).toBeInTheDocument();
});

// Integration test - with feature gate
it('hides device breakdown for free users', () => {
  mockUser({ plan: 'free' });
  render(
    <FeatureGate feature="device_analytics">
      <DeviceBreakdown />
    </FeatureGate>
  );
  expect(screen.queryByText('Mobile')).not.toBeInTheDocument();
});
Enter fullscreen mode Exit fullscreen mode

The Gotcha: Early Returns and Hooks

We hit an issue in the link detail page. Initial code:

const { hasAccess } = useFeatureAccess('reading_insights');

// 🚫 This violates Rules of Hooks
if (!hasAccess) {
  return <UpgradePrompt />;
}

const someOtherHook = useSomeHook(); // Hook called conditionally!
Enter fullscreen mode Exit fullscreen mode

The fix: Call all hooks first, then check access:

const { hasAccess } = useFeatureAccess('reading_insights');
const someOtherHook = useSomeHook();
const router = useRouter();

// ✅ Now we can return early safely
if (!hasAccess) {
  return <UpgradePrompt />;
}
Enter fullscreen mode Exit fullscreen mode

Configuration Lives in One Place

All feature definitions live in a single config:

const FEATURE_ACCESS = {
  free: ['basic_analytics'],
  pro: ['basic_analytics', 'reading_insights', 'device_analytics'],
  business: ['basic_analytics', 'reading_insights', 'device_analytics', 'document_comparison', 'ab_tests'],
};
Enter fullscreen mode Exit fullscreen mode

Want to change what's included in Pro? Update one object.

The Result

  • 17 files modified with feature gates
  • Zero changes to the actual feature components
  • Consistent upgrade prompts across the app
  • Clean separation between features and billing

The wrapper pattern keeps billing concerns isolated. Components stay focused on what they do, not who can see them.

Top comments (0)