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

Published: (December 12, 2025 at 08:27 PM EST)
3 min read
Source: Dev.to

Source: Dev.to

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

// After: Gated by feature flag

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)

// If the user doesn't have access, the component doesn't render.

The UI flows naturally without gaps.

Mode 2: Show upgrade prompt

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

The Hook That Powers It

const { hasAccess, isLoading, planName } = useFeatureAccess('reading_insights');

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 (
      <>
        <h3>Document Comparison</h3>
        <p>Currently on: {planName}</p>
        <button onClick={() => router.push('/settings/subscription')}>
          Upgrade to Business
        </button>
      </>
    );
  }

  // Normal page content...
}

The early‑return pattern keeps the locked state isolated at the top.

What Changed in This Commit

Looking at the analytics page specifically:

// Before

// After

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();
  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();
});

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 <Redirect />;
}

const someOtherHook = useSomeHook(); // Hook called conditionally!

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 <Redirect />;
}

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',
  ],
};

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.

Back to Blog

Related posts

Read more »