Skip to content
All Insights
Dec 20259 min read

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.

Gabriel Njoabozia
Gabriel NjoaboziaFounder & Lead Engineer
Role-based access control hierarchy diagram illustrating user roles, permissions, and route protection layers

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 →