@vivekcsein-logo
  • Home
  • About
  • Development

    • Web Development
    • Performance & Optimization
    • Backend & APIs
    • Authentication & Security

    Specialized

    • SEO & Ad Monetization
    • AR Experiences
    • Animation
    • Figma to Code
  • Articles
  • Projects
  • Marketplace
  • Contact
@vivekcsein-logo

A portfolio full stack developer with a passion for building scalable and high-performance web applications.

Explore

  • Home
  • About

Services

  • Web Development
  • Backend & APIs
  • Performance Optimization
  • Security & Authentication

Connect

  • Contact
  • Privacy
  • Terms
  • Home
  • About
  • Web Development
  • Backend & APIs
  • Performance Optimization
  • Security & Authentication
  • Contact
  • Privacy
  • Terms

© @vivekcsein. All rights reserved.

  • Home
  • All Articles47
  • Featured
  • Recently Added
  • Most Read
  • Bookmarks
Topics
  • Interview3
  • 3d4
  • Ai Prompts2
  • Business3
  • Deployment1
  • Dev2
  • Frontend5
  • Getting Started2
  • Security2
    • The Complete Guide to Web Authentication
    • RBAC & Secure Client-Side Data Handling in Next.js
  • Seo2
  • Workflow10
  • Backend1
  • Deployment1
  • Security1
  • Javascript1
  • Client Advisory5
  • Engineering2
Collections
  • Docs
  • Tutorials
  • Resources
  • Articles
“A better developer every single day.”
— Vivek
  1. Docs
  2. Security
  3. RBAC & Secure Client-Side Data Handling in Next.js
Security

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.

Updated Sep 12, 20267 min read
On this page

On this page

  • A practical implementation guide (App Router)
  • 1. Core Concept
  • 2. Data Model: Roles → Permissions
  • 3. Layer 1 — Middleware (fast, cheap gatekeeping)
  • 4. Layer 2 — Server Components (the real UI authorization)
  • 5. Layer 3 — Server Actions & Route Handlers (the actual security boundary)
  • 6. Layer 4 — Client Components (UX only)
  • 7. Secure Client-Side Data Handling
  • 7.1 Never over-fetch then filter in the browser
  • 7.2 Keep secrets and role logic out of client bundles
  • 7.3 JWT/session handling
  • 7.4 Defense against tampering
  • 7.5 Caching with roles
  • 8. Common Pitfalls
  • 9. Recommended Stack (2026)
  • 10. Summary Checklist

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:

LayerPurposeWhat it stops
Middleware (Edge)Block unauthorized requests before they hit app codeUnauthenticated users reaching protected routes
Server ComponentsDecide what data/UI gets rendered server-sideSensitive data ever being sent to the client
Server Actions / Route HandlersValidate permission before mutations/readsDirect API calls bypassing the UI
Client ComponentsConditionally show/hide UIBad UX only — never treat as real security

2. Data Model: Roles → Permissions#

Avoid scattering if (role === "admin") everywhere. Centralize it.

ts
// 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.

ts
// 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.

tsx
// 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.

ts
// 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.

tsx
"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.

ts
// ❌ 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.

ts
"use cache";
cacheTag(`dashboard-${role}`);
cacheLife("minutes");

8. Common Pitfalls#

PitfallWhy it's dangerousFix
Only hiding UI elements client-sideAPI/Server Action still reachable directlyRe-check permissions server-side, always
Checking role instead of permissionAdding a role means editing every checkCentralize role→permission mapping
Trusting client-supplied role/user IDAnyone can edit a request bodyAlways derive identity/role from verified session
Storing tokens in localStorageReadable by any XSS payloadUse httpOnly, signed cookies
No session refresh on role changeUser keeps old permissions until token expiryForce session refresh/revoke on role change
Skipping middleware and relying only on Server ComponentsSlower failure, no URL-level redirectUse 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
PreviousThe Complete Guide to Web Authentication

Keep reading

  • Security

    The Complete Guide to Web Authentication

    A beginner-to-advanced guide to web authentication: XSS, CSRF, cookies, sessions, JWT, OAuth, MFA and RBAC, with full Hono, Express and Fastify implementations.

    41 min read·Sep 12, 2026
  • Workflow

    Stage 8 — Project Environment Setup

    Read every environment variable through Zod-validated client, app and auth modules, with server-only boundaries, fail-fast parsing and a synced .env.example.

    4 min read·Oct 2, 2026
  • Workflow

    Stage 10 — API Layer

    Build the API layer: AppError, toAppError, asyncHandler, one Axios instance with interceptors, typed services, Zod-validated responses and useAsyncAction.

    11 min read·Oct 2, 2026

On this page

  • A practical implementation guide (App Router)
  • 1. Core Concept
  • 2. Data Model: Roles → Permissions
  • 3. Layer 1 — Middleware (fast, cheap gatekeeping)
  • 4. Layer 2 — Server Components (the real UI authorization)
  • 5. Layer 3 — Server Actions & Route Handlers (the actual security boundary)
  • 6. Layer 4 — Client Components (UX only)
  • 7. Secure Client-Side Data Handling
  • 7.1 Never over-fetch then filter in the browser
  • 7.2 Keep secrets and role logic out of client bundles
  • 7.3 JWT/session handling
  • 7.4 Defense against tampering
  • 7.5 Caching with roles
  • 8. Common Pitfalls
  • 9. Recommended Stack (2026)
  • 10. Summary Checklist