RBAC & Secure Client-Side Data Handling in Next.js
Role-based access control and secure data handling in Next.js: four enforcement layers from middleware to client components, plus common pitfalls and a 2026 stack.
On this page
A practical implementation guide (App Router)#
1. Core Concept#
RBAC (Role-Based Access Control) means users get a role (admin, editor, viewer, etc.), and roles map to permissions. You check permissions, not raw roles, everywhere authorization matters.
The golden rule: UI hiding is cosmetic, not security. Every layer that touches data must independently verify the user is allowed to touch it. A hidden button means nothing if the API route behind it has no check.
Next.js App Router gives you four enforcement layers — use all of them, not just one:
| Layer | Purpose | What it stops |
|---|---|---|
| Middleware (Edge) | Block unauthorized requests before they hit app code | Unauthenticated users reaching protected routes |
| Server Components | Decide what data/UI gets rendered server-side | Sensitive data ever being sent to the client |
| Server Actions / Route Handlers | Validate permission before mutations/reads | Direct API calls bypassing the UI |
| Client Components | Conditionally show/hide UI | Bad UX only — never treat as real security |
2. Data Model: Roles → Permissions#
Avoid scattering if (role === "admin") everywhere. Centralize it.
// lib/rbac/roles.ts
export enum Role {
ADMIN = "admin",
MANAGER = "manager",
USER = "user",
}
export const permissions = {
admin: [
"users:list",
"users:edit",
"posts:edit",
"posts:view",
"billing:manage",
],
manager: ["posts:edit", "posts:view"],
user: ["posts:view"],
} as const;
export type Permission = (typeof permissions)[keyof typeof permissions][number];
export function hasPermission(role: Role, permission: Permission): boolean {
return permissions[role]?.includes(permission) ?? false;
}
This gives you one source of truth. Adding a new role or permission means editing one file, not hunting through components.
3. Layer 1 — Middleware (fast, cheap gatekeeping)#
Middleware runs at the edge before a request reaches your app. Use it for coarse checks (logged in? role in an allowed list?) — not fine-grained permission logic, since it should stay fast.
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { auth } from "@/auth";
const roleRoutes: Record<string, string[]> = {
"/admin": ["admin"],
"/dashboard": ["admin", "manager", "user"],
};
export default auth((req: NextRequest & { auth: any }) => {
const { pathname } = req.nextUrl;
const role = req.auth?.user?.role;
const matchedRoute = Object.keys(roleRoutes).find((r) =>
pathname.startsWith(r),
);
if (matchedRoute) {
if (!req.auth) {
return NextResponse.redirect(new URL("/login", req.url));
}
if (!roleRoutes[matchedRoute].includes(role)) {
return NextResponse.redirect(new URL("/unauthorized", req.url));
}
}
return NextResponse.next();
});
export const config = {
matcher: ["/admin/:path*", "/dashboard/:path*"],
};
Why this matters: middleware gives instant redirects for unauthorized users before any server rendering work happens — cheaper and faster than failing deep inside a component tree.
4. Layer 2 — Server Components (the real UI authorization)#
Do fine-grained "what does this specific user get to see" logic here, on the server, so unauthorized UI and data never reach the client bundle at all — not even hidden in the DOM.
// app/dashboard/page.tsx
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { hasPermission } from "@/lib/rbac/roles";
import { AdminStats } from "@/components/admin-stats";
import { EditorQueue } from "@/components/editor-queue";
import { ViewerFeed } from "@/components/viewer-feed";
export default async function DashboardPage() {
const session = await auth();
if (!session?.user) redirect("/login");
const role = session.user.role;
return (
<div>
<h1>Dashboard</h1>
{hasPermission(role, "users:list") && <AdminStats />}
{hasPermission(role, "posts:edit") && <EditorQueue />}
<ViewerFeed />
</div>
);
}
Because this renders server-side, AdminStats and its data never get shipped to a user who shouldn't see it — unlike a client-side {isAdmin && <AdminStats />} check, which still bundles and can be exposed via devtools/network inspection.
5. Layer 3 — Server Actions & Route Handlers (the actual security boundary)#
This is the layer that matters most. Every mutation and every data fetch must re-check permissions independently — assume the UI check never happened.
// app/actions/posts.ts
"use server";
import { auth } from "@/auth";
import { hasPermission } from "@/lib/rbac/roles";
export async function deletePost(postId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!hasPermission(session.user.role, "posts:edit")) {
throw new Error("Forbidden: insufficient permissions");
}
// Also verify resource-level ownership if relevant, e.g.:
// const post = await db.post.findUnique({ where: { id: postId } });
// if (post.authorId !== session.user.id && !hasPermission(role, "posts:edit:any")) throw ...
await db.post.delete({ where: { id: postId } });
}
Same principle for Route Handlers (app/api/.../route.ts) — check the session and permission at the top of every handler, before touching the database.
6. Layer 4 — Client Components (UX only)#
Use client-side checks purely to avoid flashing buttons a user can't use — never as the actual gate.
"use client";
import { usePermission } from "@/hooks/use-permission";
export function EditButton({ postId }: { postId: string }) {
const canEdit = usePermission("posts:edit");
if (!canEdit) return null;
return <button onClick={() => deletePost(postId)}>Delete</button>;
}
Even if a user forges this check via devtools and calls deletePost() directly, the Server Action from Section 5 still blocks them. That redundancy is the point.
7. Secure Client-Side Data Handling#
Rendering UI correctly is half the problem — the other half is not leaking data to the client in the first place.
7.1 Never over-fetch then filter in the browser#
Don't fetch the full admin dataset and hide rows with CSS/JS. Filter on the server so the unauthorized data is never transmitted.
// ❌ Bad: fetch everything, hide client-side
const allUsers = await fetchAllUsers(); // sent to browser regardless
// ✅ Good: filter before it ever leaves the server
const users = hasPermission(role, "users:list")
? await fetchAllUsers()
: await fetchOwnProfile(session.user.id);
7.2 Keep secrets and role logic out of client bundles#
- Never put API keys, DB credentials, or full permission tables in code that ships to
"use client"components. - Only pass the minimal derived value (
canEdit: boolean) to the client, not the full role/permission map.
7.3 JWT/session handling#
- Store role/permission claims in the session token (JWT via NextAuth/Auth.js, or a session cookie), signed and httpOnly — never in
localStorage, which is readable by any injected script (XSS risk). - Keep tokens short-lived and use refresh tokens; on role change, explicitly invalidate/refresh the session — a stale JWT will keep granting old permissions until it expires.
7.4 Defense against tampering#
- Treat all client input (including role claimed in a request body) as untrusted. Always re-derive the role from the verified session server-side, never from a client-supplied field.
- Validate/parse every Server Action and Route Handler input with a schema (e.g. Zod) before using it.
7.5 Caching with roles#
If using Next.js's cache directives, make sure cache keys include the role — otherwise Admin A's cached response can leak into Viewer B's request.
"use cache";
cacheTag(`dashboard-${role}`);
cacheLife("minutes");
8. Common Pitfalls#
| Pitfall | Why it's dangerous | Fix |
|---|---|---|
| Only hiding UI elements client-side | API/Server Action still reachable directly | Re-check permissions server-side, always |
| Checking role instead of permission | Adding a role means editing every check | Centralize role→permission mapping |
| Trusting client-supplied role/user ID | Anyone can edit a request body | Always derive identity/role from verified session |
Storing tokens in localStorage | Readable by any XSS payload | Use httpOnly, signed cookies |
| No session refresh on role change | User keeps old permissions until token expiry | Force session refresh/revoke on role change |
| Skipping middleware and relying only on Server Components | Slower failure, no URL-level redirect | Use middleware for coarse routing checks too |
9. Recommended Stack (2026)#
- Auth: Auth.js (NextAuth) v5, Clerk, or Supabase Auth — all support role claims in session/JWT.
- Validation: Zod for all Server Action/Route Handler inputs.
- Permission checks: A single
hasPermission(role, permission)helper, called at every layer. - Testing: Cover every critical permission path (admin can, viewer cannot, unauthenticated redirected) with integration tests — not just unit tests on the helper function.
10. Summary Checklist#
- Roles and permissions centralized in one file
- Middleware blocks unauthorized routes early
- Server Components decide what UI/data renders — sensitive data never shipped to unauthorized users
- Every Server Action / Route Handler re-validates permission independently
- Client-side checks exist only for UX, never as the real gate
- Tokens are httpOnly/signed, never in localStorage
- Role changes force session invalidation/refresh
- Cache keys are role-aware
- All external input is schema-validated server-side