Web Development
Next.js feature gatingNext.js App RouterauthorizationRBACStripe subscriptions

Feature Gating in Next.js (App Router): Permissions, Entitlements, and Billing-Aware Access

AO
Adrijan Omićević
·16 min read

# Introduction#

Most Next.js apps start with simple role checks like admin versus user. As soon as you add pricing tiers, add-ons, usage limits, and Stripe subscription lifecycle events, those checks fragment across Server Components, route handlers, server actions, and middleware.

That fragmentation is expensive. It causes subtle security gaps, inconsistent UX, and support tickets like “I upgraded but still can’t access the feature” or “Why can I still call the endpoint after downgrading”.

This guide covers practical Next.js feature gating patterns for the App Router: how to gate UI and server-side operations by role, plan, entitlements, and billing-aware access using Stripe subscription state. You’ll get a concrete data model, reusable gate helpers, and consistent enforcement across Server Components, route handlers, and middleware.

For deeper patterns on RBAC and ABAC in App Router, see Next.js App Router authz patterns: RBAC and ABAC. For B2B admin panels with audit logs and multi-tenant access rules, see B2B SaaS admin panel architecture. For Stripe billing sync details, see Next.js Stripe subscriptions for SaaS billing.

# The 4 layers of feature gating that actually matter#

You’ll usually need all four. If you skip any layer, you’ll either leak access or ship a clunky UX.

1) UI gating for UX#

UI gating hides or disables controls, routes, and pages. It reduces confusion and increases conversions by showing upgrade prompts in-context.

UI gating is not security. Treat it as a UX optimization only.

2) Server gating for security#

Every privileged operation must be enforced on the server, regardless of whether you hide buttons. That includes:

  • Route handlers in app/api/*
  • Server actions
  • Server-only functions invoked from Server Components
  • Background jobs and webhooks

3) Data gating for tenant isolation#

In B2B, “can do X” is not enough. It must be “can do X on resource Y inside tenant Z”.

That is where ABAC-style conditions matter, like resource.organizationId === session.organizationId.

4) Billing-aware gating for revenue integrity#

Billing-aware gating uses Stripe subscription state, plan, add-ons, and “grace periods” to decide access. This is where many apps break, because Stripe has real-world lifecycle complexity:

  • Trialing, active, past_due, canceled
  • Cancellation at period end
  • Payment failures and recovery flows

If you gate incorrectly, you either lose revenue by giving access for free or lose trust by blocking paying customers.

🎯 Key Takeaway: Treat feature gating as a system. UI gating improves UX, but server gating and billing-aware gating prevent security and revenue leaks.

# Roles, plans, and entitlements: what to use when#

These concepts overlap. The easiest way to stay sane is to map each to a different responsibility.

ConceptBest forExampleChanges how oftenStored where
RoleWho can do admin-level actionsowner, admin, member, viewerLowApp DB
PlanProduct packagingfree, pro, businessMediumApp DB mirrored from Stripe
EntitlementFine-grained access to a capabilityexportCsv, sso, apiAccessMedium to highApp DB derived from plan and add-ons
Usage limitsMetered constraintsmaxProjects, maxSeatsHighApp DB counters, analytics, or billing meter

A practical rule: use roles for admin actions, plans for pricing tier selection, and entitlements as the single source of truth for “can this tenant use feature X”.

Why entitlements matter: they let you ship new features without creating more plans. You can roll out a feature behind a flag like feature.aiSummaries for “business plan plus AI add-on”, and your app code stays stable.

# A concrete data model for Next.js feature gating#

The model below supports B2B tenants, RBAC, feature entitlements, and Stripe subscription state. It is intentionally normalized to make checks predictable.

Tables and fields#

TableKey fieldsPurpose
organizationsid, nameTenant boundary
organization_membersorganizationId, userId, roleRole per tenant
plansid, code, nameLocal plan catalog mapped to Stripe
subscriptionsorganizationId, stripeCustomerId, stripeSubscriptionId, status, currentPeriodEnd, cancelAtPeriodEnd, planCodeBilling state snapshot
entitlementsorganizationId, key, value, sourceFeature access per tenant
usage_countersorganizationId, key, periodStart, periodEnd, valueLimits like seats, projects, API calls

You can keep entitlements.value as boolean or number depending on key. Example keys:

  • feature.auditLogs boolean
  • feature.sso boolean
  • limit.seats number
  • limit.projects number
  • feature.apiAccess boolean

Example schema snippet#

SQL
-- entitlements: one row per org and entitlement key
-- value stored as text for flexibility, parsed in app layer
create table entitlements (
  organization_id uuid not null,
  key text not null,
  value text not null,
  source text not null, -- "plan", "addon", "manual"
  updated_at timestamptz not null default now(),
  primary key (organization_id, key)
);
 
create table subscriptions (
  organization_id uuid primary key,
  stripe_customer_id text,
  stripe_subscription_id text,
  status text not null, -- "trialing" | "active" | "past_due" | "canceled" etc
  plan_code text not null, -- "free" | "pro" | "business"
  current_period_end timestamptz,
  cancel_at_period_end boolean not null default false,
  updated_at timestamptz not null default now()
);

ℹ️ Note: Stripe is not your authorization database. Stripe is your billing system. For reliable gating, mirror Stripe state into your own DB via webhooks and gate off your DB snapshot, not live Stripe API calls.

# Centralize gating: one module, used everywhere#

Consistency is the hardest part. The solution is boring: create one gate function that evaluates access, then reuse it in:

  • Server Components
  • Route handlers
  • Server actions
  • Middleware for coarse routing decisions

A gate vocabulary: permissions, entitlements, and conditions#

Define “requirements” in a structured way, so features have a single policy definition.

Requirement typeChecksExample
rolemember’s role within organizationadmin or owner
entitlementtenant capabilityfeature.auditLogs equals true
billingsubscription status and period endactive or trialing
conditionresource ownership or tenant matchresource.organizationId equals session.organizationId

Gate implementation in Next.js App Router#

Keep this server-only. Avoid mixing it into client bundles.

TypeScript
// lib/authz/gate.ts
export type Role = "owner" | "admin" | "member" | "viewer";
 
export type SubscriptionStatus =
  | "incomplete"
  | "trialing"
  | "active"
  | "past_due"
  | "canceled"
  | "unpaid";
 
export type GateContext = {
  userId: string;
  organizationId: string;
  role: Role;
  subscription: {
    status: SubscriptionStatus;
    currentPeriodEnd: string | null;
    cancelAtPeriodEnd: boolean;
    planCode: string;
  };
  entitlements: Record<string, string>;
};
 
export type GateResult =
  | { ok: true }
  | { ok: false; reason: "unauthenticated" | "forbidden" | "billing" | "not_found" };
 
const ROLE_RANK: Record<Role, number> = {
  owner: 4,
  admin: 3,
  member: 2,
  viewer: 1,
};
 
function entitlementBool(entitlements: Record<string, string>, key: string) {
  return entitlements[key] === "true";
}
 
export function canAccess(
  ctx: GateContext | null,
  req: {
    minRole?: Role;
    entitlement?: string;
    requireActiveSubscription?: boolean;
  }
): GateResult {
  if (!ctx) return { ok: false, reason: "unauthenticated" };
 
  if (req.minRole) {
    const ok = ROLE_RANK[ctx.role] >= ROLE_RANK[req.minRole];
    if (!ok) return { ok: false, reason: "forbidden" };
  }
 
  if (req.entitlement) {
    if (!entitlementBool(ctx.entitlements, req.entitlement)) {
      return { ok: false, reason: "forbidden" };
    }
  }
 
  if (req.requireActiveSubscription) {
    const ok = ctx.subscription.status === "active" || ctx.subscription.status === "trialing";
    if (!ok) return { ok: false, reason: "billing" };
  }
 
  return { ok: true };
}

This looks simple, and that’s the point. Complexity belongs in “how you build the context”, not scattered across features.

# Building the gate context: session, membership, billing, entitlements#

Your gate context should be loaded once per request and reused. In App Router, you typically build it in server code and pass a minimal version to the client.

Context loader pattern#

  • Fetch session and organization selection
  • Fetch membership role for that organization
  • Fetch subscription snapshot
  • Fetch entitlements key-value map

Use caching so you don’t hit the DB repeatedly from multiple Server Components.

TypeScript
// lib/authz/context.ts
import { cache } from "react";
 
export const getGateContext = cache(async function getGateContext() {
  // Replace these with your auth and DB calls
  const session = await getSession();
  if (!session) return null;
 
  const organizationId = await getActiveOrganizationId(session.user.id);
  if (!organizationId) return null;
 
  const membership = await db.organizationMember.findUnique({
    where: { organizationId_userId: { organizationId, userId: session.user.id } },
    select: { role: true },
  });
 
  if (!membership) return null;
 
  const subscription = await db.subscription.findUnique({
    where: { organizationId },
    select: {
      status: true,
      currentPeriodEnd: true,
      cancelAtPeriodEnd: true,
      planCode: true,
    },
  });
 
  const entRows = await db.entitlement.findMany({
    where: { organizationId },
    select: { key: true, value: true },
  });
 
  const entitlements = Object.fromEntries(entRows.map((r) => [r.key, r.value]));
 
  return {
    userId: session.user.id,
    organizationId,
    role: membership.role,
    subscription: subscription ?? {
      status: "canceled",
      currentPeriodEnd: null,
      cancelAtPeriodEnd: false,
      planCode: "free",
    },
    entitlements,
  };
});
 
// Placeholder signatures to keep the example focused
async function getSession() {
  return null as any;
}
async function getActiveOrganizationId(userId: string) {
  return null as any;
}
const db = null as any;

⚠️ Warning: Avoid calling Stripe inside getGateContext on every request. Even a 200 millisecond Stripe call quickly becomes expensive at scale. Sync via webhooks and read from your DB.

# Pattern 1: Gating Server Components and pages#

In App Router, Server Components are the default. That’s great for gating because you can decide what to render based on server-enforced checks.

Example: gate an admin page by role and entitlement#

TypeScript
// app/(app)/settings/audit-logs/page.tsx
import { getGateContext } from "@/lib/authz/context";
import { canAccess } from "@/lib/authz/gate";
import { redirect } from "next/navigation";
 
export default async function AuditLogsPage() {
  const ctx = await getGateContext();
  const gate = canAccess(ctx, {
    minRole: "admin",
    entitlement: "feature.auditLogs",
    requireActiveSubscription: true,
  });
 
  if (!gate.ok) {
    if (gate.reason === "unauthenticated") redirect("/login");
    if (gate.reason === "billing") redirect("/billing/upgrade?feature=auditLogs");
    redirect("/403");
  }
 
  const logs = await loadAuditLogs(ctx.organizationId);
  return <pre>{JSON.stringify(logs, null, 2)}</pre>;
}
 
async function loadAuditLogs(organizationId: string) {
  return [{ id: "evt_1", action: "USER_INVITED" }];
}

This is the minimum acceptable security posture: if the user can’t access, you never fetch the protected data.

# Pattern 2: Gating server actions (the most commonly missed)#

Server actions are easy to accidentally expose because they feel “component-scoped”. They are not. Treat them like an API endpoint.

Example: gate an “invite user” action by role and seat limit#

This example shows a common combination: role gate plus usage limit gate.

TypeScript
// app/(app)/team/actions.ts
"use server";
 
import { getGateContext } from "@/lib/authz/context";
import { canAccess } from "@/lib/authz/gate";
 
export async function inviteMemberAction(email: string) {
  const ctx = await getGateContext();
  const gate = canAccess(ctx, { minRole: "admin", requireActiveSubscription: true });
  if (!gate.ok) throw new Error(`Access denied: ${gate.reason}`);
 
  const seatLimit = Number(ctx.entitlements["limit.seats"] ?? "1");
  const seatsUsed = await countActiveSeats(ctx.organizationId);
  if (seatsUsed >= seatLimit) {
    throw new Error("Seat limit reached. Upgrade to add more members.");
  }
 
  await createInvite(ctx.organizationId, email);
}
 
async function countActiveSeats(organizationId: string) {
  return 3;
}
async function createInvite(organizationId: string, email: string) {
  return;
}

The UX can still show a disabled button client-side, but the server action must enforce both gates.

# Pattern 3: Gating route handlers and API endpoints#

Route handlers are the most direct attack surface. Always gate at the start, before touching data.

Example: gate an export endpoint by entitlement#

TypeScript
// app/api/export/route.ts
import { NextResponse } from "next/server";
import { getGateContext } from "@/lib/authz/context";
import { canAccess } from "@/lib/authz/gate";
 
export async function POST() {
  const ctx = await getGateContext();
  const gate = canAccess(ctx, {
    entitlement: "feature.exportCsv",
    requireActiveSubscription: true,
  });
 
  if (!gate.ok) {
    return NextResponse.json({ error: gate.reason }, { status: gate.reason === "unauthenticated" ? 401 : 403 });
  }
 
  const csv = await generateCsv(ctx.organizationId);
  return new NextResponse(csv, {
    headers: { "content-type": "text/csv" },
  });
}
 
async function generateCsv(organizationId: string) {
  return "id,name\n1,Example\n";
}

# Pattern 4: Middleware for coarse gating and tenant routing#

Middleware is useful for:

  • Redirecting unauthenticated users away from app routes
  • Enforcing that a tenant is selected
  • Blocking obviously restricted areas early

Middleware is not ideal for fine-grained entitlement checks because it runs on the Edge runtime and you should avoid DB calls there. Use it for “fast fail” decisions that can be made from cookies or JWT claims.

A practical middleware approach#

  • Put role and organizationId into a signed session token
  • Use middleware to block /app/admin/* if role is less than admin
  • Do detailed entitlement and billing checks in Server Components and route handlers
TypeScript
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
 
export function middleware(req: NextRequest) {
  const path = req.nextUrl.pathname;
 
  if (path.startsWith("/app")) {
    const token = req.cookies.get("session")?.value;
    if (!token) return NextResponse.redirect(new URL("/login", req.url));
 
    // Keep this lightweight: decode and check basic claims only
    const claims = decodeSessionToken(token);
    if (!claims.organizationId) return NextResponse.redirect(new URL("/select-org", req.url));
 
    if (path.startsWith("/app/admin") && claims.role !== "owner" && claims.role !== "admin") {
      return NextResponse.redirect(new URL("/403", req.url));
    }
  }
 
  return NextResponse.next();
}
 
function decodeSessionToken(token: string) {
  return { organizationId: "org_123", role: "member" };
}
 
export const config = {
  matcher: ["/app/:path*"],
};

If you need Edge-based billing checks, consider embedding a minimal “billingAccess” claim in the session token that you refresh on key events. For most SaaS apps, that is unnecessary if your server-side gates are correct.

# Billing-aware access with Stripe: state you actually need#

Stripe’s subscription object is rich. For gating, you typically need a simplified snapshot.

Stripe fields worth mirroring#

Stripe conceptWhat to store in DBWhy it matters
Subscription statusstatusPrimary allow or block switch
Current period endcurrentPeriodEndAccess until end-of-period for cancel-at-period-end
Cancel at period endcancelAtPeriodEndDecide upgrade prompts and renewal UX
Price or product mappingplanCodeDetermine entitlements and limits
Trialstatus trialing, trial endAllow trial access predictably

A common SaaS policy is:

  • Allow access if status is active or trialing
  • Optionally allow a short grace period for past_due like 3 days
  • Block access if canceled and currentPeriodEnd is in the past

If you implement grace periods, make it explicit and measurable. For example, “3 days after invoice becomes past due”. Store the timestamp you need to evaluate it.

Mapping plans to entitlements#

You can compute entitlements on-the-fly from planCode, but many teams prefer storing entitlements for debuggability and “manual overrides”.

A practical hybrid:

  • Maintain a plan-to-entitlements map in code
  • On webhook events, upsert entitlements in DB for the organization
  • Use entitlements table for all runtime checks

This also makes support easier: you can inspect entitlements rows to see exactly what the tenant has.

# Keeping gates consistent: a single policy definition per feature#

If you define rules inline in pages and endpoints, they will diverge. Instead, define feature policies centrally and reference them everywhere.

Policy registry pattern#

TypeScript
// lib/authz/policies.ts
export const policies = {
  auditLogs: {
    minRole: "admin",
    entitlement: "feature.auditLogs",
    requireActiveSubscription: true,
  },
  exportCsv: {
    minRole: "member",
    entitlement: "feature.exportCsv",
    requireActiveSubscription: true,
  },
} as const;
 
export type PolicyKey = keyof typeof policies;

Usage stays consistent:

TypeScript
// lib/authz/enforce.ts
import { getGateContext } from "@/lib/authz/context";
import { canAccess } from "@/lib/authz/gate";
import { policies, type PolicyKey } from "@/lib/authz/policies";
 
export async function enforce(policyKey: PolicyKey) {
  const ctx = await getGateContext();
  const gate = canAccess(ctx, policies[policyKey]);
  return { ctx, gate };
}

Now your pages, actions, and APIs use the same policyKey string instead of duplicating requirements.

💡 Tip: Log gate failures with policyKey, organizationId, and reason. In practice, this cuts “I can’t access X” debugging time from hours to minutes because you can correlate logs to billing changes and entitlement rows.

# Practical UI gating that matches server rules#

Client gating should reuse the same semantics, but it cannot rely on server-only context. The safe approach is:

  • Enforce on server always
  • Provide a minimal “capabilities” object to the client, derived on the server

Capability payload example#

A payload that is easy to consume:

FieldTypeExample
rolestringadmin
planCodestringpro
featuresmap of booleanfeature.auditLogs true
limitsmap of numberlimit.seats 10

Then your client components can do things like “show upsell banner” or “disable button”.

Keep it minimal and avoid putting sensitive data in it. Feature flags and role are usually fine.

# Testing and observability: treat gates like payments code#

Feature gating touches revenue and security. It deserves real tests and logging.

What to test#

Test typeWhat it catchesMinimum set
Unit tests for canAccessBroken logic on role and billing statusrole rank, billing status mapping
Integration tests for policiesDrift between expected and actualeach policy key for core roles
Webhook testsStripe state sync issuesstatus transitions, cancel at period end

If you track access denials, you’ll also see product signals. A spike in billing denials after a pricing change is a real alert.

# Key Takeaways#

  • Centralize Next.js feature gating in one gate module and one policy registry, then reuse it in Server Components, route handlers, server actions, and middleware.
  • Mirror Stripe subscription state into your database via webhooks and gate off your DB snapshot, not live Stripe API calls per request.
  • Use roles for admin authority, plans for packaging, and entitlements as the single “can use feature X” source of truth.
  • Enforce gates server-side first, then mirror them client-side via a minimal capabilities payload for better UX.
  • Add observability: log denials by policy key and reason, and write tests for billing status transitions and policy coverage.

# Conclusion#

Feature gating is where authorization, product packaging, and billing meet, and small inconsistencies become security gaps or revenue leaks. If you centralize policies, normalize Stripe state into your DB, and enforce gates consistently across App Router primitives, you get predictable access control and cleaner code.

If you want help implementing billing-aware authorization in a B2B Next.js SaaS, Samioda can review your data model, Stripe webhook flow, and gating layer end-to-end. Start with our Next.js authorization patterns guide or reach out via our web development services.

FAQ

Share
A
Adrijan OmićevićFounder & Senior Developer

Founder & Senior Developer at Samioda. 8+ years building React, Next.js, Flutter and n8n automation solutions for clients across Europe.

Need help with your project?

We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.