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.
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}`);
}
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.
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 />}
</>
);
role === "admin" everywhere drifts out of sync. Centralize in the matrix.can() for UX, never as security.