<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged rbac</title>
<link>https://coderlegion.com/tag/rbac</link>
<description>Powered by Question2Answer</description>
<item>
<title>Role-Based Access Control (RBAC) in Next.js 16 (2026 Beginner's Guide)</title>
<link>https://coderlegion.com/24809/role-based-access-control-rbac-in-next-js-16-2026-beginners-guide</link>
<description>&lt;p&gt;Logging a user in is the easy part. The hard part shows up right after — when your &quot;admin&quot; and your &quot;customer&quot; both hit &lt;code&gt;/dashboard&lt;/code&gt; and somehow need to see completely different things. Or worse, when a regular user figures out they can just type &lt;code&gt;/admin&lt;/code&gt; into the URL bar and… get in, because nothing was actually checking who they were.&lt;/p&gt;
&lt;p&gt;That's the gap &lt;strong&gt;role-based authorization&lt;/strong&gt; (RBAC — Role-Based Access Control) fills. It's not about &lt;em&gt;whether&lt;/em&gt; someone is logged in anymore — it's about &lt;em&gt;what they're allowed to do&lt;/em&gt; once they are. An admin can delete users. An editor can publish posts. A viewer can only look. Same login system, three completely different sets of permissions.&lt;/p&gt;
&lt;p&gt;In this guide, you'll build a complete role-based authorization system in Next.js 16 — from storing roles, to protecting routes with &lt;code&gt;proxy.ts&lt;/code&gt;, to locking down Server Actions and API routes so a sneaky &lt;code&gt;curl&lt;/code&gt; request can't bypass your UI checks. By the end, you'll have a reusable pattern you can drop into any SaaS, dashboard, or internal tool.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-this-is-tricky&quot;&gt;Why This Is Tricky&lt;/h3&gt;
&lt;p&gt;Authentication just answers &quot;who are you?&quot; Authorization answers &quot;what are you allowed to do?&quot; — and that second question has to be checked in &lt;strong&gt;every place&lt;/strong&gt; data or actions are exposed, not just once at the login screen.&lt;/p&gt;
&lt;p&gt;The mistake almost everyone makes early on is checking roles only in the UI — hiding an &quot;Edit&quot; button if &lt;code&gt;role !== &quot;admin&quot;&lt;/code&gt;. That stops confused users, but it does nothing to stop a determined one, since a hidden button doesn't stop someone from calling the underlying Server Action or API route directly. Real role-based auth needs checks in at least three layers:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Route level&lt;/strong&gt; — don't even let the page load for the wrong role&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;UI level&lt;/strong&gt; — hide actions the user can't perform (good UX, not security)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Server level&lt;/strong&gt; — re-verify inside every Server Action, Route Handler, and database query (the actual security boundary)&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;We'll build all three.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-we-re-building&quot;&gt;What We're Building&lt;/h3&gt;
&lt;p&gt;A small app with three roles:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;admin&lt;/strong&gt; — full access, can manage users&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;editor&lt;/strong&gt; — can create and edit posts&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;viewer&lt;/strong&gt; — read-only access&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You'll end up with:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A typed &lt;code&gt;Role&lt;/code&gt; system shared across the whole app&lt;/li&gt;
&lt;li&gt;Role storage using Auth.js v5's JWT session (works the same pattern with a database session)&lt;/li&gt;
&lt;li&gt;Route protection in &lt;code&gt;proxy.ts&lt;/code&gt; based on role, not just login state&lt;/li&gt;
&lt;li&gt;A reusable &lt;code&gt;requirePermission()&lt;/code&gt; helper for Server Actions and Route Handlers&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;&amp;lt;Can&amp;gt;&lt;/code&gt; component for conditionally showing UI based on permissions&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;                 ┌──────────────┐
                 │    Role      │
                 │ admin/editor │
                 │    viewer    │
                 └──────┬───────┘
                        ↓
                 ┌──────────────┐
                 │ Permissions  │
                 └──────┬───────┘
                        ↓
              ┌─────────┴─────────┐
              ↓                   ↓
      requirePermission()       &amp;lt;Can&amp;gt;
              ↓                   ↓
       Security boundary      UI visibility
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;tech-stack&quot;&gt;Tech Stack&lt;/h4&gt;
&lt;p&gt;This guide uses:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Next.js 16&lt;/strong&gt; with the App Router&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;TypeScript&lt;/strong&gt; in strict mode&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tailwind CSS&lt;/strong&gt; for the small UI examples&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Auth.js v5&lt;/strong&gt; (&lt;code&gt;next-auth@beta&lt;/code&gt;) for authentication — the same library covered in &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/clerk-vs-nextauth-2026&quot;&gt;Clerk vs NextAuth (Auth.js) in 2026&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; Don't have Auth.js set up yet? Read &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/how-to-add-nextauth-in-nextjs-16&quot;&gt;How to Add Auth.js (NextAuth v5) Authentication in Next.js 16&lt;/a&gt; first — this guide picks up right where that one leaves off. If you're using &lt;strong&gt;Clerk&lt;/strong&gt; instead, skip to the &lt;a rel=&quot;nofollow&quot; href=&quot;#doing-this-with-clerk-instead&quot;&gt;Clerk section&lt;/a&gt; near the end — the concepts are identical, just the plumbing changes.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-1-define-your-roles-in-one-place&quot;&gt;Step 1: Define Your Roles in One Place&lt;/h3&gt;
&lt;p&gt;Before writing any auth code, decide what roles exist and what each one can actually do. Hardcoding role strings (&quot;admin&quot;, &quot;editor&quot;...) all over your codebase is exactly how typos slip in and silently break a permission check.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/auth/roles.ts
export const ROLES = [&quot;admin&quot;, &quot;editor&quot;, &quot;viewer&quot;] as const;

export type Role = (typeof ROLES)[number];

// What each role is allowed to do — the single source of truth
export const PERMISSIONS = {
  admin: [&quot;manage_users&quot;, &quot;create_post&quot;, &quot;edit_post&quot;, &quot;delete_post&quot;, &quot;view_post&quot;],
  editor: [&quot;create_post&quot;, &quot;edit_post&quot;, &quot;view_post&quot;],
  viewer: [&quot;view_post&quot;],
} as const satisfies Record&amp;lt;Role, readonly string[]&amp;gt;;

export type Permission = (typeof PERMISSIONS)[Role][number];

export function hasPermission(role: Role, permission: Permission): boolean {
  return (PERMISSIONS[role] as readonly string[]).includes(permission);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;ROLES&lt;/code&gt; is a fixed list — TypeScript's &lt;code&gt;as const&lt;/code&gt; makes &lt;code&gt;Role&lt;/code&gt; a union type (&lt;code&gt;&quot;admin&quot; | &quot;editor&quot; | &quot;viewer&quot;&lt;/code&gt;) instead of a loose &lt;code&gt;string&lt;/code&gt;, so a typo like &lt;code&gt;&quot;admn&quot;&lt;/code&gt; fails to compile instead of silently failing at runtime. &lt;code&gt;PERMISSIONS&lt;/code&gt; maps each role to what it's allowed to do, and &lt;code&gt;hasPermission()&lt;/code&gt; is the one function every check in this guide will eventually call.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; Notice we're checking &lt;strong&gt;permissions&lt;/strong&gt; (&lt;code&gt;&quot;create_post&quot;&lt;/code&gt;), not roles (&lt;code&gt;&quot;editor&quot;&lt;/code&gt;), everywhere except this one file. If you ever add a new role or change what editors can do, you update this file once — nothing else in your app needs to change.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-2-store-the-role-on-the-user&quot;&gt;Step 2: Store the Role on the User&lt;/h3&gt;
&lt;p&gt;Your role has to live somewhere Auth.js can read it during login. In a real app, this is a &lt;code&gt;role&lt;/code&gt; column on your &lt;code&gt;users&lt;/code&gt; table. For this guide, here's the shape assuming you're using a database adapter (Prisma, Drizzle, or similar):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Minimal schema addition
alter table users
add column role text not null default 'viewer'
check (role in ('admin', 'editor', 'viewer'));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;New users default to the lowest-privilege role — &lt;code&gt;viewer&lt;/code&gt; — which is a good default for any RBAC system. Never default a new signup to &lt;code&gt;admin&lt;/code&gt;. &lt;/p&gt;
&lt;p&gt;TypeScript protects application code at compile time; the database constraint protects persisted data at runtime.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Letting the client send its own role during signup (&lt;code&gt;formData.get(&quot;role&quot;)&lt;/code&gt;). Always assign the default role server-side and require an existing admin to promote anyone — otherwise anyone can sign up and just declare themselves an admin.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-3-put-the-role-on-the-session&quot;&gt;Step 3: Put the Role on the Session&lt;/h3&gt;
&lt;p&gt;This is the step most tutorials skip, and it's the one that actually makes RBAC work. By default, Auth.js's session only contains the basics (name, email, image) — you have to explicitly add &lt;code&gt;role&lt;/code&gt; to it using two callbacks: &lt;code&gt;jwt&lt;/code&gt; and &lt;code&gt;session&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/auth.ts
import NextAuth from &quot;next-auth&quot;;
import GitHub from &quot;next-auth/providers/github&quot;;
import type { Role } from &quot;./auth/roles&quot;;

declare module &quot;next-auth&quot; {
  interface User {
    id: string;
    role: Role;
  }
  interface Session {
    user: {
      id: string;
      role: Role;
    } &amp;amp; DefaultSession[&quot;user&quot;];
  }
}

declare module &quot;@auth/core/jwt&quot; {
  interface JWT {
    role: Role;
    id: string;
  }
}

export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: [GitHub],
  callbacks: {
    async jwt({ token, user }) {
      // Runs on sign-in, and on every subsequent request
      if (user) {
        // `user` is only defined right after login — look up the role from your DB here
        token.id = user.id!;
        token.role = user.role ?? &quot;viewer&quot;;
      }
      return token;
    },
    async session({ session, token }) {
      // Copies data from the JWT token into the session object your app actually reads
      session.user.id = token.id;
      session.user.role = token.role;
      return session;
    },
  },
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;jwt()&lt;/code&gt; can run whenever the JWT session is created or updated, but the &lt;code&gt;user&lt;/code&gt; object is available during the initial sign-in flow. That's where you copy the user's role into &lt;code&gt;token.role&lt;/code&gt;. On later requests, the role is read from the token rather than fetched again. &lt;code&gt;session()&lt;/code&gt; then runs on every &lt;code&gt;auth()&lt;/code&gt; call and copies fields from that token onto the &lt;code&gt;session&lt;/code&gt; object your pages and Server Actions actually read. The &lt;code&gt;declare module&lt;/code&gt; blocks aren't runtime code — they extend Auth.js's built-in TypeScript types so &lt;code&gt;session.user.role&lt;/code&gt; autocompletes instead of throwing a type error.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Trying to read the role directly off &lt;code&gt;session.user&lt;/code&gt; without adding it in these two callbacks first. Auth.js won't magically know about a custom &lt;code&gt;role&lt;/code&gt; column — you have to wire it through &lt;code&gt;jwt()&lt;/code&gt; → &lt;code&gt;session()&lt;/code&gt; explicitly, every time.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; If a user's role changes (e.g. an admin promotes them), the JWT won't reflect that until they log in again, since &lt;code&gt;jwt()&lt;/code&gt; only re-reads &lt;code&gt;user&lt;/code&gt; on fresh sign-in. For roles that need to update instantly, either switch to database sessions (&lt;code&gt;strategy: &quot;database&quot;&lt;/code&gt;) or add a &lt;code&gt;trigger === &quot;update&quot;&lt;/code&gt; branch that re-fetches the role on demand.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-4-protect-routes-by-role-in-proxy-ts&quot;&gt;Step 4: Protect Routes by Role in &lt;code&gt;proxy.ts&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;In Next.js 16, &lt;code&gt;middleware.ts&lt;/code&gt; was renamed to &lt;code&gt;proxy.ts&lt;/code&gt; — same file conventions, same idea: it runs before a request reaches a page, making it useful for a fast, optimistic route-level redirect before the page renders.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// proxy.ts
import { NextResponse } from &quot;next/server&quot;;
import { auth } from &quot;@/lib/auth&quot;;
import type { Role } from &quot;@/lib/auth/roles&quot;;

// Map route prefixes to the roles allowed to access them
const ROUTE_ROLES: { prefix: string; roles: Role[] }[] = [
  { prefix: &quot;/admin&quot;, roles: [&quot;admin&quot;] },
  { prefix: &quot;/editor&quot;, roles: [&quot;admin&quot;, &quot;editor&quot;] },
  { prefix: &quot;/dashboard&quot;, roles: [&quot;admin&quot;, &quot;editor&quot;, &quot;viewer&quot;] },
];

export default auth((req) =&amp;gt; {
  const { nextUrl } = req;
  const role = req.auth?.user?.role;

  const matched = ROUTE_ROLES.find((r) =&amp;gt; nextUrl.pathname === r.prefix || nextUrl.pathname.startsWith(`${r.prefix}/`));
  const isAuthPage = nextUrl.pathname === &quot;/login&quot;;
  const isLoggedIn = !!req.auth;

  if (isAuthPage &amp;amp;&amp;amp; isLoggedIn) {
      // Redirect authenticated users to the dashboard
      return NextResponse.redirect(new URL(&quot;/dashboard&quot;, nextUrl))
  }

  if (!matched) {
    return NextResponse.next(); // route isn't role-protected, let it through
  }

  if (!isLoggedIn) {
    // Not logged in at all — send to login
    return NextResponse.redirect(new URL(&quot;/login&quot;, nextUrl));
  }

  if (!role || !matched.roles.includes(role)) {
    // Logged in, but wrong role — send to a friendly &quot;not allowed&quot; page, not login
    return NextResponse.redirect(new URL(&quot;/unauthorized&quot;, nextUrl));
  }

  return NextResponse.next();
});

export const config = {
  matcher: [&quot;/((?!api|_next/static|_next/image|favicon.ico).*)&quot;],
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;ROUTE_ROLES&lt;/code&gt; is a lookup table — no scattered &lt;code&gt;if&lt;/code&gt; statements per route, just prefixes mapped to the roles allowed in. The proxy checks the incoming path against that table, and only bothers checking &lt;code&gt;req.auth&lt;/code&gt; at all if the route is actually protected. Notice the two different redirects: no session → &lt;code&gt;/login&lt;/code&gt; (they need to authenticate), wrong role → &lt;code&gt;/unauthorized&lt;/code&gt; (they're authenticated, just not allowed here) — conflating these two cases is a common source of confusing UX.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Forgetting the &lt;code&gt;matcher&lt;/code&gt; config, or matching too broadly. Running the proxy on every static asset request adds unnecessary overhead and can cause odd caching behavior — always exclude &lt;code&gt;_next/static&lt;/code&gt;, &lt;code&gt;_next/image&lt;/code&gt;, and favicon paths.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-5-build-a-requirepermission-helper-for-the-real-security-boundary&quot;&gt;Step 5: Build a &lt;code&gt;requirePermission()&lt;/code&gt; Helper for the Real Security Boundary&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;proxy.ts&lt;/code&gt; stops the page from loading, but it does &lt;strong&gt;not&lt;/strong&gt; protect Server Actions or Route Handlers called directly — those need their own check. This is the layer that actually matters for security, since &lt;code&gt;proxy.ts&lt;/code&gt; is a UX nicety, not a guarantee.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/auth/require-role.ts
import { auth } from &quot;@/lib/auth&quot;;
import type { Permission } from &quot;./roles&quot;;
import { hasPermission } from &quot;./roles&quot;;

export class UnauthorizedError extends Error {}
export class ForbiddenError extends Error {}

export async function requirePermission(permission: Permission) {
  const session = await auth();

  if (!session?.user) {
    throw new UnauthorizedError(&quot;You must be signed in.&quot;);
  }

  if (!hasPermission(session.user.role, permission)) {
    throw new ForbiddenError(&quot;You don't have permission to do that.&quot;);
  }

  return session.user; // callers get a typed, guaranteed-authorized user back
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;requirePermission()&lt;/code&gt; checks permissions (&lt;code&gt;&quot;delete_post&quot;&lt;/code&gt;), not roles directly — this keeps every call site decoupled from &lt;em&gt;which&lt;/em&gt; roles can do what, so changing that mapping later only touches &lt;code&gt;roles.ts&lt;/code&gt;. Two distinct errors (&lt;code&gt;Unauthorized&lt;/code&gt; vs &lt;code&gt;Forbidden&lt;/code&gt;) let callers respond differently — a 401 (not logged in) is a different problem than a 403 (logged in, not allowed).&lt;/p&gt;
&lt;h4 id=&quot;using-it-in-a-server-action&quot;&gt;Using It in a Server Action&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// actions/posts.ts
&quot;use server&quot;;

import { requirePermission, ForbiddenError, UnauthorizedError } from &quot;@/lib/auth/require-role&quot;;
import { revalidatePath } from &quot;next/cache&quot;;

export async function deletePost(postId: string) {
  try {
    await requirePermission(&quot;delete_post&quot;);
  } catch (err) {
    if (err instanceof UnauthorizedError) return { error: &quot;Please sign in.&quot; };
    if (err instanceof ForbiddenError) return { error: &quot;You can't delete posts.&quot; };
    throw err;
  }

  // await db.post.delete({ where: { id: postId } });
  revalidatePath(&quot;/dashboard&quot;);
  return { success: true };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Only checking permissions in &lt;code&gt;proxy.ts&lt;/code&gt; and assuming Server Actions are automatically covered. They aren't — a Server Action is a real network endpoint under the hood, callable directly, and needs its own &lt;code&gt;requirePermission()&lt;/code&gt; call every single time it touches something sensitive.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;using-it-in-a-route-handler&quot;&gt;Using It in a Route Handler&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// app/api/users/[id]/route.ts
import { NextResponse } from &quot;next/server&quot;;
import { requirePermission, ForbiddenError, UnauthorizedError } from &quot;@/lib/auth/require-role&quot;;

export async function DELETE(req: Request, { params }: { params: Promise&amp;lt;{ id: string }&amp;gt; }) {
  try {
    await requirePermission(&quot;manage_users&quot;);
  } catch (err) {
    if (err instanceof UnauthorizedError) {
      return NextResponse.json({ error: &quot;Unauthorized&quot; }, { status: 401 });
    }
    if (err instanceof ForbiddenError) {
      return NextResponse.json({ error: &quot;Forbidden&quot; }, { status: 403 });
    }
    throw err;
  }

  const { id } = await params;
  // await db.user.delete({ where: { id } });
  return NextResponse.json({ success: true });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Same pattern, correct HTTP status codes this time — &lt;code&gt;401&lt;/code&gt; for &quot;log in first,&quot; &lt;code&gt;403&lt;/code&gt; for &quot;you're logged in, but no.&quot;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-6-conditionally-show-ui-with-a-lt-can-gt-component&quot;&gt;Step 6: Conditionally Show UI with a &lt;code&gt;&amp;lt;Can&amp;gt;&lt;/code&gt; Component&lt;/h3&gt;
&lt;p&gt;Server-side checks are the real security boundary, but users still need a UI that doesn't dangle buttons they can't actually use. A small &lt;code&gt;&amp;lt;Can&amp;gt;&lt;/code&gt; component keeps this readable instead of &lt;code&gt;{role === &quot;admin&quot; &amp;amp;&amp;amp; ...}&lt;/code&gt; scattered through every page.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// components/can.tsx
import type { Permission } from &quot;@/lib/auth/roles&quot;;
import { hasPermission } from &quot;@/lib/auth/roles&quot;;
import { auth } from &quot;@/lib/auth&quot;;

interface CanProps {
  permission: Permission;
  children: React.ReactNode;
  fallback?: React.ReactNode;
}

export async function Can({ permission, children, fallback = null }: CanProps) {
  const session = await auth();

  if (!session?.user || !hasPermission(session.user.role, permission)) {
    return &amp;lt;&amp;gt;{fallback}&amp;lt;/&amp;gt;;
  }

  return &amp;lt;&amp;gt;{children}&amp;lt;/&amp;gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/dashboard/page.tsx
import { Can } from &quot;@/components/can&quot;;
import Link from 'next/link';

export default function DashboardPage() {
  return (
    &amp;lt;div className=&quot;space-y-4&quot;&amp;gt;
      &amp;lt;h1 className=&quot;text-2xl font-semibold&quot;&amp;gt;Dashboard&amp;lt;/h1&amp;gt;

      &amp;lt;Can permission=&quot;create_post&quot;&amp;gt;
        &amp;lt;button className=&quot;rounded-lg bg-primary px-4 py-2 text-white&quot;&amp;gt;
          New Post
        &amp;lt;/button&amp;gt;
      &amp;lt;/Can&amp;gt;

      &amp;lt;Can permission=&quot;manage_users&quot; fallback={&amp;lt;p className=&quot;text-sm text-muted-foreground&quot;&amp;gt;Admins only.&amp;lt;/p&amp;gt;}&amp;gt;
        &amp;lt;Link href=&quot;/admin/users&quot; className=&quot;underline&quot;&amp;gt;Manage Users&amp;lt;/Link&amp;gt;
      &amp;lt;/Can&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;Can&lt;/code&gt; is an async Server Component — it calls &lt;code&gt;auth()&lt;/code&gt; directly, no client-side loading flicker, no &lt;code&gt;useEffect&lt;/code&gt;. Wrap anything role-gated in it, optionally supply a &lt;code&gt;fallback&lt;/code&gt; for what non-permitted users see instead (or leave it out to render nothing at all).&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; Because &lt;code&gt;Can&lt;/code&gt; reads the current session on the server, use it in parts of the tree where request-time authentication data is expected.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; This component controls &lt;em&gt;visibility&lt;/em&gt;, not &lt;em&gt;security&lt;/em&gt;. Someone could still call the underlying Server Action with dev tools open — that's exactly why Step 5's &lt;code&gt;requirePermission()&lt;/code&gt; exists as the real gate. &lt;code&gt;&amp;lt;Can&amp;gt;&lt;/code&gt; is UX, &lt;code&gt;requirePermission()&lt;/code&gt; is the lock.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-7-filter-data-at-the-query-level-too&quot;&gt;Step 7: Filter Data at the Query Level Too&lt;/h3&gt;
&lt;p&gt;There's a fourth layer worth calling out separately: what data a query even returns. A &lt;code&gt;viewer&lt;/code&gt; shouldn't just be blocked from an edit &lt;em&gt;button&lt;/em&gt; — if your query returns draft posts by default, make sure a viewer's query never fetches drafts in the first place, rather than fetching everything and hiding it in the UI.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/data/posts.ts
import { auth } from &quot;@/lib/auth&quot;;
import { UnauthorizedError } from &quot;../auth/require-role&quot;;

export async function getVisiblePosts() {
  const session = await auth();
  const role = session?.user?.role;

  if (!session?.user) {
    throw new UnauthorizedError(&quot;You must be signed in.&quot;);
  }

  if (role === &quot;admin&quot; || role === &quot;editor&quot;) {
    // return await db.post.findMany(); // everything, including drafts
  }

  // return await db.post.findMany({ where: { status: &quot;published&quot; } }); // viewers only see published
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This matters because a UI-only check can miss cases like server-rendered pages, RSS feeds, or an API response that isn't wired through the component you added &lt;code&gt;&amp;lt;Can&amp;gt;&lt;/code&gt; to.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;frequently-asked-questions&quot;&gt;Frequently Asked Questions&lt;/h3&gt;
&lt;h4 id=&quot;what-s-the-difference-between-authentication-and-authorization-&quot;&gt;What's the difference between authentication and authorization?&lt;/h4&gt;
&lt;p&gt;Authentication confirms &lt;em&gt;who&lt;/em&gt; someone is (logging in). Authorization decides &lt;em&gt;what&lt;/em&gt; they're allowed to do once they're recognized (roles and permissions). You need both — Auth.js/Clerk handle authentication, and everything in this guide is the authorization layer on top.&lt;/p&gt;
&lt;h4 id=&quot;should-i-store-roles-as-a-string-or-a-separate-roles-table-&quot;&gt;Should I store roles as a string or a separate roles table?&lt;/h4&gt;
&lt;p&gt;A single &lt;code&gt;role&lt;/code&gt; string column (like this guide uses) is fine for simple apps with a handful of fixed roles. If you need multiple roles per user, per-organization roles, or roles that admins can create dynamically, move to a proper &lt;code&gt;roles&lt;/code&gt; and &lt;code&gt;user_roles&lt;/code&gt; join table instead — the &lt;code&gt;hasPermission()&lt;/code&gt; pattern still works the same way on top of either.&lt;/p&gt;
&lt;h4 id=&quot;is-checking-roles-in-proxy-ts-enough-security-by-itself-&quot;&gt;Is checking roles in &lt;code&gt;proxy.ts&lt;/code&gt; enough security by itself?&lt;/h4&gt;
&lt;p&gt;No. Treat &lt;code&gt;proxy.ts&lt;/code&gt; as a fast UX redirect, not your security boundary — it can be bypassed by hitting a Server Action or API route directly. Always re-check permissions inside the Server Action or Route Handler itself, as shown in Step 5.&lt;/p&gt;
&lt;h4 id=&quot;how-do-i-handle-a-role-change-without-forcing-the-user-to-log-out-&quot;&gt;How do I handle a role change without forcing the user to log out?&lt;/h4&gt;
&lt;p&gt;With JWT sessions, the user value isn't available on every JWT callback invocation, so simply changing the role in your database doesn't automatically replace the role already stored in an existing token. Either switch to &lt;code&gt;strategy: &quot;database&quot;&lt;/code&gt; sessions (Auth.js re-reads the DB on every request), or add an &lt;code&gt;update()&lt;/code&gt; trigger that refreshes the token when an admin changes someone's role.&lt;/p&gt;
&lt;h4 id=&quot;can-i-have-more-granular-permissions-than-just-three-roles-&quot;&gt;Can I have more granular permissions than just three roles?&lt;/h4&gt;
&lt;p&gt;Yes — that's exactly why Step 1 separates &lt;code&gt;PERMISSIONS&lt;/code&gt; from &lt;code&gt;ROLES&lt;/code&gt;. Add a new permission string to whichever roles should have it, and every &lt;code&gt;requirePermission()&lt;/code&gt; call automatically respects the new mapping without touching the rest of your codebase.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;doing-this-with-clerk-instead&quot;&gt;Doing This with Clerk Instead&lt;/h2&gt;
&lt;p&gt;If you're using &lt;a rel=&quot;nofollow&quot; href=&quot;https://clerk.com/&quot;&gt;Clerk&lt;/a&gt; instead of Auth.js, the RBAC architecture stays the same. Only the way you retrieve the user's role changes.&lt;/p&gt;
&lt;p&gt;For a simple app, store the role in Clerk's &lt;code&gt;publicMetadata&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;{
  role: &quot;editor&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Set it server-side or through the Clerk Dashboard. &lt;strong&gt;Never let users submit their own role during signup.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Expose the metadata through the Clerk session token. In &lt;strong&gt;Clerk Dashboard → Sessions → Customize session token&lt;/strong&gt;, add:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;metadata&quot;: &quot;{{user.public_metadata}}&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then read the role with &lt;code&gt;auth()&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { auth } from &quot;@clerk/nextjs/server&quot;;
import type { Role } from &quot;@/lib/auth/roles&quot;;

const { isAuthenticated, sessionClaims } = await auth();

if (!isAuthenticated) {
  // Not signed in
}

const role = sessionClaims?.metadata?.role as Role | undefined;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;From here, the same &lt;code&gt;hasPermission()&lt;/code&gt;, &lt;code&gt;requirePermission()&lt;/code&gt;, and &lt;code&gt;&amp;lt;Can&amp;gt;&lt;/code&gt; pattern from this guide still applies. Your authorization layer doesn't need to care whether the role came from Auth.js or Clerk.&lt;/p&gt;
&lt;p&gt;Role changes may take a short time to appear in an existing session token, so don't assume a metadata update is immediately reflected in &lt;code&gt;sessionClaims&lt;/code&gt;.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; If you're building a multi-tenant SaaS with teams or organizations, consider Clerk's built-in &lt;strong&gt;Organizations, Roles, and Permissions&lt;/strong&gt; instead of storing organization roles in &lt;code&gt;publicMetadata&lt;/code&gt;. (&lt;a rel=&quot;nofollow&quot; href=&quot;https://clerk.com/docs/guides/organizations/control-access/roles-and-permissions&quot;&gt;Clerk Roles &amp;amp; Permissions&lt;/a&gt;)&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The important rule remains the same: &lt;strong&gt;UI checks improve UX; server-side permission checks provide security.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wrapping-up&quot;&gt;Wrapping Up&lt;/h3&gt;
&lt;p&gt;Role-based authorization isn't one big feature — it's four small, boring checks layered together: a protected route in &lt;code&gt;proxy.ts&lt;/code&gt;, a re-check inside every Server Action and Route Handler, a &lt;code&gt;&amp;lt;Can&amp;gt;&lt;/code&gt; wrapper for the UI, and a query that filters data before it ever leaves the server. Skip any one layer and you've got a gap; stack all four and a &quot;hidden&quot; admin button stops being a real vulnerability.&lt;/p&gt;
&lt;p&gt;From here, a natural next step is adding an admin UI for promoting/demoting users, or layering row-level security on top if you're using &lt;a rel=&quot;nofollow&quot; href=&quot;https://supabase.com/&quot;&gt;Supabase&lt;/a&gt; alongside this pattern for defense in depth at the database layer too.&lt;/p&gt;
&lt;p&gt;📦 &lt;strong&gt;Source Code:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Muhammad-Ali-sma/Role-base-Authorization-Nextjs-setup&quot;&gt;View on GitHub&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;continue-learning&quot;&gt;Continue Learning&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/how-to-add-nextauth-in-nextjs-16&quot;&gt;How to Add Auth.js (NextAuth v5) Authentication in Next.js 16 (2026)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/how-to-add-clerk-authentication-in-nextjs-16&quot;&gt;How to Add Clerk Authentication in Next.js 16 (2026 Guide)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/clerk-vs-nextauth-2026&quot;&gt;Clerk vs NextAuth (Auth.js) in 2026: Which One Should You Choose?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/nextjs-16-supabase-todo-app&quot;&gt;Build a Todo App with Next.js 16 and Supabase (2026 Guide)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/saas-starter-architecture-nextjs&quot;&gt;SaaS Starter Architecture with Next.js 2026&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;free-developer-tools&quot;&gt;Free Developer Tools&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/tools/rls-policy-generator&quot;&gt;Supabase RLS Policy Generator&lt;/a&gt; — pair this guide's application-level RBAC with database-level Row Level Security. Generates owner-only, team, and admin-override policies with the &lt;code&gt;auth.uid()&lt;/code&gt; performance wrapper built in.&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/tools/zod-schema-generator&quot;&gt;Zod Schema Generator&lt;/a&gt; — generate a validated schema for your role/permission payloads straight from a JSON sample or TypeScript interface, including a ready-to-use Server Action.&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/24809/role-based-access-control-rbac-in-next-js-16-2026-beginners-guide</guid>
<pubDate>Tue, 18 Aug 2026 03:49:46 +0000</pubDate>
</item>
</channel>
</rss>