# Role-Based Access Control Without a Library
By Govind Bajaj · 2025

Tags: security, rbac, typescript, next.js, auth
You do not need CASL, CASL-React, or any RBAC library for most applications. 47 lines of TypeScript. Zero dependencies. Here's the pattern.
I have used CASL, accesscontrol, and a half-dozen other RBAC libraries over the years.

For most applications, they are overkill. You have three roles: admin, editor, and viewer. The admin can do everything. The editor can create and update. The viewer can read. You do not need a 50KB library with its own DSL for this.

Here is the forty-seven-line TypeScript implementation I use instead. Zero dependencies. Fully typed. Easy to extend.

## The Permission Type

```ts
// lib/permissions.ts
export type Permission = 
  | 'user:read' | 'user:create' | 'user:update' | 'user:delete'
  | 'product:read' | 'product:create' | 'product:update' | 'product:delete'
  | 'order:read' | 'order:update'
  | 'analytics:read'

export type Role = 'admin' | 'editor' | 'viewer'

const rolePermissions: Record<Role, Permission[]> = {
  admin: [
    'user:read', 'user:create', 'user:update', 'user:delete',
    'product:read', 'product:create', 'product:update', 'product:delete',
    'order:read', 'order:update',
    'analytics:read',
  ],
  editor: [
    'product:read', 'product:create', 'product:update',
    'order:read', 'order:update',
  ],
  viewer: [
    'product:read',
    'order:read',
  ],
}

export function hasPermission(role: Role, permission: Permission): boolean {
  return rolePermissions[role]?.includes(permission) ?? false
}

export function hasAnyPermission(role: Role, permissions: Permission[]): boolean {
  return permissions.some(p => hasPermission(role, p))
}
```

## Using in Server Components

```tsx
// app/admin/users/page.tsx
import { auth } from '@/auth'
import { hasPermission } from '@/lib/permissions'
import { redirect } from 'next/navigation'

export default async function UsersPage() {
  const session = await auth()
  
  if (!hasPermission(session?.user?.role, 'user:read')) {
    redirect('/')
  }
  
  const users = await getUsers()
  return <UsersTable users={users} />
}
```

## Using in API Routes

```ts
// app/api/users/route.ts
import { auth } from '@/auth'
import { hasPermission } from '@/lib/permissions'

export async function POST(req: Request) {
  const session = await auth()
  
  if (!hasPermission(session?.user?.role, 'user:create')) {
    return Response.json({ error: 'Forbidden' }, { status: 403 })
  }
  
  const body = await req.json()
  const user = await createUser(body)
  return Response.json(user)
}
```

## Conditional UI Rendering

```tsx
// components/ProductActions.tsx
import { hasPermission } from '@/lib/permissions'
import { useSession } from 'next-auth/react'

export function ProductActions({ productId }: { productId: string }) {
  const { data: session } = useSession()
  const role = session?.user?.role
  
  return (
    <div>
      {hasPermission(role, 'product:update') && (
        <button>Edit</button>
      )}
      {hasPermission(role, 'product:delete') && (
        <button>Delete</button>
      )}
    </div>
  )
}
```

## When to Use a Library

This pattern works for applications with a simple role hierarchy and resource-based permissions. Consider a library when you need attribute-based access control (user can only edit their own posts), time-based permissions (access expires), dynamic roles (roles created at runtime), or hierarchical resources (permissions inherit from parent resources).

For everything else, the forty-seven-line approach is clearer, faster, and easier to debug.

## Takeaways

- Most applications do not need an RBAC library — a typed permissions map is sufficient
- Define Permission and Role types for compile-time safety
- Check permissions in Server Components, API routes, and conditional UI
- Return 403 from APIs, redirect from pages, hide buttons in UI
- Centralize permission definitions — changes propagate everywhere
- Use a library only for attribute-based, time-based, or dynamic role requirements
- Zero dependencies means zero upgrade conflicts and zero bundle impact