Asia/Karachi
BlogOctober 4, 2026

Role-Based Access Control in Next.js with Prisma

Model roles, check permissions on the server and protect vendor-owned data
Fawaz Israr
Role-Based Access Control in Next.js with Prisma
Role-based access control (RBAC) means every user has a role, and each role is allowed a fixed set of actions. To build RBAC in Next.js with Prisma, store the role in your database, keep a small permission map in code, and check it on the server wherever data is read or changed. GoCart, my final year project, is a multi-vendor store with customers, vendors and admins, and it uses role-based access control with vendor dashboards. I'm a student, so read this as how I approach the problem rather than the only correct design. It assumes the Next.js App Router (15 or 16) and Prisma 5 or 6. For most apps, an enum on the user is enough. It is simple, type-safe in the generated client, and easy to read in the schema.
Prisma
enum Role {
  CUSTOMER
  VENDOR
  ADMIN
}

model User {
  id       String    @id @default(cuid())
  email    String    @unique
  role     Role      @default(CUSTOMER)
  products Product[]
}

model Product {
  id       String @id @default(cuid())
  name     String
  price    Int
  vendorId String
  vendor   User   @relation(fields: [vendorId], references: [id])
}
Notice that Product stores its owner. That column is what makes ownership checks possible later. For the wider database design, see Prisma schema design for an e-commerce database. If admins need to create new roles without a deploy, or a user can hold several roles, move to tables. A Role model and a Permission model with a many-to-many relation let you change permissions with data instead of code:
Prisma
model Role {
  id          Int          @id @default(autoincrement())
  name        String       @unique
  permissions Permission[]
  users       User[]
}

model Permission {
  id    Int    @id @default(autoincrement())
  key   String @unique
  roles Role[]
}
In that version User would carry a roleId instead of the enum. I would start with the enum and switch only when a real requirement forces it, since the table version adds joins and caching questions. Checking user.role === "ADMIN" all over the codebase gets messy fast. A single map from role to permissions keeps the rules in one file.
Ts
// lib/permissions.ts
import type { Role } from "@prisma/client";

export type Permission =
  | "product:create"
  | "product:update"
  | "product:update:any"
  | "user:manage";

const rolePermissions: Record<Role, Permission[]> = {
  CUSTOMER: [],
  VENDOR: ["product:create", "product:update"],
  ADMIN: ["product:update:any", "user:manage"],
};

export function can(role: Role, permission: Permission) {
  return rolePermissions[role].includes(permission);
}
The product:update permission only says a vendor may edit products. Whether they may edit this particular product is a separate question, and the ownership section below answers it. Keep the default closed: a role with no entry in the map can do nothing, so a new role you add later starts with no powers instead of every power. Hiding a button from customers is good interface design, but it is not security. Anyone can call your route handlers directly with curl, so every endpoint has to decide for itself. The Next.js middleware (renamed proxy in Next.js 16) deserves the same caution. It is a good first gate: cheap, runs early, and can redirect visitors who have no session cookie before a page renders. It is a poor last line of defence. It usually sees only the request, not your database, and a 2025 Next.js vulnerability showed that middleware could be skipped in some setups. A minimal gate looks like this:
Ts
// proxy.ts (named middleware.ts before Next.js 16)
import { NextRequest, NextResponse } from "next/server";

export function proxy(request: NextRequest) {
  // Presence check only. Real verification happens on the server.
  if (!request.cookies.get("session")) {
    return NextResponse.redirect(new URL("/login", request.url));
  }
  return NextResponse.next();
}

export const config = { matcher: ["/dashboard/:path*", "/admin/:path*"] };
The cookie name is a placeholder for whatever your auth setup uses. The Next.js authentication guide recommends exactly this split: optimistic checks in middleware, and real checks close to the data. If you build the login side yourself, JWT authentication in Express with bcrypt covers hashing and token handling. Put the check at every entry point that touches data. I assume a getCurrentUser() helper from your auth layer that verifies the session and returns the user with a fresh role, or null. How you implement it depends on your login setup, and I am deliberately not tying this article to one library. Loading the role from the database on each request costs one query, and it means a demoted vendor loses access immediately instead of when a token or session happens to expire. The same order of checks works everywhere: find out who is calling (401 if nobody), load the resource, decide whether this caller may touch it (403 if not), and only then do the work. Here is a route handler for editing a product:
Ts
// app/api/products/[id]/route.ts
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { getCurrentUser } from "@/lib/session";
import { can } from "@/lib/permissions";

export async function PATCH(
  req: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  const user = await getCurrentUser();
  if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

  const product = await prisma.product.findUnique({ where: { id } });
  if (!product) return NextResponse.json({ error: "Not found" }, { status: 404 });

  const isOwner = product.vendorId === user.id;
  const allowed =
    can(user.role, "product:update:any") ||
    (can(user.role, "product:update") && isOwner);
  if (!allowed) return NextResponse.json({ error: "Forbidden" }, { status: 403 });

  const body = await req.json();
  const updated = await prisma.product.update({
    where: { id },
    data: { name: body.name, price: body.price },
  });
  return NextResponse.json(updated);
}
Two details matter here. The role comes from getCurrentUser(), never from the request body or a header the client controls. And the update lists its fields explicitly, so a client cannot slip vendorId into the payload and reassign the product. In real code, validate the body with a schema library first. For server components, run the same check at the top of the page and call redirect or notFound when it fails. Do not rely on a layout alone: layouts do not re-render on every navigation, so a check that lives only there can be skipped. This is the ownership check, sometimes called object-level authorization. A role says what kind of action is allowed, and ownership says whether it applies to this row.
A commit history and file tree shown in a code editor
The strongest place for it is the query itself. Server actions are public POST endpoints too, so they need the check just like route handlers do:
Ts
"use server";

import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { getCurrentUser } from "@/lib/session";

export async function deleteProduct(id: string) {
  const user = await getCurrentUser();
  if (!user || (user.role !== "VENDOR" && user.role !== "ADMIN")) {
    throw new Error("Forbidden");
  }

  const { count } = await prisma.product.deleteMany({
    where: user.role === "ADMIN" ? { id } : { id, vendorId: user.id },
  });
  if (count === 0) throw new Error("Not found");

  revalidatePath("/dashboard/products");
}
A vendor who targets another vendor's product matches zero rows, so nothing is deleted, and the error is the same as for a missing product. That also avoids confirming which ids exist. Wrapping checks like this in a small data access layer, so pages never call Prisma directly, makes it much harder to forget one. Vendor dashboards, which GoCart has, are where this pattern matters most: every list and every mutation should be scoped to the signed-in vendor. The wider design is in how to build a multi-vendor e-commerce platform. Most authorization bugs hide in the cases nobody tried. Write tests for the requests that should fail, not only the happy path. With Vitest, mock the session and Prisma and call the handler directly:
Ts
import { it, expect, vi } from "vitest";
import { PATCH } from "@/app/api/products/[id]/route";
import { getCurrentUser } from "@/lib/session";
import { prisma } from "@/lib/prisma";

vi.mock("@/lib/session", () => ({ getCurrentUser: vi.fn() }));
vi.mock("@/lib/prisma", () => ({
  prisma: { product: { findUnique: vi.fn(), update: vi.fn() } },
}));

it("returns 403 when a vendor edits another vendor's product", async () => {
  vi.mocked(getCurrentUser).mockResolvedValue({ id: "v1", role: "VENDOR" } as never);
  vi.mocked(prisma.product.findUnique).mockResolvedValue({ id: "p1", vendorId: "v2" } as never);

  const req = new Request("http://test/api/products/p1", { method: "PATCH", body: "{}" });
  const res = await PATCH(req, { params: Promise.resolve({ id: "p1" }) });

  expect(res.status).toBe(403);
  expect(prisma.product.update).not.toHaveBeenCalled();
});
Add the same style of test for a signed-out request (401), a customer calling a vendor route, and an admin acting on any product.
  • Trusting a role sent from the client, whether in a hidden form field, a header or a request body. Read the role on the server.
  • Checking permissions in the page but forgetting the route handler or server action behind it.
  • Forgetting the check in the data layer, so a new page that queries Prisma directly skips the rules.
  • Putting secrets or sensitive personal data in the JWT. The payload is readable by the client, and a role stored in it stays stale until the token expires.
  • Using one broad "isAdmin" flag for everything, which makes it hard to give someone a smaller set of powers later.
  • Returning detailed error messages that reveal whether a resource exists.
The OWASP authorization cheat sheet lists more, including denying by default. If you are using this for a final year project, GoCart's write-up is on the project page, and a good next step is to add the denied-path tests above to one existing route. Cover photo from Unsplash.
Share this post: