DEV Community

huangyongshan46-a11y
huangyongshan46-a11y

Posted on

How to Add Role-Based Access Control to Next.js 16 with Auth.js v5

Most SaaS apps start simple: one user type, one set of features. Then a customer asks for a team plan, an admin dashboard, or a read-only guest mode — and suddenly you need roles.

Role-Based Access Control (RBAC) is how you handle this cleanly. In this guide we'll add it to a Next.js 16 app using Auth.js v5, Prisma, and TypeScript — with real code you can drop in today.

What We're Building

Three roles: USER, ADMIN, VIEWER. We'll:

  1. Store roles in the database
  2. Inject them into the Auth.js session
  3. Protect pages via middleware
  4. Protect API routes with a helper
  5. Conditionally render UI based on role

Step 1: Add the Role to Your Prisma Schema

// prisma/schema.prisma

enum UserRole {
  USER
  ADMIN
  VIEWER
}

model User {
  id            String    @id @default(cuid())
  email         String    @unique
  name          String?
  role          UserRole  @default(USER)
  emailVerified DateTime?
  image         String?
  accounts      Account[]
  sessions      Session[]
  createdAt     DateTime  @default(now())
  updatedAt     DateTime  @updatedAt
}
Enter fullscreen mode Exit fullscreen mode

Run the migration:

npx prisma migrate dev --name add-user-role
Enter fullscreen mode Exit fullscreen mode

Step 2: Extend the Auth.js Session

By default Auth.js doesn't include custom fields in the session. We need to tell it to.

// auth.ts
import NextAuth from "next-auth"
import { PrismaAdapter } from "@auth/prisma-adapter"
import { prisma } from "@/lib/prisma"
import type { UserRole } from "@prisma/client"

export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: PrismaAdapter(prisma),
  providers: [/* your providers */],
  callbacks: {
    async session({ session, user }) {
      // Attach role to the session token
      session.user.role = user.role as UserRole
      return session
    },
  },
})
Enter fullscreen mode Exit fullscreen mode

Then extend the TypeScript types so your editor knows about it:

// types/next-auth.d.ts
import type { UserRole } from "@prisma/client"

declare module "next-auth" {
  interface User {
    role: UserRole
  }
  interface Session {
    user: {
      id: string
      role: UserRole
    } & DefaultSession["user"]
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Protect Pages with Middleware

Next.js middleware runs at the edge before a page renders — perfect for role gating.

// middleware.ts
import { auth } from "@/auth"
import { NextResponse } from "next/server"

export default auth((req) => {
  const { pathname } = req.nextUrl
  const session = req.auth

  // Unauthenticated: redirect to login
  if (!session && pathname.startsWith("/dashboard")) {
    return NextResponse.redirect(new URL("/login", req.url))
  }

  // Admin-only routes
  if (pathname.startsWith("/dashboard/admin")) {
    if (session?.user.role !== "ADMIN") {
      return NextResponse.redirect(new URL("/dashboard", req.url))
    }
  }

  return NextResponse.next()
})

export const config = {
  matcher: ["/dashboard/:path*"],
}
Enter fullscreen mode Exit fullscreen mode

This is a hard gate — non-admins can't even load the page.

Step 4: Protect API Routes

For API endpoints, use a helper that reads the session and checks role:

// lib/auth-guard.ts
import { auth } from "@/auth"
import type { UserRole } from "@prisma/client"

export async function requireRole(
  requiredRole: UserRole | UserRole[]
): Promise<void> {
  const session = await auth()

  if (!session?.user) {
    throw new Response("Unauthorized", { status: 401 })
  }

  const allowed = Array.isArray(requiredRole)
    ? requiredRole.includes(session.user.role)
    : session.user.role === requiredRole

  if (!allowed) {
    throw new Response("Forbidden", { status: 403 })
  }
}
Enter fullscreen mode Exit fullscreen mode

Use it in a route handler:

// app/api/admin/users/route.ts
import { NextResponse } from "next/server"
import { requireRole } from "@/lib/auth-guard"
import { prisma } from "@/lib/prisma"

export async function GET() {
  try {
    await requireRole("ADMIN")
  } catch (res) {
    return res
  }

  const users = await prisma.user.findMany({
    select: { id: true, email: true, name: true, role: true },
  })

  return NextResponse.json(users)
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Role-Conditional UI

In Server Components, read the session directly:

// app/dashboard/layout.tsx
import { auth } from "@/auth"

export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const session = await auth()
  const isAdmin = session?.user.role === "ADMIN"

  return (
    <div className="flex">
      <nav>
        <a href="/dashboard">Home</a>
        {isAdmin && <a href="/dashboard/admin">Admin Panel</a>}
      </nav>
      <main>{children}</main>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

In Client Components, use the useSession hook:

"use client"
import { useSession } from "next-auth/react"

export function AdminButton() {
  const { data: session } = useSession()

  if (session?.user.role !== "ADMIN") return null

  return <button>Admin Action</button>
}
Enter fullscreen mode Exit fullscreen mode

Note: Client-side role checks are for UX only. Always enforce roles server-side in middleware or API routes — never trust the client.

Bonus: Promote a User to Admin

A quick Prisma script to promote yourself during development:

// scripts/promote-admin.ts
import { prisma } from "@/lib/prisma"

async function main() {
  await prisma.user.update({
    where: { email: "you@example.com" },
    data: { role: "ADMIN" },
  })
  console.log("Done.")
}

main()
  .catch(console.error)
  .finally(() => prisma.$disconnect())
Enter fullscreen mode Exit fullscreen mode

Don't Want to Wire All This Up Yourself?

RBAC, Auth.js v5, Prisma schema, Stripe subscriptions, and an AI chat layer — it's a lot of boilerplate to assemble from scratch.

LaunchKit ships all of this pre-configured as a Next.js 16 starter kit:

  • ✅ Auth.js v5 with role support baked in
  • ✅ Prisma schema with Users, Subscriptions, AI Chat
  • ✅ Admin dashboard with role-gated routes
  • ✅ Stripe billing + webhook handler
  • ✅ Dark mode, Tailwind CSS v4, TypeScript strict

$49 one-time. No subscription. Yours forever.

Get LaunchKit

Skip the boilerplate. Ship the thing.

Top comments (0)