How to Implement Role-Based Access Control (2026)
RBAC determines who can do what in your app. Here's how to implement it properly — from database schema to UI.
RBAC Design
User → has Role → Role has Permissions
admin → can: create, read, update, delete, manage_users
editor → can: create, read, update
viewer → can: read
Database Schema
export const roles = pgTable('roles', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull().unique(), // 'admin', 'editor', 'viewer'
description: text('description'),
})
export const permissions = pgTable('permissions', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull().unique(), // 'posts:create', 'posts:delete', 'users:manage'
description: text('description'),
})
export const rolePermissions = pgTable('role_permissions', {
roleId: uuid('role_id').references(() => roles.id),
permissionId: uuid('permission_id').references(() => permissions.id),
}, (t) => ({
pk: primaryKey({ columns: [t.roleId, t.permissionId] }),
}))
export const userRoles = pgTable('user_roles', {
userId: text('user_id').notNull(),
roleId: uuid('role_id').references(() => roles.id),
tenantId: uuid('tenant_id'), // For multi-tenant: role per org
}, (t) => ({
pk: primaryKey({ columns: [t.userId, t.roleId] }),
}))
Permission Check Function
// lib/permissions.ts
export async function hasPermission(
userId: string,
permission: string,
tenantId?: string
): Promise<boolean> {
const result = await db
.select()
.from(userRoles)
.innerJoin(rolePermissions, eq(userRoles.roleId, rolePermissions.roleId))
.innerJoin(permissions, eq(rolePermissions.permissionId, permissions.id))
.where(
and(
eq(userRoles.userId, userId),
eq(permissions.name, permission),
tenantId ? eq(userRoles.tenantId, tenantId) : undefined,
)
)
.limit(1)
return result.length > 0
}
// Usage
if (await hasPermission(user.id, 'posts:delete')) {
await deletePost(postId)
} else {
throw new Error('Forbidden')
}
Middleware (Next.js)
// middleware.ts
import { auth } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
const ROUTE_PERMISSIONS: Record<string, string> = {
'POST /api/posts': 'posts:create',
'DELETE /api/posts': 'posts:delete',
'GET /api/admin': 'admin:access',
'POST /api/users': 'users:manage',
}
export async function middleware(req: NextRequest) {
const user = await auth()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const routeKey = `${req.method} ${req.nextUrl.pathname}`
const requiredPermission = ROUTE_PERMISSIONS[routeKey]
if (requiredPermission && !(await hasPermission(user.id, requiredPermission))) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
return NextResponse.next()
}
UI-Level Access Control
// components/PermissionGate.tsx
'use client'
function PermissionGate({
permission,
children,
fallback = null,
}: {
permission: string
children: React.ReactNode
fallback?: React.ReactNode
}) {
const { permissions } = useUser() // From your auth context
if (!permissions.includes(permission)) return fallback
return children
}
// Usage
<PermissionGate permission="posts:delete">
<button onClick={deletePost}>Delete Post</button>
</PermissionGate>
<PermissionGate permission="users:manage" fallback={<p>Admin only</p>}>
<UserManagementPanel />
</PermissionGate>
Simpler Alternative: Role-Based (No Permissions Table)
For small apps, skip the permissions table:
const ROLE_PERMISSIONS = {
admin: ['*'],
editor: ['posts:create', 'posts:read', 'posts:update'],
viewer: ['posts:read'],
} as const
function hasPermission(role: string, permission: string): boolean {
const perms = ROLE_PERMISSIONS[role]
return perms.includes('*') || perms.includes(permission)
}
Hardcoded is simpler. Use the database approach when roles/permissions need to be configurable at runtime.
FAQ
RBAC vs ABAC?
RBAC: permissions based on roles. ABAC: permissions based on attributes (user department, resource owner, time of day). Start with RBAC, add ABAC rules when needed.
Should I use Clerk's organizations?
Yes. Clerk handles roles (admin, member) per organization. Build fine-grained permissions on top of Clerk's roles.
How do I handle role changes?
Invalidate cached permissions when roles change. If using JWTs with embedded roles, force token refresh.
Bottom Line
Start simple: hardcoded role-permission mapping. Add a database-backed system when roles need to be configurable. Always check permissions server-side (middleware + API routes). UI-level gates improve UX but don't replace server checks.