SaaS Plan Limits: Enforcing Feature Gates, Usage Quotas, and Tier-Based Access in Next.js
Enforce SaaS plan limits and feature gates in Next.js. Covers plan limit configuration, usage quota checks in Server Actions, middleware feature gates, upgrade prompts, and PostgreSQL usage counters with atomic increments.
Plan limits are the monetization engine of SaaS. Free tier users hit a limit, see an upgrade prompt, and convert. But limits implemented sloppily create two problems: users bypass them (race conditions on count checks), or limits are too aggressive and frustrate legitimate usage. The implementation needs atomic enforcement at the database level, not just a count query followed by an insert.
Plan Configuration
// lib/plans/config.ts
export type Plan = "free" | "starter" | "growth" | "enterprise";
export interface PlanLimits {
projects: number; // -1 = unlimited
teamMembers: number;
storageGb: number;
apiCallsPerMonth: number;
features: {
customDomain: boolean;
ssoSaml: boolean;
auditLog: boolean;
advancedAnalytics: boolean;
prioritySupport: boolean;
apiAccess: boolean;
};
}
export const PLAN_LIMITS: Record<Plan, PlanLimits> = {
free: {
projects: 3,
teamMembers: 1,
storageGb: 1,
apiCallsPerMonth: 0,
features: {
customDomain: false,
ssoSaml: false,
auditLog: false,
advancedAnalytics: false,
prioritySupport: false,
apiAccess: false,
},
},
starter: {
projects: 10,
teamMembers: 5,
storageGb: 10,
apiCallsPerMonth: 10_000,
features: {
customDomain: true,
ssoSaml: false,
auditLog: false,
advancedAnalytics: false,
prioritySupport: false,
apiAccess: true,
},
},
growth: {
projects: 50,
teamMembers: 25,
storageGb: 100,
apiCallsPerMonth: 100_000,
features: {
customDomain: true,
ssoSaml: false,
auditLog: true,
advancedAnalytics: true,
prioritySupport: true,
apiAccess: true,
},
},
enterprise: {
projects: -1, // Unlimited
teamMembers: -1,
storageGb: -1,
apiCallsPerMonth: -1,
features: {
customDomain: true,
ssoSaml: true,
auditLog: true,
advancedAnalytics: true,
prioritySupport: true,
apiAccess: true,
},
},
};
export function getPlanLimits(plan: Plan): PlanLimits {
return PLAN_LIMITS[plan] ?? PLAN_LIMITS.free;
}
export function isUnlimited(limit: number): boolean {
return limit === -1;
}
Database: Atomic Usage Tracking
-- Monthly usage counters β reset each billing period
CREATE TABLE workspace_usage (
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
period_start DATE NOT NULL, -- First day of billing period
api_calls INTEGER NOT NULL DEFAULT 0,
storage_bytes BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (workspace_id, period_start)
);
-- Atomic API call increment (prevents race conditions)
-- Returns new count so caller can check against limit
CREATE OR REPLACE FUNCTION increment_api_calls(
p_workspace_id UUID,
p_period_start DATE
) RETURNS INTEGER LANGUAGE SQL AS $$
INSERT INTO workspace_usage (workspace_id, period_start, api_calls)
VALUES (p_workspace_id, p_period_start, 1)
ON CONFLICT (workspace_id, period_start)
DO UPDATE SET api_calls = workspace_usage.api_calls + 1
RETURNING api_calls;
$$;
π SaaS MVP in 8 Weeks β Seriously
We have launched 50+ SaaS platforms. Multi-tenant architecture, Stripe billing, auth, role-based access, and cloud deployment β all handled by one senior team.
- Week 1β2: Architecture design + wireframes
- Week 3β6: Core features built + tested
- Week 7β8: Launch-ready on AWS/Vercel with CI/CD
- Post-launch: Maintenance plans from month 3
Limit Enforcement Utility
// lib/plans/enforce.ts
import { prisma } from "@/lib/prisma";
import { getPlanLimits, isUnlimited, type Plan } from "./config";
export class PlanLimitError extends Error {
constructor(
public readonly resource: string,
public readonly limit: number,
public readonly current: number,
public readonly plan: Plan,
) {
super(`${resource} limit reached (${current}/${limit}) on ${plan} plan`);
this.name = "PlanLimitError";
}
}
export interface LimitCheck {
allowed: boolean;
current: number;
limit: number;
pct: number; // 0-100, useful for progress bars
}
// Check project count limit
export async function checkProjectLimit(
workspaceId: string,
plan: Plan,
): Promise<LimitCheck> {
const limits = getPlanLimits(plan);
const limit = limits.projects;
if (isUnlimited(limit)) {
return { allowed: true, current: 0, limit: -1, pct: 0 };
}
const current = await prisma.project.count({ where: { workspaceId } });
return {
allowed: current < limit,
current,
limit,
pct: Math.round((current / limit) * 100),
};
}
// Check team member limit
export async function checkMemberLimit(
workspaceId: string,
plan: Plan,
): Promise<LimitCheck> {
const limits = getPlanLimits(plan);
const limit = limits.teamMembers;
if (isUnlimited(limit)) {
return { allowed: true, current: 0, limit: -1, pct: 0 };
}
const current = await prisma.workspaceMember.count({ where: { workspaceId } });
return { allowed: current < limit, current, limit, pct: Math.round((current / limit) * 100) };
}
// Atomically increment API calls and check limit
export async function consumeApiCall(
workspaceId: string,
plan: Plan,
): Promise<LimitCheck> {
const limits = getPlanLimits(plan);
const limit = limits.apiCallsPerMonth;
if (isUnlimited(limit)) {
return { allowed: true, current: 0, limit: -1, pct: 0 };
}
if (limit === 0) {
return { allowed: false, current: 0, limit: 0, pct: 100 };
}
const periodStart = new Date();
periodStart.setDate(1); // First of current month
const periodStr = periodStart.toISOString().split("T")[0];
// Atomic increment β returns new count
const result = await prisma.$queryRaw<[{ api_calls: number }]>`
SELECT increment_api_calls(${workspaceId}::uuid, ${periodStr}::date) AS api_calls
`;
const current = result[0].api_calls;
return {
allowed: current <= limit,
current,
limit,
pct: Math.min(100, Math.round((current / limit) * 100)),
};
}
// Feature gate check
export function checkFeatureAccess(
feature: keyof PlanLimits["features"],
plan: Plan,
): boolean {
return getPlanLimits(plan).features[feature];
}
Server Actions with Limit Enforcement
// app/actions/projects.ts
"use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { checkProjectLimit, PlanLimitError } from "@/lib/plans/enforce";
import { revalidatePath } from "next/cache";
export async function createProject(
name: string
): Promise<{ success: boolean; error?: string; upgradeRequired?: boolean }> {
const session = await auth();
if (!session?.user) return { success: false, error: "Unauthorized" };
const workspace = await prisma.workspace.findUniqueOrThrow({
where: { id: session.user.workspaceId },
select: { plan: true },
});
// Check limit before creating
const check = await checkProjectLimit(
session.user.workspaceId,
workspace.plan as any
);
if (!check.allowed) {
return {
success: false,
error: `You've reached the ${check.limit}-project limit on your ${workspace.plan} plan.`,
upgradeRequired: true,
};
}
await prisma.project.create({
data: {
workspaceId: session.user.workspaceId,
name,
createdById: session.user.id,
},
});
revalidatePath("/projects");
return { success: true };
}
π‘ The Difference Between a SaaS Demo and a SaaS Business
Anyone can build a demo. We build SaaS products that handle real load, real users, and real payments β with architecture that does not need to be rewritten at 1,000 users.
- Multi-tenant PostgreSQL with row-level security
- Stripe subscriptions, usage billing, annual plans
- SOC2-ready infrastructure from day one
- We own zero equity β you own everything
Middleware: Feature Gate for Routes
// middleware.ts β block entire routes based on plan features
import { NextResponse, type NextRequest } from "next/server";
import { getSession } from "@/lib/session";
import { checkFeatureAccess } from "@/lib/plans/enforce";
import type { Plan } from "@/lib/plans/config";
// Map route prefixes to required features
const FEATURE_ROUTES: Array<{
prefix: string;
feature: keyof import("@/lib/plans/config").PlanLimits["features"];
}> = [
{ prefix: "/settings/sso", feature: "ssoSaml" },
{ prefix: "/settings/audit", feature: "auditLog" },
{ prefix: "/analytics", feature: "advancedAnalytics" },
];
export async function middleware(req: NextRequest) {
const session = await getSession(req);
if (!session) return NextResponse.next();
const plan = session.plan as Plan;
for (const { prefix, feature } of FEATURE_ROUTES) {
if (req.nextUrl.pathname.startsWith(prefix)) {
if (!checkFeatureAccess(feature, plan)) {
return NextResponse.redirect(
new URL(`/upgrade?feature=${feature}&from=${req.nextUrl.pathname}`, req.url)
);
}
}
}
return NextResponse.next();
}
Upgrade Prompt Component
// components/plan/upgrade-prompt.tsx
import Link from "next/link";
import { Lock, Zap } from "lucide-react";
import type { Plan } from "@/lib/plans/config";
interface UpgradePromptProps {
feature: string;
currentPlan: Plan;
description: string;
}
const NEXT_PLAN: Record<Plan, Plan | null> = {
free: "starter",
starter: "growth",
growth: "enterprise",
enterprise: null,
};
export function UpgradePrompt({ feature, currentPlan, description }: UpgradePromptProps) {
const nextPlan = NEXT_PLAN[currentPlan];
return (
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 border border-blue-200 rounded-2xl p-6 text-center">
<div className="inline-flex items-center justify-center w-12 h-12 bg-blue-100 rounded-full mb-4">
<Lock className="w-6 h-6 text-blue-600" />
</div>
<h3 className="font-semibold text-gray-900 mb-2">
Upgrade to unlock {feature}
</h3>
<p className="text-sm text-gray-600 mb-6 max-w-xs mx-auto">{description}</p>
{nextPlan && (
<Link
href={`/billing/upgrade?plan=${nextPlan}`}
className="inline-flex items-center gap-2 px-5 py-2.5 bg-blue-600 text-white text-sm font-semibold rounded-xl hover:bg-blue-700 transition-colors"
>
<Zap className="w-4 h-4" />
Upgrade to {nextPlan.charAt(0).toUpperCase() + nextPlan.slice(1)}
</Link>
)}
</div>
);
}
// Usage limit bar:
// components/plan/usage-bar.tsx
interface UsageBarProps {
label: string;
current: number;
limit: number;
unit?: string;
}
export function UsageBar({ label, current, limit, unit = "" }: UsageBarProps) {
if (limit === -1) {
return (
<div className="flex items-center justify-between text-sm">
<span className="text-gray-700">{label}</span>
<span className="text-gray-500">{current.toLocaleString()}{unit} / Unlimited</span>
</div>
);
}
const pct = Math.min(100, Math.round((current / limit) * 100));
const isHigh = pct >= 80;
const isFull = pct >= 100;
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-700">{label}</span>
<span className={isFull ? "text-red-600 font-semibold" : isHigh ? "text-amber-600" : "text-gray-500"}>
{current.toLocaleString()}{unit} / {limit.toLocaleString()}{unit}
</span>
</div>
<div className="w-full bg-gray-100 rounded-full h-1.5">
<div
className={`h-1.5 rounded-full transition-all ${
isFull ? "bg-red-500" : isHigh ? "bg-amber-500" : "bg-blue-500"
}`}
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
}
Cost and Timeline Estimates
| Scope | Team | Timeline | Cost Range |
|---|---|---|---|
| Plan config + basic limit checks | 1 dev | 1β2 days | $400β800 |
| Atomic DB counters + Server Action enforcement | 1β2 devs | 2β3 days | $800β1,500 |
| Middleware feature gates + upgrade prompt UI | 1 dev | 2 days | $600β1,200 |
See Also
- SaaS Subscription Upgrade and Downgrade
- SaaS Usage-Based Billing
- SaaS Feature Flags Advanced
- Next.js Middleware Auth Patterns
- SaaS Role-Based Access Control
Working With Viprasol
Plan limits enforced only at the UI layer get bypassed. Race conditions on count-then-insert let concurrent requests exceed limits. Our team implements plan limits with atomic PostgreSQL increments (INSERT β¦ ON CONFLICT β¦ RETURNING), Server Action checks that return upgradeRequired: true for clear UI handling, middleware route guards for premium features, and UsageBar components that turn amber at 80% and red at 100%.
What we deliver:
PLAN_LIMITSconfig object: projects/teamMembers/storageGb/apiCalls + feature boolean mapincrement_api_callsSQL function: atomic INSERT ON CONFLICT DO UPDATE RETURNINGcheckProjectLimit/checkMemberLimit: Prisma count + limit comparisonconsumeApiCall: atomic increment via$queryRaw, returns allowed/current/limit/pctcreateProjectServer Action: limit check βupgradeRequired: trueon breach- Middleware: FEATURE_ROUTES array β redirect
/upgrade?feature=β¦on gate fail UpgradePrompt+UsageBarwith color thresholds (blueβamber at 80%βred at 100%)
Talk to our team about your SaaS monetization architecture β
Or explore our SaaS development services.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Building a SaaS Product?
We've helped launch 50+ SaaS platforms. Let's build yours β fast.
Free consultation β’ No commitment β’ Response within 24 hours
Add AI automation to your SaaS product?
Viprasol builds custom AI agent crews that plug into any SaaS workflow β automating repetitive tasks, qualifying leads, and responding across every channel your customers use.