RBAC in Next.js: Role-Based Access Control That Scales

Updated July 2026 ยท ~6 min read ยท Next.js 15 App Router

Every app starts with if (user.role === "admin") sprinkled through the code. It works โ€” until you add a third role, a new action, or an edge case, and now the checks disagree with each other and a member can hit an endpoint they shouldn't. This guide shows a role-based access control (RBAC) setup for Next.js that stays correct as you grow: one permission matrix, enforced on the server.

Model permissions, not roles

The scaling trick is to check permissions (project:delete), not roles (admin), at the call site โ€” and map roles โ†’ permissions in exactly one place. Add a role or shift a capability, and you edit one table instead of hunting through handlers:

// lib/rbac.ts
export type Role = "OWNER" | "ADMIN" | "MEMBER";

const PERMISSIONS: Record<Role, string[]> = {
  OWNER:  ["project:*", "member:*", "billing:*"],
  ADMIN:  ["project:*", "member:invite", "member:read"],
  MEMBER: ["project:read", "member:read"],
};

export class ForbiddenError extends Error {}

export function can(role: Role, action: string): boolean {
  return (PERMISSIONS[role] ?? []).some(
    p => p === action || (p.endsWith(":*") && action.startsWith(p.slice(0, -1))),
  );
}

export function assertCan(role: Role, action: string): void {
  if (!can(role, action)) throw new ForbiddenError(`${role} cannot ${action}`);
}

Enforce on the server โ€” always

Authorization lives on the server. A route handler or server action must re-check on every call; never rely on the UI having hidden a button.

// app/api/projects/[id]/route.ts
import { requireMembership } from "@/lib/tenant";  // resolves { role, organizationId }
import { assertCan, ForbiddenError } from "@/lib/rbac";

export async function DELETE(_req: Request, { params }: { params: { id: string } }) {
  try {
    const { role, organizationId } = await requireMembership();
    assertCan(role, "project:delete");
    await prisma.project.delete({ where: { id: params.id, organizationId } });
    return Response.json({ ok: true });
  } catch (e) {
    if (e instanceof ForbiddenError) return new Response(e.message, { status: 403 });
    throw e;
  }
}

Server Actions are just as reachable as API routes โ€” put the same assertCan() at the top of every one that mutates data.

โš ๏ธ Hiding a button in the UI is not authorization โ€” it's convenience. Anyone can call the endpoint directly. The server check is the real gate; the UI check just avoids showing dead-ends.

Gate the UI with the same source of truth

Use the identical can() in the UI so what a user sees matches what the server allows:

const { role } = await requireMembership();
return (
  <>
    <ProjectList />
    {can(role, "project:delete") && <DeleteButton />}
  </>
);

Mistakes that create escalation holes

๐Ÿ›  Want RBAC + multi-tenancy already wired? SaaS Starter โ†’
A production Next.js 15 boilerplate with this exact permission matrix, tenant-scoped queries, signature-verified Stripe billing, API keys, and audit logs. Free Lite on GitHub; full version one-time $99.

Takeaways

  1. Check permissions, not roles, at the call site.
  2. Map roles โ†’ permissions in one matrix; edit one place to change access.
  3. Enforce on every route handler AND server action โ€” server is the gate.
  4. Gate the UI with the same can() for UX, never as security.