Feature Gating: How We Built a Freemium SaaS Without Duplicating Components
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.