Back to Blog

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.

Viprasol Tech Team
May 26, 2027
12 min read

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

ScopeTeamTimelineCost Range
Plan config + basic limit checks1 dev1–2 days$400–800
Atomic DB counters + Server Action enforcement1–2 devs2–3 days$800–1,500
Middleware feature gates + upgrade prompt UI1 dev2 days$600–1,200

See Also


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_LIMITS config object: projects/teamMembers/storageGb/apiCalls + feature boolean map
  • increment_api_calls SQL function: atomic INSERT ON CONFLICT DO UPDATE RETURNING
  • checkProjectLimit/checkMemberLimit: Prisma count + limit comparison
  • consumeApiCall: atomic increment via $queryRaw, returns allowed/current/limit/pct
  • createProject Server Action: limit check β†’ upgradeRequired: true on breach
  • Middleware: FEATURE_ROUTES array β†’ redirect /upgrade?feature=… on gate fail
  • UpgradePrompt + UsageBar with color thresholds (blueβ†’amber at 80%β†’red at 100%)

Talk to our team about your SaaS monetization architecture β†’

Or explore our SaaS development services.

Share this article:

About the Author

V

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.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Building a SaaS Product?

We've helped launch 50+ SaaS platforms. Let's build yours β€” fast.

Free consultation β€’ No commitment β€’ Response within 24 hours

Viprasol Β· AI Agent Systems

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.