Feature gating restricts access based on subscription plans. Here is how to build it for SaaS applications.
Step 1: Define Features and Plans
// lib/features.ts
export const FEATURES = {
// Core features
"projects.create": { name: "Create Projects", type: "boolean" },
"projects.limit": { name: "Project Limit", type: "limit" },
"members.limit": { name: "Team Members", type: "limit" },
"storage.limit": { name: "Storage (GB)", type: "limit" },
// Premium features
"analytics.advanced": { name: "Advanced Analytics", type: "boolean" },
"api.access": { name: "API Access", type: "boolean" },
"webhooks": { name: "Webhooks", type: "boolean" },
"custom.domain": { name: "Custom Domain", type: "boolean" },
"sso": { name: "SSO", type: "boolean" },
"audit.log": { name: "Audit Log", type: "boolean" },
"priority.support": { name: "Priority Support", type: "boolean" },
"exports.csv": { name: "CSV Export", type: "boolean" },
"exports.pdf": { name: "PDF Export", type: "boolean" },
"white.label": { name: "White Label", type: "boolean" },
} as const;
export type FeatureKey = keyof typeof FEATURES;
type FeatureValue = boolean | number;
export type PlanFeatures = Record<FeatureKey, FeatureValue>;
export const PLAN_FEATURES: Record<string, PlanFeatures> = {
free: {
"projects.create": true,
"projects.limit": 3,
"members.limit": 1,
"storage.limit": 1,
"analytics.advanced": false,
"api.access": false,
"webhooks": false,
"custom.domain": false,
"sso": false,
"audit.log": false,
"priority.support": false,
"exports.csv": false,
"exports.pdf": false,
"white.label": false,
},
starter: {
"projects.create": true,
"projects.limit": 10,
"members.limit": 5,
"storage.limit": 10,
"analytics.advanced": false,
"api.access": true,
"webhooks": false,
"custom.domain": false,
"sso": false,
"audit.log": false,
"priority.support": false,
"exports.csv": true,
"exports.pdf": false,
"white.label": false,
},
pro: {
"projects.create": true,
"projects.limit": 50,
"members.limit": 25,
"storage.limit": 100,
"analytics.advanced": true,
"api.access": true,
"webhooks": true,
"custom.domain": true,
"sso": false,
"audit.log": true,
"priority.support": true,
"exports.csv": true,
"exports.pdf": true,
"white.label": false,
},
enterprise: {
"projects.create": true,
"projects.limit": -1, // unlimited
"members.limit": -1,
"storage.limit": -1,
"analytics.advanced": true,
"api.access": true,
"webhooks": true,
"custom.domain": true,
"sso": true,
"audit.log": true,
"priority.support": true,
"exports.csv": true,
"exports.pdf": true,
"white.label": true,
},
};
export function hasFeature(plan: string, feature: FeatureKey): boolean {
const features = PLAN_FEATURES[plan];
if (!features) return false;
const value = features[feature];
return typeof value === "boolean" ? value : value !== 0;
}
export function getFeatureLimit(plan: string, feature: FeatureKey): number {
const features = PLAN_FEATURES[plan];
if (!features) return 0;
const value = features[feature];
return typeof value === "number" ? value : value ? -1 : 0;
}
export function isWithinLimit(plan: string, feature: FeatureKey, currentUsage: number): boolean {
const limit = getFeatureLimit(plan, feature);
if (limit === -1) return true; // unlimited
return currentUsage < limit;
}
export function getUpgradePlan(currentPlan: string, feature: FeatureKey): string | null {
const planOrder = ["free", "starter", "pro", "enterprise"];
const currentIndex = planOrder.indexOf(currentPlan);
for (let i = currentIndex + 1; i < planOrder.length; i++) {
if (hasFeature(planOrder[i], feature)) {
return planOrder[i];
}
}
return null;
}
Step 2: Server-Side Check
// lib/features-server.ts
import { auth } from "@/lib/auth";
import { db } from "@/db";
import { users } from "@/db/schema";
import { eq } from "drizzle-orm";
import { hasFeature, isWithinLimit, type FeatureKey } from "./features";
export async function checkFeatureAccess(feature: FeatureKey): Promise<{
allowed: boolean;
plan: string;
upgradeTo: string | null;
}> {
const session = await auth();
if (!session?.user) {
return { allowed: false, plan: "free", upgradeTo: "starter" };
}
const user = await db
.select({ plan: users.plan })
.from(users)
.where(eq(users.id, session.user.id))
.limit(1);
const plan = user[0]?.plan ?? "free";
const allowed = hasFeature(plan, feature);
return {
allowed,
plan,
upgradeTo: allowed ? null : getUpgradePlan(plan, feature),
};
}
function getUpgradePlan(currentPlan: string, feature: FeatureKey): string | null {
const planOrder = ["free", "starter", "pro", "enterprise"];
const currentIndex = planOrder.indexOf(currentPlan);
for (let i = currentIndex + 1; i < planOrder.length; i++) {
if (hasFeature(planOrder[i], feature)) {
return planOrder[i];
}
}
return null;
}
Step 3: Feature Gate Component
"use client";
import { Lock, ArrowRight } from "lucide-react";
import Link from "next/link";
interface FeatureGateProps {
feature: string;
hasAccess: boolean;
upgradeLabel?: string;
children: React.ReactNode;
}
export function FeatureGate({
feature,
hasAccess,
upgradeLabel = "Upgrade to unlock",
children,
}: FeatureGateProps) {
if (hasAccess) return <>{children}</>;
return (
<div className="relative">
{/* Blurred content */}
<div className="pointer-events-none select-none blur-sm" aria-hidden="true">
{children}
</div>
{/* Upgrade overlay */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="rounded-xl bg-white/90 p-6 text-center shadow-lg backdrop-blur dark:bg-gray-900/90">
<Lock className="mx-auto mb-3 h-8 w-8 text-gray-400" />
<p className="mb-1 text-sm font-semibold">Premium Feature</p>
<p className="mb-4 text-xs text-gray-500">{feature} requires an upgrade</p>
<Link
href="/pricing"
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
{upgradeLabel}
<ArrowRight className="h-4 w-4" />
</Link>
</div>
</div>
</div>
);
}
Step 4: Usage Limit Component
"use client";
interface UsageLimitProps {
feature: string;
current: number;
limit: number; // -1 for unlimited
onUpgrade: () => void;
}
export function UsageLimit({ feature, current, limit, onUpgrade }: UsageLimitProps) {
const isUnlimited = limit === -1;
const percentage = isUnlimited ? 0 : (current / limit) * 100;
const isNearLimit = percentage >= 80;
const isAtLimit = percentage >= 100;
return (
<div className="rounded-lg border p-4 dark:border-gray-700">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium">{feature}</span>
<span className="text-sm text-gray-500">
{current} / {isUnlimited ? "Unlimited" : limit}
</span>
</div>
{!isUnlimited && (
<div className="h-2 rounded-full bg-gray-100 dark:bg-gray-800">
<div
className={`h-2 rounded-full transition-all ${
isAtLimit
? "bg-red-500"
: isNearLimit
? "bg-orange-500"
: "bg-blue-500"
}`}
style={{ width: `${Math.min(percentage, 100)}%` }}
/>
</div>
)}
{isAtLimit && (
<div className="mt-2 flex items-center justify-between">
<p className="text-xs text-red-600">Limit reached</p>
<button
onClick={onUpgrade}
className="text-xs font-medium text-blue-600 hover:underline"
>
Upgrade for more
</button>
</div>
)}
{isNearLimit && !isAtLimit && (
<p className="mt-2 text-xs text-orange-600">
Approaching limit ({limit - current} remaining)
</p>
)}
</div>
);
}
Step 5: Usage in Server Components
// app/dashboard/analytics/page.tsx
import { checkFeatureAccess } from "@/lib/features-server";
import { FeatureGate } from "@/components/FeatureGate";
import { AdvancedAnalytics } from "@/components/analytics/AdvancedAnalytics";
import { BasicAnalytics } from "@/components/analytics/BasicAnalytics";
export default async function AnalyticsPage() {
const { allowed } = await checkFeatureAccess("analytics.advanced");
return (
<div className="space-y-8">
<h1 className="text-2xl font-bold">Analytics</h1>
{/* Basic analytics always shown */}
<BasicAnalytics />
{/* Advanced analytics gated */}
<FeatureGate feature="Advanced Analytics" hasAccess={allowed}>
<AdvancedAnalytics />
</FeatureGate>
</div>
);
}
Step 6: API Route Guard
// lib/api-guard.ts
import { NextRequest, NextResponse } from "next/server";
import { checkFeatureAccess } from "./features-server";
import type { FeatureKey } from "./features";
export function withFeatureCheck(feature: FeatureKey) {
return async function guard(
request: NextRequest,
handler: () => Promise<NextResponse>
) {
const { allowed, plan, upgradeTo } = await checkFeatureAccess(feature);
if (!allowed) {
return NextResponse.json(
{
error: "Feature not available on your plan",
plan,
upgradeTo,
upgradeUrl: "/pricing",
},
{ status: 403 }
);
}
return handler();
};
}
Summary
- Centralized feature configuration per plan
- Server-side access checks before rendering
- Blurred overlay with upgrade prompts for locked features
- Usage limit indicators with warnings
- API route guards prevent unauthorized access
Need SaaS Feature Management?
We build subscription-based applications with plan management and feature gating. Contact us to discuss your project.