# 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.
| Concept | Best for | Example | Changes how often | Stored where |
|---|---|---|---|---|
| Role | Who can do admin-level actions | owner, admin, member, viewer | Low | App DB |
| Plan | Product packaging | free, pro, business | Medium | App DB mirrored from Stripe |
| Entitlement | Fine-grained access to a capability | exportCsv, sso, apiAccess | Medium to high | App DB derived from plan and add-ons |
| Usage limits | Metered constraints | maxProjects, maxSeats | High | App 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#
| Table | Key fields | Purpose |
|---|---|---|
| organizations | id, name | Tenant boundary |
| organization_members | organizationId, userId, role | Role per tenant |
| plans | id, code, name | Local plan catalog mapped to Stripe |
| subscriptions | organizationId, stripeCustomerId, stripeSubscriptionId, status, currentPeriodEnd, cancelAtPeriodEnd, planCode | Billing state snapshot |
| entitlements | organizationId, key, value, source | Feature access per tenant |
| usage_counters | organizationId, key, periodStart, periodEnd, value | Limits like seats, projects, API calls |
You can keep entitlements.value as boolean or number depending on key. Example keys:
feature.auditLogsbooleanfeature.ssobooleanlimit.seatsnumberlimit.projectsnumberfeature.apiAccessboolean
Example schema snippet#
-- 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 type | Checks | Example |
|---|---|---|
| role | member’s role within organization | admin or owner |
| entitlement | tenant capability | feature.auditLogs equals true |
| billing | subscription status and period end | active or trialing |
| condition | resource ownership or tenant match | resource.organizationId equals session.organizationId |
Gate implementation in Next.js App Router#
Keep this server-only. Avoid mixing it into client bundles.
// 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.
// 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
getGateContexton 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#
// 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.
// 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#
// 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
// 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 concept | What to store in DB | Why it matters |
|---|---|---|
| Subscription status | status | Primary allow or block switch |
| Current period end | currentPeriodEnd | Access until end-of-period for cancel-at-period-end |
| Cancel at period end | cancelAtPeriodEnd | Decide upgrade prompts and renewal UX |
| Price or product mapping | planCode | Determine entitlements and limits |
| Trial | status trialing, trial end | Allow trial access predictably |
A common SaaS policy is:
- Allow access if status is
activeortrialing - Optionally allow a short grace period for
past_duelike 3 days - Block access if
canceledandcurrentPeriodEndis 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#
// 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:
// 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:
| Field | Type | Example |
|---|---|---|
| role | string | admin |
| planCode | string | pro |
| features | map of boolean | feature.auditLogs true |
| limits | map of number | limit.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 type | What it catches | Minimum set |
|---|---|---|
Unit tests for canAccess | Broken logic on role and billing status | role rank, billing status mapping |
| Integration tests for policies | Drift between expected and actual | each policy key for core roles |
| Webhook tests | Stripe state sync issues | status 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
Founder & Senior Developer at Samioda. 8+ years building React, Next.js, Flutter and n8n automation solutions for clients across Europe.
More in Web Development
All →Next.js + Stripe Webhooks in the App Router: A Reliability-First Implementation Guide
A production-ready guide to Next.js Stripe webhooks in the App Router — signature verification, raw body handling, idempotency, retries, event ordering, Stripe CLI testing, and troubleshooting.
Supabase RLS + Next.js App Router: End-to-End Authorization Without Footguns
A practical 2026 guide to Supabase RLS with Next.js App Router — multi-tenant policy design, secure server enforcement patterns, and a checklist to avoid service-role leaks, unsafe RPC, and client-side filtering.
React Error Handling in 2026: Error Boundaries, Retries, and UX Patterns That Scale
A practical layered guide to React error handling patterns in 2026: component and route error boundaries, TanStack Query and SWR retries, and UX messaging that scales across teams.
Need help with your project?
We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.
Related Articles
Next.js B2B SaaS Admin Panel Architecture: RBAC, Audit Logs, Impersonation, and Safe Bulk Actions
A practical reference architecture for Next.js admin panels: permission modeling, audit logs, secure impersonation, and safety checklists for bulk actions, exports, and PII handling.
Next.js Authorization in the App Router: RBAC vs ABAC with Middleware, RLS, and Policy Patterns
A practical guide to Next.js authorization in the App Router using RBAC and ABAC — with middleware checks, server component guards, database-enforced RLS, decision matrix, and copy-paste policy patterns.
Next.js SaaS Onboarding Checklist: Accounts, Permissions, Emails, and Trials (App Router, 2026)
A production-ready Next.js SaaS onboarding checklist covering authentication, organizations, invites, RBAC, transactional emails, and trial-to-paid conversion with practical patterns, libraries, and pitfalls.