Most SaaS apps start with a single user type. Then comes the request: "Can we have admins who can see everything, and regular users who can only see their own data?"
That is RBAC -- role-based access control. Adding it late means auditing every route and every service call to figure out what should be gated. Adding it early takes under two hours. Here is the exact pattern.
Add a role column to your users table. Use a Postgres enum so invalid values are rejected at the DB level -- not just in application code:
// modules/user/user.schema.ts
import { pgTable, uuid, text, timestamp, pgEnum } from 'drizzle-orm/pg-core';
export const userRoleEnum = pgEnum('user_role', ['user', 'admin']);
export const userTable = pgTable('users', {
id: uuid('id').primaryKey().defaultRandom(),
email: text('email').notNull().unique(),
passwordHash: text('password_hash').notNull(),
role: userRoleEnum('role').notNull().default('user'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
});
Run npm run db:generate && npm run db:migrate to apply the change. Every new user gets the user role by default. You promote to admin directly in Drizzle Studio or via a one-off script.
Put role-checking logic in one place. A requireRole() helper throws HttpError(403) if the caller does not meet the required role:
// lib/auth.ts
import { HttpError } from './errors';
export type UserRole = 'user' | 'admin';
export function requireRole(userRole: UserRole, required: UserRole) {
const rank: Record<UserRole, number> = { user: 0, admin: 1 };
if (rank[userRole] < rank[required]) {
throw new HttpError(403, 'Insufficient permissions');
}
}
The rank map makes adding a superadmin level later a one-line change.
Call requireRole() at the top of any service method that should be gated. The route stays thin -- it never touches role logic directly:
// modules/user/user.service.ts
import { getUserFromRequest, requireRole } from '@/lib/auth';
import { userRepo } from './user.repo';
export const userService = {
async listAll(req: Request) {
const caller = await getUserFromRequest(req);
requireRole(caller.role, 'admin');
return userRepo.findAll();
},
};
// app/api/users/route.ts
import { userService } from '@/modules/user';
import { handleError } from '@/lib/errors';
export async function GET(req: Request) {
try {
const users = await userService.listAll(req);
return Response.json(users);
} catch (error: unknown) {
return handleError(error);
}
}
If the caller is not an admin, requireRole() throws, handleError() catches it, and the route returns a 403 with a clear message. The route itself never needs to know what roles exist.
For pages, check the role in the server component and redirect unauthorized visitors:
// app/(main)/admin/page.tsx
import { redirect } from 'next/navigation';
import { getUserFromRequest } from '@/lib/auth';
import { cookies } from 'next/headers';
export default async function AdminPage() {
const token = (await cookies()).get('token')?.value;
const req = new Request('http://localhost', {
headers: { Authorization: `Bearer ${token ?? ''}` },
});
let user;
try {
user = await getUserFromRequest(req);
} catch {
redirect('/login');
}
if (user.role !== 'admin') redirect('/');
return <AdminDashboard />;
}
Pass the role down as a prop and render conditionally:
// components/layout/header.tsx (server component)
export async function Header({ role }: { role: string }) {
return (
<nav>
<a href="/">Home</a>
{role === 'admin' && <a href="/admin">Admin</a>}
</nav>
);
}
UI guards are for experience, not security. A user who removes a hidden link from the DOM can still call your API. Always enforce roles in the service layer.
You do not need Next.js middleware for this. Middleware runs at the edge and cannot query your database, so it cannot verify the role stored on the user record. Keep role checks where data access happens -- in the service layer -- and you never have to worry about a middleware bypass.
With this structure already in place:
requireRole() helper: 10 minutesYou go from zero to working RBAC in under 30 minutes. The pattern stays consistent whether you have 5 routes or 50.
If you want to skip the setup entirely, clone the boilerplate -- getUserFromRequest(), HttpError, and handleError() are already wired. You add the role column, the helper, and you are done.