back

Role-Based Access Control in Next.js with PostgreSQL — No Library Required

May 18, 2025·9 min read·Antima Singh

The VisaWalk visa management system required three distinct admin roles — each with different permission levels over applicant data, documents, and case workflows. I wanted clean, testable code, not a permissions library I would need to learn and maintain. Here is how I built it.

The data model

I store roles as a PostgreSQL enum and attach them to users in the database. Permissions are checked in code rather than stored in a permissions table — this trades flexibility for simplicity, which is the right trade for a fixed set of roles.

CREATE TYPE user_role AS ENUM ('viewer', 'case_manager', 'admin');

ALTER TABLE users ADD COLUMN role user_role NOT NULL DEFAULT 'viewer';

A permissions map in TypeScript

I define what each role can do in a single TypeScript object. This is the entire permissions system.

type Permission =
  | 'view:applicants'
  | 'edit:applicants'
  | 'delete:applicants'
  | 'manage:users';

const ROLE_PERMISSIONS: Record<UserRole, Permission[]> = {
  viewer:       ['view:applicants'],
  case_manager: ['view:applicants', 'edit:applicants'],
  admin:        ['view:applicants', 'edit:applicants', 'delete:applicants', 'manage:users'],
};

export function hasPermission(role: UserRole, permission: Permission): boolean {
  return ROLE_PERMISSIONS[role].includes(permission);
}

Enforcing permissions in Route Handlers

Every protected Route Handler calls a shared `getAuthenticatedUser()` helper that returns the user with their role, then checks permission before proceeding.

export async function DELETE(req: Request, { params }: { params: { id: string } }) {
  const user = await getAuthenticatedUser();
  if (!hasPermission(user.role, 'delete:applicants')) {
    return new Response('Forbidden', { status: 403 });
  }
  // proceed with deletion
}

What I would change

This approach does not scale well beyond ~10 permissions or ~5 roles. If the system needed to grow, I would move to a proper roles-and-permissions table with a many-to-many relationship. For VisaWalk's scope, the simplicity was worth the trade-off.