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>
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>
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>
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 (
<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...
}
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>
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();
});
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!
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 />;
}
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.
Top comments (0)