Building Role-Based Access Control with Next.js
Step-by-step guide to implementing role-based access control in your Next.js application using middleware, session-based auth, and composable protection patterns.


Introduction
Role-based access control (RBAC) is one of the most essential patterns in modern web applications. Instead of manually checking user roles everywhere, you build a reusable system that protects routes, components, and API endpoints based on user permissions. In this guide, we'll build a complete RBAC system using Next.js middleware and NextAuth.js.
The Auth Hook
First, we need a hook that checks the current user's role and exposes their permissions. NextAuth.js makes this straightforward with its session callback.
// lib/auth.ts: NextAuth configuration with role support
import NextAuth, { DefaultSession } from "next-auth"
import { PrismaAdapter } from "@auth/prisma-adapter"
import { prisma } from "./prisma"
declare module "next-auth" {
interface Session {
user: {
id: string
role: "admin" | "editor" | "viewer"
} & DefaultSession["user"]
}
}
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
session: { strategy: "jwt" },
callbacks: {
async jwt({ token, user }) {
if (user) {
token.role = user.role
token.id = user.id
}
return token
},
async session({ session, token }) {
if (session.user) {
session.user.role = token.role as string
session.user.id = token.id as string
}
return session
},
},
providers: [
// Configure your providers here
],
})The Protect Component
Now we build a reusable component that conditionally renders content based on the user's role.
"use client"
import { useSession } from "next-auth/react"
type Role = "admin" | "editor" | "viewer"
interface ProtectProps {
roles: Role[]
children: React.ReactNode
fallback?: React.ReactNode
}
export function Protect({ roles, children, fallback }: ProtectProps) {
const { data: session, status } = useSession()
if (status === "loading") {
return <div className="animate-pulse h-32 bg-white/5 rounded-lg" />
}
if (!session?.user || !roles.includes(session.user.role as Role)) {
return fallback ?? (
<div className="p-8 bg-white/5 rounded-xl text-center">
<p className="text-white/60">
You don't have permission to access this content.
</p>
</div>
)
}
return <>{children}</>
}Middleware Route Protection
For server-side route protection, Next.js middleware is the right tool. It runs before the request reaches your page, so unauthorized users never see protected content.
// middleware.ts
import { withAuth } from "next-auth/middleware"
import { NextResponse } from "next/server"
export default withAuth(
function middleware(req) {
const token = req.nextauth.token
const path = req.nextUrl.pathname
// Admin-only routes
if (path.startsWith("/admin") && token?.role !== "admin") {
return NextResponse.redirect(new URL("/dashboard", req.url))
}
// Editor+ routes
if (
path.startsWith("/dashboard/settings") &&
!["admin", "editor"].includes(token?.role as string)
) {
return NextResponse.redirect(new URL("/dashboard", req.url))
}
return NextResponse.next()
},
{
callbacks: {
authorized: ({ token }) => !!token,
},
}
)
export const config = {
matcher: ["/dashboard/:path*", "/admin/:path*"],
}Usage Example
Here's how you'd use the Protect component in a page. Notice how the admin panel content is only rendered for users with the admin role.
// app/dashboard/page.tsx
import { Protect } from "@/components/Protect"
import { AdminPanel } from "@/components/AdminPanel"
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
<Protect
roles={["admin"]}
fallback={<p>Contact an admin for access</p>}
>
<AdminPanel />
</Protect>
</div>
)
}API Route Protection
Server-side API routes need protection too. Here's how to check roles in API handlers.
// app/api/admin/users/route.ts
import { auth } from "@/lib/auth"
export async function GET() {
const session = await auth()
if (!session) {
return Response.json({ error: "Unauthorized" }, { status: 401 })
}
if (session.user.role !== "admin") {
return Response.json(
{ error: "Insufficient permissions" },
{ status: 403 }
)
}
// Proceed with admin-only logic
const users = await getUsers()
return Response.json(users)
}Conclusion
Role-based access control with Next.js and NextAuth.js is straightforward once you understand the three-layer approach: middleware for route protection, the session object for server-side checks, and the Protect component for client-side rendering. The key is keeping each layer small and composable so you can reuse permission logic across your entire application.
Building something great? Let's talk →