Claude Code Boilerplate
FeaturesPricingBlogDocs
Get started →

Product

  • Features
  • Pricing
  • Skills

Compare

  • vs ShipFast
  • vs MakerKit
  • vs supastarter

Resources

  • Docs
  • Blog
  • Discord

Legal

  • License
  • Privacy Policy
  • Terms of Service
Claude Code Boilerplate

© 2026 Claude Code Boilerplate. All rights reserved.

← All posts

Role-based access control in Next.js App Router -- gating routes and actions by user role with Drizzle ORM

June 21, 2026
nextjsdrizzle-ormauthsaas

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.

The schema

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.

The helper

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.

Using it in a service

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.

Gating page access in server components

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 />;
}

Hiding UI elements by role

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.

What you do not need

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.

How long this takes

With this structure already in place:

  • Schema change + migration: 5 minutes
  • requireRole() helper: 10 minutes
  • Applying it to each existing service: 2 minutes per service

You 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.