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:
- Store roles in the database
- Inject them into the Auth.js session
- Protect pages via middleware
- Protect API routes with a helper
- 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
}
Run the migration:
npx prisma migrate dev --name add-user-role
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
},
},
})
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"]
}
}
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*"],
}
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 })
}
}
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)
}
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>
)
}
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>
}
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())
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.
Skip the boilerplate. Ship the thing.
Top comments (0)