Role-Based Access Control in Next.js with PostgreSQL — No Library Required
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.