Introduction: You Have Authentication — Now What?
So you've added Auth.js v5 to your Next.js app, wired up providers, and users can sign in and out. That's great — but here's the question that most tutorials gloss over: what happens when different users need different levels of access?
A customer shouldn't see the admin dashboard. An editor shouldn't be able to delete users. A viewer shouldn't be able to publish posts.
This is where role-based access control (RBAC) comes in — and in the Next.js App Router, it touches every layer of your stack: middleware, server components, server actions, route handlers, and client components.
Most guides on Next.js RBAC either show you a single middleware check and call it a day, or lock you into a specific vendor like Clerk. This guide takes a different approach. You'll build a complete, vendor-agnostic RBAC system using Auth.js v5 that protects your application at every level — from the network edge to individual UI buttons.
What RBAC Actually Means in the App Router
Role-based access control assigns each user a role (like admin, editor, or viewer), and each role grants a set of permissions. Instead of checking individual users, you check their role. This keeps authorization logic centralized, predictable, and honestly much easier to maintain as your application grows.
In the Next.js App Router, RBAC enforcement happens at four distinct layers:
- Middleware — blocks unauthorized requests before they reach your application code
- Server Components — controls what data and UI renders on the server
- Server Actions — validates permissions before executing mutations
- Client Components — conditionally shows or hides UI elements based on the user's role
Skipping any of these layers creates a security gap. Middleware alone can't protect server actions called directly from the client. Client-side hiding is purely cosmetic — a user can still hit the underlying endpoint. You need defense in depth.
Defining Roles and Permissions
Before writing any authorization code, define your roles and permissions in a single file. This centralized approach prevents the scattered if (role === "admin") checks that become unmaintainable as your app grows.
// lib/rbac/roles.ts
export const ROLES = {
ADMIN: "admin",
EDITOR: "editor",
VIEWER: "viewer",
} as const;
export type Role = (typeof ROLES)[keyof typeof ROLES];
export const PERMISSIONS = {
// User management
"users:list": [ROLES.ADMIN],
"users:create": [ROLES.ADMIN],
"users:delete": [ROLES.ADMIN],
"users:update-role": [ROLES.ADMIN],
// Content management
"posts:create": [ROLES.ADMIN, ROLES.EDITOR],
"posts:edit": [ROLES.ADMIN, ROLES.EDITOR],
"posts:delete": [ROLES.ADMIN],
"posts:publish": [ROLES.ADMIN, ROLES.EDITOR],
"posts:read": [ROLES.ADMIN, ROLES.EDITOR, ROLES.VIEWER],
// Settings
"settings:view": [ROLES.ADMIN, ROLES.EDITOR],
"settings:update": [ROLES.ADMIN],
} as const;
export type Permission = keyof typeof PERMISSIONS;
export function hasPermission(
userRole: Role | undefined,
permission: Permission
): boolean {
if (!userRole) return false;
return PERMISSIONS[permission].includes(userRole);
}
export function hasRole(
userRole: Role | undefined,
...roles: Role[]
): boolean {
if (!userRole) return false;
return roles.includes(userRole);
}
This gives you two utilities: hasPermission for granular checks (can this user publish posts?) and hasRole for broader checks (is this user an admin?). Both are fully type-safe and auto-complete in your IDE, which is a nice bonus.
Storing Roles in Your Database
Roles need to live in your database alongside user records. If you're already using Drizzle ORM with your Next.js project, adding a role column is straightforward:
// db/schema.ts
import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name"),
email: text("email").notNull().unique(),
password: text("password"),
role: text("role").notNull().default("viewer"),
emailVerified: timestamp("email_verified", { mode: "date" }),
image: text("image"),
createdAt: timestamp("created_at").defaultNow(),
});
The default role is viewer — this follows the principle of least privilege. New users start with minimal access and an admin explicitly grants elevated roles.
If you're using a different ORM or raw SQL, the concept is the same: add a role column (or a join table for users who can have multiple roles) and default it to your lowest-privilege role.
Wiring Roles into Auth.js v5
Auth.js v5 uses callbacks to control what data appears in the JWT token and the session object. To make the user's role available throughout your application, you need to modify both the jwt and session callbacks.
TypeScript Type Augmentation
First, extend the default Auth.js types so TypeScript knows about the role property:
// types/next-auth.d.ts
import type { Role } from "@/lib/rbac/roles";
import type { DefaultSession } from "next-auth";
declare module "next-auth" {
interface Session {
user: {
id: string;
role: Role;
} & DefaultSession["user"];
}
interface User {
role: Role;
}
}
declare module "next-auth/jwt" {
interface JWT {
role: Role;
}
}
Auth Configuration with Role Callbacks
Here's where things get interesting. Auth.js v5 recommends splitting your configuration into auth.config.ts (edge-compatible, used by middleware) and auth.ts (full config with database adapter). This split is critical for RBAC because middleware runs at the edge, where database adapters typically aren't available.
// auth.config.ts
import type { NextAuthConfig } from "next-auth";
import type { Role } from "@/lib/rbac/roles";
export default {
pages: {
signIn: "/login",
},
callbacks: {
jwt({ token, user }) {
// On initial sign-in, persist the role from the database user
if (user) {
token.role = (user.role as Role) ?? "viewer";
token.id = user.id;
}
return token;
},
session({ session, token }) {
// Expose role and id to the client session
if (session.user) {
session.user.role = token.role;
session.user.id = token.id as string;
}
return session;
},
},
providers: [],
} satisfies NextAuthConfig;
// auth.ts
import NextAuth from "next-auth";
import authConfig from "./auth.config";
import Credentials from "next-auth/providers/credentials";
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import { db } from "@/db";
import { users } from "@/db/schema";
import { eq } from "drizzle-orm";
import bcrypt from "bcryptjs";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: DrizzleAdapter(db),
session: { strategy: "jwt" },
...authConfig,
providers: [
Credentials({
async authorize(credentials) {
const { email, password } = credentials as {
email: string;
password: string;
};
const user = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
if (!user[0] || !user[0].password) return null;
const isValid = await bcrypt.compare(password, user[0].password);
if (!isValid) return null;
// Return user with role — this feeds into the jwt callback
return {
id: user[0].id,
email: user[0].email,
name: user[0].name,
role: user[0].role,
};
},
}),
],
});
The key insight here is the data flow: authorize returns the user with a role → the jwt callback embeds it in the token → the session callback exposes it to components. This chain means the role is available everywhere without an extra database query on every request.
Middleware: Blocking Unauthorized Requests at the Edge
Middleware is your first line of defense. It runs before any server component or route handler, and it operates at the edge — meaning it's fast and doesn't wait for your origin server.
// middleware.ts
import { auth } from "./auth";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import type { Role } from "@/lib/rbac/roles";
// Define which roles can access which route prefixes
const routeRoleMap: Record<string, Role[]> = {
"/admin": ["admin"],
"/dashboard/settings": ["admin"],
"/dashboard/users": ["admin"],
"/dashboard/posts/new": ["admin", "editor"],
"/dashboard/posts/edit": ["admin", "editor"],
"/dashboard": ["admin", "editor", "viewer"],
};
export default auth((req) => {
const { nextUrl } = req;
const session = req.auth;
const isLoggedIn = !!session?.user;
const userRole = session?.user?.role;
// Public routes — no auth required
const publicRoutes = ["/", "/login", "/register", "/api/auth"];
if (publicRoutes.some((route) => nextUrl.pathname.startsWith(route))) {
return NextResponse.next();
}
// Redirect unauthenticated users to login
if (!isLoggedIn) {
const loginUrl = new URL("/login", nextUrl);
loginUrl.searchParams.set("callbackUrl", nextUrl.pathname);
return NextResponse.redirect(loginUrl);
}
// Check role-based access
for (const [route, allowedRoles] of Object.entries(routeRoleMap)) {
if (nextUrl.pathname.startsWith(route)) {
if (!userRole || !allowedRoles.includes(userRole)) {
return NextResponse.redirect(new URL("/unauthorized", nextUrl));
}
break;
}
}
return NextResponse.next();
});
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|public/).*)",
],
};
A few important things to note about this middleware:
- The
routeRoleMapis iterated in order, so more specific routes (like/dashboard/users) must come before broader ones (like/dashboard) - The
auth()wrapper from Auth.js v5 automatically populatesreq.authwith the decoded session from the JWT — no manual token parsing needed - Unauthenticated users get redirected to login with a
callbackUrlso they return to the protected page after signing in - The
matcherconfig excludes static assets and images from middleware processing for performance
Server Component Authorization
Middleware catches broad route-level access, but server components handle fine-grained data and UI authorization. You might want to show different content, load different data, or render entirely different layouts based on the user's role.
Creating a Reusable Authorization Utility
// lib/rbac/authorize.ts
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { hasPermission, hasRole, type Permission, type Role } from "./roles";
export async function requireRole(...roles: Role[]) {
const session = await auth();
if (!session?.user) {
redirect("/login");
}
if (!hasRole(session.user.role, ...roles)) {
redirect("/unauthorized");
}
return session;
}
export async function requirePermission(permission: Permission) {
const session = await auth();
if (!session?.user) {
redirect("/login");
}
if (!hasPermission(session.user.role, permission)) {
redirect("/unauthorized");
}
return session;
}
export async function getAuthorizedSession() {
const session = await auth();
return session;
}
Using Authorization in Server Components
// app/dashboard/users/page.tsx
import { requirePermission } from "@/lib/rbac/authorize";
import { db } from "@/db";
import { users } from "@/db/schema";
export default async function UsersPage() {
// Redirects to /unauthorized if the user lacks "users:list" permission
const session = await requirePermission("users:list");
const allUsers = await db.select().from(users);
return (
<div>
<h1>User Management</h1>
<p>Logged in as {session.user.name} ({session.user.role})</p>
<table>
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Role</th>
</tr>
</thead>
<tbody>
{allUsers.map((user) => (
<tr key={user.id}>
<td>{user.name}</td>
<td>{user.email}</td>
<td>{user.role}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
The requirePermission call at the top acts as a gate. If the user doesn't have the users:list permission, they're redirected before any data is fetched or rendered. This is important — the database query only runs for authorized users, so there's no risk of data leakage.
Conditional Rendering by Role
Sometimes you don't want to redirect — you just want to show different content based on the role:
// app/dashboard/page.tsx
import { auth } from "@/auth";
import { hasPermission } from "@/lib/rbac/roles";
import { AdminStats } from "@/components/admin-stats";
import { EditorQueue } from "@/components/editor-queue";
import { ViewerFeed } from "@/components/viewer-feed";
export default async function DashboardPage() {
const session = await auth();
if (!session?.user) {
redirect("/login");
}
const role = session.user.role;
return (
<div>
<h1>Dashboard</h1>
{hasPermission(role, "users:list") && <AdminStats />}
{hasPermission(role, "posts:edit") && <EditorQueue />}
<ViewerFeed />
</div>
);
}
Since this renders on the server, the hidden components are never sent to the client. There's no way for a user to inspect the DOM and find admin content that was filtered out.
Server Action Authorization
Server actions are arguably the most critical layer to protect. They execute mutations — deleting data, changing roles, publishing content — and they're essentially HTTP endpoints that anyone can call with the right request. Client-side role checks are cosmetic; server-side validation in the action itself is the real security boundary.
// app/actions/user-actions.ts
"use server";
import { auth } from "@/auth";
import { db } from "@/db";
import { users } from "@/db/schema";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { hasPermission, type Role, ROLES } from "@/lib/rbac/roles";
export async function updateUserRole(userId: string, newRole: Role) {
const session = await auth();
// Authentication check
if (!session?.user) {
throw new Error("Unauthorized: not authenticated");
}
// Authorization check
if (!hasPermission(session.user.role, "users:update-role")) {
throw new Error("Forbidden: insufficient permissions");
}
// Prevent self-demotion (an admin accidentally removing their own admin role)
if (session.user.id === userId) {
throw new Error("Cannot change your own role");
}
// Validate the role value
if (!Object.values(ROLES).includes(newRole)) {
throw new Error("Invalid role");
}
await db
.update(users)
.set({ role: newRole })
.where(eq(users.id, userId));
revalidatePath("/dashboard/users");
}
export async function deleteUser(userId: string) {
const session = await auth();
if (!session?.user) {
throw new Error("Unauthorized: not authenticated");
}
if (!hasPermission(session.user.role, "users:delete")) {
throw new Error("Forbidden: insufficient permissions");
}
// Prevent self-deletion
if (session.user.id === userId) {
throw new Error("Cannot delete your own account");
}
await db.delete(users).where(eq(users.id, userId));
revalidatePath("/dashboard/users");
}
Notice the defense-in-depth here: even though middleware already blocks non-admin users from the /dashboard/users route, each server action independently validates permissions. A malicious user who crafts a direct POST request to the server action endpoint will still get blocked.
Route Handler Authorization
If you're building API routes alongside your server actions (for external integrations, webhooks, or mobile clients), those need role protection too:
// app/api/users/route.ts
import { auth } from "@/auth";
import { db } from "@/db";
import { users } from "@/db/schema";
import { hasPermission } from "@/lib/rbac/roles";
import { NextResponse } from "next/server";
export async function GET() {
const session = await auth();
if (!session?.user) {
return NextResponse.json(
{ error: "Authentication required" },
{ status: 401 }
);
}
if (!hasPermission(session.user.role, "users:list")) {
return NextResponse.json(
{ error: "Insufficient permissions" },
{ status: 403 }
);
}
const allUsers = await db.select({
id: users.id,
name: users.name,
email: users.email,
role: users.role,
}).from(users);
return NextResponse.json(allUsers);
}
The key difference from server components: route handlers return HTTP status codes (401 for unauthenticated, 403 for unauthorized) instead of redirecting. This matters for API consumers that expect standard HTTP semantics.
Client Component Authorization
Let's be clear about something: client components can't enforce security. A determined user can always modify the DOM or bypass client-side checks. But conditional rendering based on roles is still worth doing — it improves UX by hiding actions that would fail anyway.
A Reusable Role Gate Component
// components/role-gate.tsx
"use client";
import { useSession } from "next-auth/react";
import { hasPermission, hasRole, type Permission, type Role } from "@/lib/rbac/roles";
interface RoleGateProps {
children: React.ReactNode;
allowedRoles?: Role[];
requiredPermission?: Permission;
fallback?: React.ReactNode;
}
export function RoleGate({
children,
allowedRoles,
requiredPermission,
fallback = null,
}: RoleGateProps) {
const { data: session } = useSession();
const userRole = session?.user?.role;
if (allowedRoles && !hasRole(userRole, ...allowedRoles)) {
return <>{fallback}</>;
}
if (requiredPermission && !hasPermission(userRole, requiredPermission)) {
return <>{fallback}</>;
}
return <>{children}</>;
}
Using the Role Gate
// components/post-actions.tsx
"use client";
import { RoleGate } from "@/components/role-gate";
import { deletePost, publishPost } from "@/app/actions/post-actions";
export function PostActions({ postId }: { postId: string }) {
return (
<div className="flex gap-2">
<RoleGate requiredPermission="posts:publish">
<button
onClick={() => publishPost(postId)}
className="bg-green-600 text-white px-4 py-2 rounded"
>
Publish
</button>
</RoleGate>
<RoleGate allowedRoles={["admin"]}>
<button
onClick={() => deletePost(postId)}
className="bg-red-600 text-white px-4 py-2 rounded"
>
Delete
</button>
</RoleGate>
</div>
);
}
The RoleGate component cleanly separates authorization logic from rendering. It accepts either allowedRoles for broad role checks or requiredPermission for granular permission checks. The optional fallback prop lets you show alternative content instead of nothing.
Building an Admin Role Management UI
With all the layers in place, here's how you can build an admin page that lets administrators change user roles — complete with server-side protection, client-side UX, and a smooth pending state:
// components/user-role-select.tsx
"use client";
import { useTransition } from "react";
import { updateUserRole } from "@/app/actions/user-actions";
import { ROLES, type Role } from "@/lib/rbac/roles";
interface UserRoleSelectProps {
userId: string;
currentRole: Role;
isCurrentUser: boolean;
}
export function UserRoleSelect({
userId,
currentRole,
isCurrentUser,
}: UserRoleSelectProps) {
const [isPending, startTransition] = useTransition();
function handleRoleChange(e: React.ChangeEvent<HTMLSelectElement>) {
const newRole = e.target.value as Role;
startTransition(async () => {
try {
await updateUserRole(userId, newRole);
} catch (error) {
alert(
error instanceof Error
? error.message
: "Failed to update role"
);
}
});
}
return (
<select
value={currentRole}
onChange={handleRoleChange}
disabled={isPending || isCurrentUser}
className="border rounded px-2 py-1"
>
{Object.values(ROLES).map((role) => (
<option key={role} value={role}>
{role.charAt(0).toUpperCase() + role.slice(1)}
</option>
))}
</select>
);
}
The select is disabled for the current user, which prevents admins from accidentally changing their own role. The useTransition hook gives you a nice pending state without blocking the rest of the UI.
The Unauthorized Page
You'll also need a clean unauthorized page for users who try to access routes above their permission level:
// app/unauthorized/page.tsx
import Link from "next/link";
import { auth } from "@/auth";
export default async function UnauthorizedPage() {
const session = await auth();
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center">
<h1 className="text-4xl font-bold text-gray-900">403</h1>
<h2 className="mt-2 text-xl text-gray-600">Access Denied</h2>
<p className="mt-4 text-gray-500">
{session?.user
? "You do not have permission to access this page."
: "Please sign in to continue."}
</p>
<div className="mt-6">
<Link
href={session?.user ? "/dashboard" : "/login"}
className="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
>
{session?.user ? "Go to Dashboard" : "Sign In"}
</Link>
</div>
</div>
</div>
);
}
Advanced: Scaling to Multiple Roles Per User
The single-role approach works for most applications. But if your product requires users to have multiple roles (like a user who's both an editor and a moderator), you'll need a join table:
// db/schema.ts (multi-role version)
export const roles = pgTable("roles", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull().unique(),
});
export const userRoles = pgTable("user_roles", {
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
roleId: uuid("role_id")
.notNull()
.references(() => roles.id, { onDelete: "cascade" }),
}, (table) => ({
pk: primaryKey({ columns: [table.userId, table.roleId] }),
}));
With this schema, update your authorize function to query the userRoles table and return an array of role names. The jwt callback stores the array in the token, and your hasPermission function checks if any of the user's roles grants the required permission.
RBAC Best Practices Checklist
I've seen RBAC implementations go wrong in all sorts of ways. Here's what I'd recommend keeping in mind:
- Default to deny — if no rule explicitly grants access, block the request. Don't default to allowing access and selectively blocking.
- Validate at every layer — middleware for routes, server components for data, server actions for mutations. Never rely on a single layer.
- Never trust the client — client-side role checks are UX improvements, not security measures. Always validate on the server.
- Use the principle of least privilege — new users get the lowest-level role. Elevated access must be explicitly granted by an administrator.
- Centralize role definitions — a single
roles.tsfile is your source of truth. No scattered role strings across the codebase. - Log access denials — track when users attempt to access resources above their permission level. This helps detect compromised accounts and misconfigured roles.
- Separate authentication from authorization — proving who you are (auth) is distinct from proving what you can do (RBAC). Keep these concerns in separate modules.
- Test your authorization — write tests that verify each role can only access what it should. An untested RBAC system is honestly no better than having no RBAC at all.
Common Mistakes to Avoid
After working with RBAC in quite a few Next.js applications, these are the mistakes I see trip up developers most often:
- Checking roles only in middleware — middleware protects routes, but server actions can be called directly. Always add permission checks inside your server actions too.
- Hardcoding role strings everywhere —
if (session.user.role === "admin")scattered across 50 files becomes a maintenance nightmare. Use centralized constants and utility functions instead. - Forgetting to split auth config for Edge compatibility — if you put your database adapter in
auth.config.ts, middleware will crash because database adapters don't run at the edge. Keep the adapter inauth.tsonly. - Not handling the role in the JWT callback — skip the
jwtcallback and the role won't be available in the session. All your RBAC checks will fail silently, which is a frustrating bug to track down. - Over-engineering with external services too early — tools like Permify, Permit.io, and Cerbos are powerful, but they add complexity. Start with the simple approach shown here and migrate to an external service only when your permission model outgrows a flat role-permission map.
Frequently Asked Questions
How do I add role-based access control to Next.js with Auth.js?
Store the user's role in your database, pass it through the Auth.js v5 jwt and session callbacks, and then check the role in your middleware, server components, and server actions using utility functions like hasRole and hasPermission. This guide walks through the complete implementation across all four layers of the App Router.
What's the difference between middleware RBAC and server component RBAC?
Middleware runs at the edge before your application code and is best for broad route-level protection (blocking all non-admins from /admin/*). Server components run on your origin server and handle fine-grained data and UI authorization (showing different content to different roles on the same page). You really need both for a secure application.
Can I use RBAC without Auth.js in Next.js?
Absolutely. The RBAC pattern — centralized roles, permission utilities, multi-layer checks — works with any authentication provider. Clerk, Supabase Auth, custom JWT implementations, whatever you're using. The principles are the same: embed the role in the session token and check it at every layer.
How do I handle multiple roles per user in Next.js?
Use a join table in your database (a user_roles table linking users to roles) instead of a single role column. Store the array of roles in the JWT token via the jwt callback, and update your hasPermission function to check if any of the user's roles grants the required permission.
Should I use middleware or server actions for RBAC in Next.js?
Use both. Middleware provides a fast, edge-level gate that blocks unauthorized route access before any server code runs. Server actions provide mutation-level protection that can't be bypassed by direct HTTP requests. Relying on only one layer leaves security gaps — defense in depth is the way to go.