Role-Based Access Control (RBAC) in Next.js 16 (2026 Beginner's Guide)
Logging a user in is the easy part. The hard part shows up right after — when your "admin" and your "customer" both hit /dashboard and somehow need to see completely different things. Or worse, when a regular user figures out they can just type /admin into the URL bar and… get in, because nothing was actually checking who they were.
That's the gap role-based authorization (RBAC — Role-Based Access Control) fills. It's not about whether someone is logged in anymore — it's about what they're allowed to do once they are. An admin can delete users. An editor can publish posts. A viewer can only look. Same login system, three completely different sets of permissions.
In this guide, you'll build a complete role-based authorization system in Next.js 16 — from storing roles, to protecting routes with proxy.ts, to locking down Server Actions and API routes so a sneaky curl request can't bypass your UI checks. By the end, you'll have a reusable pattern you can drop into any SaaS, dashboard, or internal tool.
Why This Is Tricky
Authentication just answers "who are you?" Authorization answers "what are you allowed to do?" — and that second question has to be checked in every place data or actions are exposed, not just once at the login screen.
The mistake almost everyone makes early on is checking roles only in the UI — hiding an "Edit" button if role !== "admin". That stops confused users, but it does nothing to stop a determined one, since a hidden button doesn't stop someone from calling the underlying Server Action or API route directly. Real role-based auth needs checks in at least three layers:
- Route level — don't even let the page load for the wrong role
- UI level — hide actions the user can't perform (good UX, not security)
- Server level — re-verify inside every Server Action, Route Handler, and database query (the actual security boundary)
We'll build all three.
What We're Building
A small app with three roles:
- admin — full access, can manage users
- editor — can create and edit posts
- viewer — read-only access
You'll end up with:
- A typed
Rolesystem shared across the whole app - Role storage using Auth.js v5's JWT session (works the same pattern with a database session)
- Route protection in
proxy.tsbased on role, not just login state - A reusable
requirePermission()helper for Server Actions and Route Handlers - A
<Can>component for conditionally showing UI based on permissions
┌──────────────┐
│ Role │
│ admin/editor │
│ viewer │
└──────┬───────┘
↓
┌──────────────┐
│ Permissions │
└──────┬───────┘
↓
┌─────────┴─────────┐
↓ ↓
requirePermission() <Can>
↓ ↓
Security boundary UI visibility
Tech Stack
This guide uses:
- Next.js 16 with the App Router
- TypeScript in strict mode
- Tailwind CSS for the small UI examples
- Auth.js v5 (
next-auth@beta) for authentication — the same library covered in Clerk vs NextAuth (Auth.js) in 2026
💡 Tip: Don't have Auth.js set up yet? Read How to Add Auth.js (NextAuth v5) Authentication in Next.js 16 first — this guide picks up right where that one leaves off. If you're using Clerk instead, skip to the Clerk section near the end — the concepts are identical, just the plumbing changes.
Step 1: Define Your Roles in One Place
Before writing any auth code, decide what roles exist and what each one can actually do. Hardcoding role strings ("admin", "editor"...) all over your codebase is exactly how typos slip in and silently break a permission check.
// lib/auth/roles.ts
export const ROLES = ["admin", "editor", "viewer"] as const;
export type Role = (typeof ROLES)[number];
// What each role is allowed to do — the single source of truth
export const PERMISSIONS = {
admin: ["manage_users", "create_post", "edit_post", "delete_post", "view_post"],
editor: ["create_post", "edit_post", "view_post"],
viewer: ["view_post"],
} as const satisfies Record<Role, readonly string[]>;
export type Permission = (typeof PERMISSIONS)[Role][number];
export function hasPermission(role: Role, permission: Permission): boolean {
return (PERMISSIONS[role] as readonly string[]).includes(permission);
}
What's happening here: ROLES is a fixed list — TypeScript's as const makes Role a union type ("admin" | "editor" | "viewer") instead of a loose string, so a typo like "admn" fails to compile instead of silently failing at runtime. PERMISSIONS maps each role to what it's allowed to do, and hasPermission() is the one function every check in this guide will eventually call.
💡 Tip: Notice we're checking permissions (
"create_post"), not roles ("editor"), everywhere except this one file. If you ever add a new role or change what editors can do, you update this file once — nothing else in your app needs to change.
Step 2: Store the Role on the User
Your role has to live somewhere Auth.js can read it during login. In a real app, this is a role column on your users table. For this guide, here's the shape assuming you're using a database adapter (Prisma, Drizzle, or similar):
-- Minimal schema addition
alter table users
add column role text not null default 'viewer'
check (role in ('admin', 'editor', 'viewer'));
New users default to the lowest-privilege role — viewer — which is a good default for any RBAC system. Never default a new signup to admin.
TypeScript protects application code at compile time; the database constraint protects persisted data at runtime.
⚠️ Common Mistake: Letting the client send its own role during signup (
formData.get("role")). Always assign the default role server-side and require an existing admin to promote anyone — otherwise anyone can sign up and just declare themselves an admin.
Step 3: Put the Role on the Session
This is the step most tutorials skip, and it's the one that actually makes RBAC work. By default, Auth.js's session only contains the basics (name, email, image) — you have to explicitly add role to it using two callbacks: jwt and session.
// lib/auth.ts
import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github";
import type { Role } from "./auth/roles";
declare module "next-auth" {
interface User {
id: string;
role: Role;
}
interface Session {
user: {
id: string;
role: Role;
} & DefaultSession["user"];
}
}
declare module "@auth/core/jwt" {
interface JWT {
role: Role;
id: string;
}
}
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [GitHub],
callbacks: {
async jwt({ token, user }) {
// Runs on sign-in, and on every subsequent request
if (user) {
// `user` is only defined right after login — look up the role from your DB here
token.id = user.id!;
token.role = user.role ?? "viewer";
}
return token;
},
async session({ session, token }) {
// Copies data from the JWT token into the session object your app actually reads
session.user.id = token.id;
session.user.role = token.role;
return session;
},
},
});
What's happening here: jwt() can run whenever the JWT session is created or updated, but the user object is available during the initial sign-in flow. That's where you copy the user's role into token.role. On later requests, the role is read from the token rather than fetched again. session() then runs on every auth() call and copies fields from that token onto the session object your pages and Server Actions actually read. The declare module blocks aren't runtime code — they extend Auth.js's built-in TypeScript types so session.user.role autocompletes instead of throwing a type error.
⚠️ Common Mistake: Trying to read the role directly off
session.userwithout adding it in these two callbacks first. Auth.js won't magically know about a customrolecolumn — you have to wire it throughjwt()→session()explicitly, every time.
💡 Tip: If a user's role changes (e.g. an admin promotes them), the JWT won't reflect that until they log in again, since
jwt()only re-readsuseron fresh sign-in. For roles that need to update instantly, either switch to database sessions (strategy: "database") or add atrigger === "update"branch that re-fetches the role on demand.
Step 4: Protect Routes by Role in proxy.ts
In Next.js 16, middleware.ts was renamed to proxy.ts — same file conventions, same idea: it runs before a request reaches a page, making it useful for a fast, optimistic route-level redirect before the page renders.
// proxy.ts
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import type { Role } from "@/lib/auth/roles";
// Map route prefixes to the roles allowed to access them
const ROUTE_ROLES: { prefix: string; roles: Role[] }[] = [
{ prefix: "/admin", roles: ["admin"] },
{ prefix: "/editor", roles: ["admin", "editor"] },
{ prefix: "/dashboard", roles: ["admin", "editor", "viewer"] },
];
export default auth((req) => {
const { nextUrl } = req;
const role = req.auth?.user?.role;
const matched = ROUTE_ROLES.find((r) => nextUrl.pathname === r.prefix || nextUrl.pathname.startsWith(`${r.prefix}/`));
const isAuthPage = nextUrl.pathname === "/login";
const isLoggedIn = !!req.auth;
if (isAuthPage && isLoggedIn) {
// Redirect authenticated users to the dashboard
return NextResponse.redirect(new URL("/dashboard", nextUrl))
}
if (!matched) {
return NextResponse.next(); // route isn't role-protected, let it through
}
if (!isLoggedIn) {
// Not logged in at all — send to login
return NextResponse.redirect(new URL("/login", nextUrl));
}
if (!role || !matched.roles.includes(role)) {
// Logged in, but wrong role — send to a friendly "not allowed" page, not login
return NextResponse.redirect(new URL("/unauthorized", nextUrl));
}
return NextResponse.next();
});
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};
What's happening here: ROUTE_ROLES is a lookup table — no scattered if statements per route, just prefixes mapped to the roles allowed in. The proxy checks the incoming path against that table, and only bothers checking req.auth at all if the route is actually protected. Notice the two different redirects: no session → /login (they need to authenticate), wrong role → /unauthorized (they're authenticated, just not allowed here) — conflating these two cases is a common source of confusing UX.
⚠️ Common Mistake: Forgetting the
matcherconfig, or matching too broadly. Running the proxy on every static asset request adds unnecessary overhead and can cause odd caching behavior — always exclude_next/static,_next/image, and favicon paths.
Step 5: Build a requirePermission() Helper for the Real Security Boundary
proxy.ts stops the page from loading, but it does not protect Server Actions or Route Handlers called directly — those need their own check. This is the layer that actually matters for security, since proxy.ts is a UX nicety, not a guarantee.
// lib/auth/require-role.ts
import { auth } from "@/lib/auth";
import type { Permission } from "./roles";
import { hasPermission } from "./roles";
export class UnauthorizedError extends Error {}
export class ForbiddenError extends Error {}
export async function requirePermission(permission: Permission) {
const session = await auth();
if (!session?.user) {
throw new UnauthorizedError("You must be signed in.");
}
if (!hasPermission(session.user.role, permission)) {
throw new ForbiddenError("You don't have permission to do that.");
}
return session.user; // callers get a typed, guaranteed-authorized user back
}
What's happening here: requirePermission() checks permissions ("delete_post"), not roles directly — this keeps every call site decoupled from which roles can do what, so changing that mapping later only touches roles.ts. Two distinct errors (Unauthorized vs Forbidden) let callers respond differently — a 401 (not logged in) is a different problem than a 403 (logged in, not allowed).
Using It in a Server Action
// actions/posts.ts
"use server";
import { requirePermission, ForbiddenError, UnauthorizedError } from "@/lib/auth/require-role";
import { revalidatePath } from "next/cache";
export async function deletePost(postId: string) {
try {
await requirePermission("delete_post");
} catch (err) {
if (err instanceof UnauthorizedError) return { error: "Please sign in." };
if (err instanceof ForbiddenError) return { error: "You can't delete posts." };
throw err;
}
// await db.post.delete({ where: { id: postId } });
revalidatePath("/dashboard");
return { success: true };
}
⚠️ Common Mistake: Only checking permissions in
proxy.tsand assuming Server Actions are automatically covered. They aren't — a Server Action is a real network endpoint under the hood, callable directly, and needs its ownrequirePermission()call every single time it touches something sensitive.
Using It in a Route Handler
// app/api/users/[id]/route.ts
import { NextResponse } from "next/server";
import { requirePermission, ForbiddenError, UnauthorizedError } from "@/lib/auth/require-role";
export async function DELETE(req: Request, { params }: { params: Promise<{ id: string }> }) {
try {
await requirePermission("manage_users");
} catch (err) {
if (err instanceof UnauthorizedError) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (err instanceof ForbiddenError) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
throw err;
}
const { id } = await params;
// await db.user.delete({ where: { id } });
return NextResponse.json({ success: true });
}
Same pattern, correct HTTP status codes this time — 401 for "log in first," 403 for "you're logged in, but no."
Step 6: Conditionally Show UI with a <Can> Component
Server-side checks are the real security boundary, but users still need a UI that doesn't dangle buttons they can't actually use. A small <Can> component keeps this readable instead of {role === "admin" && ...} scattered through every page.
// components/can.tsx
import type { Permission } from "@/lib/auth/roles";
import { hasPermission } from "@/lib/auth/roles";
import { auth } from "@/lib/auth";
interface CanProps {
permission: Permission;
children: React.ReactNode;
fallback?: React.ReactNode;
}
export async function Can({ permission, children, fallback = null }: CanProps) {
const session = await auth();
if (!session?.user || !hasPermission(session.user.role, permission)) {
return <>{fallback}</>;
}
return <>{children}</>;
}
// app/dashboard/page.tsx
import { Can } from "@/components/can";
import Link from 'next/link';
export default function DashboardPage() {
return (
<div className="space-y-4">
<h1 className="text-2xl font-semibold">Dashboard</h1>
<Can permission="create_post">
<button className="rounded-lg bg-primary px-4 py-2 text-white">
New Post
</button>
</Can>
<Can permission="manage_users" fallback={<p className="text-sm text-muted-foreground">Admins only.</p>}>
<Link href="/admin/users" className="underline">Manage Users</Link>
</Can>
</div>
);
}
What's happening here: Can is an async Server Component — it calls auth() directly, no client-side loading flicker, no useEffect. Wrap anything role-gated in it, optionally supply a fallback for what non-permitted users see instead (or leave it out to render nothing at all).
💡 Tip: Because
Canreads the current session on the server, use it in parts of the tree where request-time authentication data is expected.
💡 Tip: This component controls visibility, not security. Someone could still call the underlying Server Action with dev tools open — that's exactly why Step 5's
requirePermission()exists as the real gate.<Can>is UX,requirePermission()is the lock.
Step 7: Filter Data at the Query Level Too
There's a fourth layer worth calling out separately: what data a query even returns. A viewer shouldn't just be blocked from an edit button — if your query returns draft posts by default, make sure a viewer's query never fetches drafts in the first place, rather than fetching everything and hiding it in the UI.
// lib/data/posts.ts
import { auth } from "@/lib/auth";
import { UnauthorizedError } from "../auth/require-role";
export async function getVisiblePosts() {
const session = await auth();
const role = session?.user?.role;
if (!session?.user) {
throw new UnauthorizedError("You must be signed in.");
}
if (role === "admin" || role === "editor") {
// return await db.post.findMany(); // everything, including drafts
}
// return await db.post.findMany({ where: { status: "published" } }); // viewers only see published
}
This matters because a UI-only check can miss cases like server-rendered pages, RSS feeds, or an API response that isn't wired through the component you added <Can> to.
Frequently Asked Questions
What's the difference between authentication and authorization?
Authentication confirms who someone is (logging in). Authorization decides what they're allowed to do once they're recognized (roles and permissions). You need both — Auth.js/Clerk handle authentication, and everything in this guide is the authorization layer on top.
Should I store roles as a string or a separate roles table?
A single role string column (like this guide uses) is fine for simple apps with a handful of fixed roles. If you need multiple roles per user, per-organization roles, or roles that admins can create dynamically, move to a proper roles and user_roles join table instead — the hasPermission() pattern still works the same way on top of either.
Is checking roles in proxy.ts enough security by itself?
No. Treat proxy.ts as a fast UX redirect, not your security boundary — it can be bypassed by hitting a Server Action or API route directly. Always re-check permissions inside the Server Action or Route Handler itself, as shown in Step 5.
How do I handle a role change without forcing the user to log out?
With JWT sessions, the user value isn't available on every JWT callback invocation, so simply changing the role in your database doesn't automatically replace the role already stored in an existing token. Either switch to strategy: "database" sessions (Auth.js re-reads the DB on every request), or add an update() trigger that refreshes the token when an admin changes someone's role.
Can I have more granular permissions than just three roles?
Yes — that's exactly why Step 1 separates PERMISSIONS from ROLES. Add a new permission string to whichever roles should have it, and every requirePermission() call automatically respects the new mapping without touching the rest of your codebase.
Doing This with Clerk Instead
If you're using Clerk instead of Auth.js, the RBAC architecture stays the same. Only the way you retrieve the user's role changes.
For a simple app, store the role in Clerk's publicMetadata:
{
role: "editor"
}
Set it server-side or through the Clerk Dashboard. Never let users submit their own role during signup.
Expose the metadata through the Clerk session token. In Clerk Dashboard → Sessions → Customize session token, add:
{
"metadata": "{{user.public_metadata}}"
}
Then read the role with auth():
import { auth } from "@clerk/nextjs/server";
import type { Role } from "@/lib/auth/roles";
const { isAuthenticated, sessionClaims } = await auth();
if (!isAuthenticated) {
// Not signed in
}
const role = sessionClaims?.metadata?.role as Role | undefined;
From here, the same hasPermission(), requirePermission(), and <Can> pattern from this guide still applies. Your authorization layer doesn't need to care whether the role came from Auth.js or Clerk.
Role changes may take a short time to appear in an existing session token, so don't assume a metadata update is immediately reflected in sessionClaims.
💡 Tip: If you're building a multi-tenant SaaS with teams or organizations, consider Clerk's built-in Organizations, Roles, and Permissions instead of storing organization roles in
publicMetadata. (Clerk Roles & Permissions)
The important rule remains the same: UI checks improve UX; server-side permission checks provide security.
Wrapping Up
Role-based authorization isn't one big feature — it's four small, boring checks layered together: a protected route in proxy.ts, a re-check inside every Server Action and Route Handler, a <Can> wrapper for the UI, and a query that filters data before it ever leaves the server. Skip any one layer and you've got a gap; stack all four and a "hidden" admin button stops being a real vulnerability.
From here, a natural next step is adding an admin UI for promoting/demoting users, or layering row-level security on top if you're using Supabase alongside this pattern for defense in depth at the database layer too.
📦 Source Code: View on GitHub
Continue Learning
- How to Add Auth.js (NextAuth v5) Authentication in Next.js 16 (2026)
- How to Add Clerk Authentication in Next.js 16 (2026 Guide)
- Clerk vs NextAuth (Auth.js) in 2026: Which One Should You Choose?
- Build a Todo App with Next.js 16 and Supabase (2026 Guide)
- SaaS Starter Architecture with Next.js 2026
Free Developer Tools
- Supabase RLS Policy Generator — pair this guide's application-level RBAC with database-level Row Level Security. Generates owner-only, team, and admin-override policies with the
auth.uid()performance wrapper built in. - Zod Schema Generator — generate a validated schema for your role/permission payloads straight from a JSON sample or TypeScript interface, including a ready-to-use Server Action.
More Guides
View AllHow to Add Auth.js (NextAuth v5) Authentication in Next.js 16 (2026)
Learn how to add Auth.js (NextAuth v5) authentication to a Next.js 16 App Router project step by step — providers, sessions, protected routes, and proxy.ts, explained for beginners.
Clerk vs NextAuth (Auth.js) in 2026: Which One Should You Choose?
Compare Clerk vs Auth.js (NextAuth) v5 in 2026 for Next.js 16. Setup time, pricing, middleware security, customization, and which one fits your project.
How to Add Clerk Authentication in Next.js 16 (2026 Guide)
Learn how to add authentication in Next.js 16 using Clerk with protected routes, server components, server actions, and social login.
Google Search Console Setup for Next.js (2026 Beginner's Guide)
Learn how to set up Google Search Console for a Next.js 16 App Router site — verification, sitemap.ts, robots.ts, and indexing best practices.