Skip to main content
Back to Blog
Tutorials
4 min read
November 23, 2024

How to Implement Feature-Gated Access in Next.js

Build plan-based feature gating with middleware checks, UI gates, usage limits, and upgrade prompts in Next.js.

Ryel Banfield

Founder & Lead Developer

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.

feature gatesfeature flagsaccess controlNext.jsSaaStutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles