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 PERMISSIONS

Permission 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#

  1. Use enums for roles - Type-safe role definitions
  2. Check permissions, not roles - More flexible access control
  3. Default to least privilege - Users start with minimal permissions
  4. Centralize authorization - Single source of truth for permissions
  5. Audit role changes - Log all permission modifications