Role-Based Access Control (RBAC)
Patterns for implementing role-based permissions in Next.js.
Overview#
RBAC enables granular access control:
- Define user roles (admin, user, etc.)
- Map permissions to roles
- Protect routes and components
- API endpoint authorization
Prerequisites:
- User authentication system
- Database with role storage
Implementation#
Role Definition#
1// lib/rbac.ts
2export const ROLES = {
3 USER: 'USER',
4 ADMIN: 'ADMIN',
5 SUPER_ADMIN: 'SUPER_ADMIN'
6} as const
7
8export type Role = keyof typeof ROLES
9
10export const PERMISSIONS = {
11 // User permissions
12 'user:read': [ROLES.USER, ROLES.ADMIN, ROLES.SUPER_ADMIN],
13 'user:update': [ROLES.USER, ROLES.ADMIN, ROLES.SUPER_ADMIN],
14
15 // Admin permissions
16 'user:delete': [ROLES.ADMIN, ROLES.SUPER_ADMIN],
17 'user:create': [ROLES.ADMIN, ROLES.SUPER_ADMIN],
18
19 // Super admin permissions
20 'admin:manage': [ROLES.SUPER_ADMIN],
21 'system:configure': [ROLES.SUPER_ADMIN]
22} as const
23
24export type Permission = keyof typeof PERMISSIONSPermission Check#
1// lib/rbac.ts
2import { auth } from '@/auth'
3import { prisma } from '@/lib/db'
4
5export async function getUserRole(userId: string): Promise<Role> {
6 const user = await prisma.user.findUnique({
7 where: { id: userId },
8 select: { role: true }
9 })
10
11 return (user?.role as Role) ?? 'USER'
12}
13
14export async function hasPermission(permission: Permission): Promise<boolean> {
15 const session = await auth()
16 if (!session?.user) return false
17
18 const role = await getUserRole(session.user.id)
19 return PERMISSIONS[permission].includes(role)
20}
21
22export async function requirePermission(permission: Permission) {
23 const allowed = await hasPermission(permission)
24
25 if (!allowed) {
26 throw new Error('Forbidden')
27 }
28}Role Guard Wrapper#
1// lib/rbac.ts
2export function withRole(allowedRoles: Role[]) {
3 return async function guard() {
4 const session = await auth()
5
6 if (!session?.user) {
7 throw new Error('Unauthorized')
8 }
9
10 const role = await getUserRole(session.user.id)
11
12 if (!allowedRoles.includes(role)) {
13 throw new Error('Forbidden')
14 }
15
16 return { session, role }
17 }
18}
19
20// Usage in Server Action
21export async function deleteUser(userId: string) {
22 await withRole(['ADMIN', 'SUPER_ADMIN'])()
23
24 await prisma.user.delete({ where: { id: userId } })
25}Component-Level Protection#
1// components/admin-only.tsx
2import { auth } from '@/auth'
3import { getUserRole } from '@/lib/rbac'
4
5export async function AdminOnly({
6 children
7}: {
8 children: React.ReactNode
9}) {
10 const session = await auth()
11
12 if (!session?.user) return null
13
14 const role = await getUserRole(session.user.id)
15
16 if (!['ADMIN', 'SUPER_ADMIN'].includes(role)) {
17 return null
18 }
19
20 return <>{children}</>
21}API Route Protection#
1// app/api/admin/users/route.ts
2import { NextResponse } from 'next/server'
3import { requirePermission } from '@/lib/rbac'
4
5export async function DELETE(req: Request) {
6 try {
7 await requirePermission('user:delete')
8
9 const { userId } = await req.json()
10 await prisma.user.delete({ where: { id: userId } })
11
12 return NextResponse.json({ success: true })
13 } catch (error) {
14 if (error.message === 'Forbidden') {
15 return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
16 }
17 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
18 }
19}Middleware-Based Protection#
1// middleware.ts
2import { NextResponse } from 'next/server'
3import type { NextRequest } from 'next/server'
4import { auth } from '@/auth'
5
6const ADMIN_ROUTES = ['/admin', '/admin/(.*)']
7const PROTECTED_ROUTES = ['/dashboard', '/settings']
8
9export async function middleware(request: NextRequest) {
10 const session = await auth()
11 const pathname = request.nextUrl.pathname
12
13 // Check admin routes
14 if (pathname.startsWith('/admin')) {
15 if (!session?.user) {
16 return NextResponse.redirect(new URL('/login', request.url))
17 }
18
19 // Check for admin role
20 const role = session.user.role
21 if (!['ADMIN', 'SUPER_ADMIN'].includes(role)) {
22 return NextResponse.redirect(new URL('/unauthorized', request.url))
23 }
24 }
25
26 // Check protected routes
27 if (PROTECTED_ROUTES.some(route => pathname.startsWith(route))) {
28 if (!session?.user) {
29 return NextResponse.redirect(new URL('/login', request.url))
30 }
31 }
32
33 return NextResponse.next()
34}
35
36export const config = {
37 matcher: ['/admin/:path*', '/dashboard/:path*', '/settings/:path*']
38}Role-Based Navigation#
1// components/nav.tsx
2import { auth } from '@/auth'
3import { getUserRole } from '@/lib/rbac'
4import Link from 'next/link'
5
6export async function Navigation() {
7 const session = await auth()
8
9 if (!session?.user) {
10 return <PublicNav />
11 }
12
13 const role = await getUserRole(session.user.id)
14 const isAdmin = ['ADMIN', 'SUPER_ADMIN'].includes(role)
15
16 return (
17 <nav>
18 <Link href="/dashboard">Dashboard</Link>
19 <Link href="/settings">Settings</Link>
20
21 {isAdmin && (
22 <>
23 <Link href="/admin">Admin</Link>
24 <Link href="/admin/users">Users</Link>
25 </>
26 )}
27
28 {role === 'SUPER_ADMIN' && (
29 <Link href="/admin/system">System</Link>
30 )}
31 </nav>
32 )
33}Database Schema#
1// prisma/schema.prisma
2model User {
3 id String @id @default(cuid())
4 email String @unique
5 name String?
6 role Role @default(USER)
7 createdAt DateTime @default(now())
8 updatedAt DateTime @updatedAt
9}
10
11enum Role {
12 USER
13 ADMIN
14 SUPER_ADMIN
15}Usage Examples#
Protected Server Action#
1// actions/admin.ts
2'use server'
3
4import { withRole } from '@/lib/rbac'
5import { prisma } from '@/lib/db'
6import { revalidatePath } from 'next/cache'
7
8export async function promoteToAdmin(userId: string) {
9 const { session } = await withRole(['SUPER_ADMIN'])()
10
11 await prisma.user.update({
12 where: { id: userId },
13 data: { role: 'ADMIN' }
14 })
15
16 revalidatePath('/admin/users')
17}
18
19export async function deleteUser(userId: string) {
20 const { session } = await withRole(['ADMIN', 'SUPER_ADMIN'])()
21
22 // Prevent self-deletion
23 if (userId === session.user.id) {
24 throw new Error('Cannot delete yourself')
25 }
26
27 await prisma.user.delete({ where: { id: userId } })
28
29 revalidatePath('/admin/users')
30}Role Check Hook (Client)#
1// hooks/use-role.ts
2'use client'
3
4import { useSession } from 'next-auth/react'
5import { Role, PERMISSIONS, Permission } from '@/lib/rbac'
6
7export function useRole() {
8 const { data: session } = useSession()
9 const role = (session?.user?.role as Role) ?? 'USER'
10
11 const hasPermission = (permission: Permission): boolean => {
12 return PERMISSIONS[permission]?.includes(role) ?? false
13 }
14
15 const isAdmin = ['ADMIN', 'SUPER_ADMIN'].includes(role)
16 const isSuperAdmin = role === 'SUPER_ADMIN'
17
18 return {
19 role,
20 hasPermission,
21 isAdmin,
22 isSuperAdmin
23 }
24}Best Practices#
- Use enums for roles - Type-safe role definitions
- Check permissions, not roles - More flexible access control
- Default to least privilege - Users start with minimal permissions
- Centralize authorization - Single source of truth for permissions
- Audit role changes - Log all permission modifications
Related Patterns#
- Session Management - Session handling with roles
- Clerk - Clerk with role metadata
- NextAuth.js - NextAuth with role callbacks