← Back to articles

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.

Get AI tool guides in your inbox

Weekly deep-dives on the best AI coding tools, automation platforms, and productivity software.