Freemium is the dominant SaaS business model for a reason. Let users experience the product, demonstrate value, then convert them to paid plans when they hit limits or need advanced features. The business logic is simple. The engineering, if done wrong, becomes the single largest source of technical debt in your codebase.
We have worked on SaaS products where feature gating was implemented as hundreds of scattered `if (user.plan === 'free')` checks across the frontend and backend. Every pricing change required a full codebase search, a prayer that nothing was missed, and a week of QA. This is the default outcome when feature gating is treated as a simple conditional instead of an architectural concern.
This post covers how to build freemium feature gating that is clean, maintainable, and flexible enough to support pricing experiments without rewriting code.
The Problem with Naive Feature Gating
The naive approach looks like this: you have a user object with a `plan` field. When you need to restrict a feature, you check the plan name.
```
if (user.plan === 'pro' || user.plan === 'enterprise') {
showAdvancedAnalytics();
}
```
This works for about three months. Then the problems start:
Plan name coupling. Your UI code, your API middleware, and your database queries all reference specific plan names. When marketing wants to rename "Pro" to "Growth," or when you introduce a "Business" tier between Pro and Enterprise, every check needs updating.
Scattered logic. Feature checks end up in components, API routes, middleware, database policies, and email templates. There is no single place to see what each plan includes. The source of truth is "the entire codebase."
Hard to experiment. Want to give free users access to a premium feature for 14 days as an experiment? With plan name checks, this requires touching every location that gates that feature. Want to offer a custom plan for an enterprise client? Same problem.
Frontend and backend drift. The frontend hides a button, but the backend does not enforce the restriction. Or the backend blocks access, but the frontend still shows a broken UI. Without a shared gating system, the two layers inevitably drift.
The Entitlement Based Architecture
The solution is an entitlement layer that sits between your plans and your feature checks. Instead of checking plan names, your code checks capabilities. Instead of hardcoding what each plan includes, a configuration table maps plans to capabilities.
The architecture has three layers:
Layer 1: Plans. These are your billing constructs. Free, Starter, Pro, Enterprise, whatever you sell. Plans live in your billing system (Stripe, in most cases) and in a `plans` table in your database. Plans change when pricing changes. Your application code should never reference plan names directly.
Layer 2: Entitlements. This is the mapping layer. An `entitlements` table defines what each plan grants access to. Each row maps a plan to a capability with optional parameters (limits, quotas, feature flags). When you create a new plan or modify an existing one, you update this table. Nothing else changes.
Layer 3: Capability checks. Your application code checks capabilities, not plans. Instead of `user.plan === 'pro'`, you check `user.can('use_advanced_analytics')` or `user.limit('team_members') > currentCount`. These checks are the same regardless of which plan grants the capability.
The feature flags pattern we have written about previously is a natural extension of this system. Feature flags control availability at the release level. Entitlements control availability at the billing level. Combining them lets you gate features both by plan and by rollout status.
Database Schema for Entitlements
Here is the schema we use on production systems:
```sql
-- Plans defined by billing
create table plans (
id uuid primary key,
stripe_product_id text unique,
name text not null,
slug text unique not null,
is_free boolean default false
);
-- Capabilities that can be granted
create table capabilities (
id uuid primary key,
slug text unique not null,
name text not null,
type text not null check (type in ('boolean', 'limit', 'tier')),
description text
);
-- What each plan grants
create table plan_entitlements (
plan_id uuid references plans(id),
capability_id uuid references capabilities(id),
granted boolean default true,
limit_value integer,
primary key (plan_id, capability_id)
);
-- Override entitlements per organization (custom deals, trials)
create table org_entitlement_overrides (
org_id uuid references organizations(id),
capability_id uuid references capabilities(id),
granted boolean,
limit_value integer,
expires_at timestamptz,
reason text,
primary key (org_id, capability_id)
);
```
The `org_entitlement_overrides` table is critical. This is how you handle enterprise custom deals ("give them unlimited team members for 90 days"), feature trials ("let this organization try analytics for 14 days"), and grandfathered access ("they signed up when Pro included this feature").
The resolution logic is: check org overrides first (if not expired), then fall back to plan entitlements. This gives you per organization flexibility without complicating the standard plan based flow.
Backend Enforcement
Feature gating must be enforced on the backend. Frontend checks are for UX (hiding buttons, showing upgrade prompts), not for security. Any feature that a free user should not access needs a backend guard that returns a 403 with a clear upgrade message.
We implement this as middleware that runs before route handlers:
```
// Middleware: requireCapability('advanced_analytics')
// 1. Load user's organization
// 2. Check org_entitlement_overrides for this capability
// 3. If no override, check plan_entitlements for the org's plan
// 4. If not granted, return 403 with upgrade prompt
// 5. If granted with a limit, attach the limit to the request context
```
This middleware is reusable across every route. Adding a new gated feature means adding one capability row to the database and one middleware call to the route. No conditional logic spread across files.
For database level enforcement, row level security policies can reference the same entitlements. A query for analytics data can include a policy that checks whether the requesting user's organization has the `advanced_analytics` capability. This provides defense in depth: even if the middleware is bypassed, the database will not return data the organization is not entitled to.
Frontend Integration
On the frontend, the user's resolved entitlements should be loaded once at authentication time and stored in application state. The shape is simple: a map of capability slugs to their values.
```json
{
"advanced_analytics": true,
"team_members": 5,
"api_access": false,
"custom_domains": false,
"data_export": true
}
```
Components check this map to decide what to render:
- Gated feature: Check the capability. If not granted, render an upgrade prompt or lock icon instead of the feature.
- Usage limit: Check the limit value against current usage. If approaching or at the limit, show a contextual upgrade nudge.
- Hidden feature: If the capability is not granted, do not render the feature at all. This is appropriate for features that would confuse free users.
The key rule is that the frontend entitlement map is a cache, not the authority. The backend always makes the final decision. If the frontend map is stale (the user just upgraded but the page has not refreshed), the backend will still allow the request, and the frontend will catch up on the next auth refresh.
Handling Upgrades and Downgrades
When a user upgrades, the flow is:
1. Stripe processes the subscription change
2. Your webhook handler receives the event
3. The handler updates the organization's plan reference
4. The entitlements resolution picks up the new plan's capabilities immediately
5. The frontend refreshes the entitlement map (via polling, WebSocket push, or on next page load)
Downgrades are harder because you need to handle graceful degradation. If a user downgrades from Pro to Free and they have 15 team members (Free limit is 5), you do not delete 10 members. Instead:
- The organization is flagged as over limit
- Existing team members retain access (read only or full, depending on your policy)
- The organization cannot add new members until they are under the limit
- A banner explains the situation and prompts either an upgrade or member removal
This graceful degradation pattern applies to every limited capability: storage usage, project counts, API call quotas. Never silently remove access to existing data. Always inform the user and give them a path to resolution.
Testing the Entitlement System
Entitlement logic is critical path. If the gating is wrong, either free users access paid features (revenue loss) or paid users are blocked from features they purchased (support tickets and churn). Both are unacceptable.
Test with a matrix approach: for each capability, verify the behavior for every plan level and for override scenarios. This is a perfect use case for parameterized tests that run the same assertion against multiple plan and entitlement combinations. Our automated testing strategy guide covers the broader approach to structuring these test suites.
Why This Matters for Your Business
A clean entitlement system is not just an engineering preference. It directly enables business agility. When your pricing team wants to test a new tier, add a plan and map its entitlements. When sales closes a custom deal, add an org override. When you want to run a feature trial to drive conversions, set an override with an expiration date. None of these require code changes or deployments.
We have built entitlement systems that support 8+ pricing tiers, per organization overrides, time limited feature trials, and usage based billing, all from the same architecture. The initial build takes 2 to 3 weeks. The ongoing maintenance is nearly zero because new features and plans are configuration changes, not code changes. For teams debating whether to build this in house or hire externally, our Veld vs in house comparison covers the tradeoffs honestly.
If you are building a SaaS product with freemium and want to get the gating architecture right from the start, this is core to the system architecture work we do. Or if you already have spaghetti gating and need to refactor it without breaking things, reach out and we will scope the cleanup.