Skip to main content
Back to Blog
Tutorials
3 min read
November 12, 2024

How to Implement Role-Based Access Control in Next.js

Add role-based access control to your Next.js app. Protect routes, restrict UI elements, and validate permissions on the server.

Ryel Banfield

Founder & Lead Developer

Role-based access control (RBAC) restricts what users can see and do based on their assigned role. Here is how to implement it in a Next.js application.

Step 1: Define Roles and Permissions

// lib/permissions.ts
export const ROLES = {
  ADMIN: "admin",
  EDITOR: "editor",
  VIEWER: "viewer",
} as const;

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

export const PERMISSIONS = {
  // Content
  "content:read": [ROLES.ADMIN, ROLES.EDITOR, ROLES.VIEWER],
  "content:create": [ROLES.ADMIN, ROLES.EDITOR],
  "content:update": [ROLES.ADMIN, ROLES.EDITOR],
  "content:delete": [ROLES.ADMIN],

  // Users
  "users:read": [ROLES.ADMIN],
  "users:manage": [ROLES.ADMIN],

  // Settings
  "settings:read": [ROLES.ADMIN, ROLES.EDITOR],
  "settings:update": [ROLES.ADMIN],

  // Analytics
  "analytics:read": [ROLES.ADMIN, ROLES.EDITOR],
} as const;

export type Permission = keyof typeof PERMISSIONS;

export function hasPermission(role: Role, permission: Permission): boolean {
  return PERMISSIONS[permission].includes(role);
}

export function hasAnyPermission(role: Role, permissions: Permission[]): boolean {
  return permissions.some((p) => hasPermission(role, p));
}

Step 2: Get the User's Role

This depends on your auth provider. Here is an example with a session:

// lib/auth.ts
import { cookies } from "next/headers";
import { type Role } from "./permissions";

type User = {
  id: string;
  name: string;
  email: string;
  role: Role;
};

export async function getCurrentUser(): Promise<User | null> {
  const cookieStore = await cookies();
  const sessionToken = cookieStore.get("session")?.value;
  if (!sessionToken) return null;

  // Verify the session token and get user data
  // Replace with your auth provider's logic
  const user = await verifySession(sessionToken);
  return user;
}

export async function requireUser(): Promise<User> {
  const user = await getCurrentUser();
  if (!user) {
    throw new Error("Unauthorized");
  }
  return user;
}

export async function requireRole(role: Role): Promise<User> {
  const user = await requireUser();
  if (user.role !== role) {
    throw new Error("Forbidden");
  }
  return user;
}

export async function requirePermission(permission: Permission): Promise<User> {
  const user = await requireUser();
  if (!hasPermission(user.role, permission)) {
    throw new Error("Forbidden");
  }
  return user;
}

Step 3: Protect Server Components

// app/dashboard/users/page.tsx
import { requirePermission } from "@/lib/auth";
import { redirect } from "next/navigation";

export default async function UsersPage() {
  try {
    await requirePermission("users:read");
  } catch {
    redirect("/dashboard");
  }

  return (
    <div>
      <h1 className="text-2xl font-bold">User Management</h1>
      {/* User list */}
    </div>
  );
}

Step 4: Protect with Middleware

// middleware.ts
import { NextRequest, NextResponse } from "next/server";

const roleRoutes: Record<string, string[]> = {
  "/dashboard/users": ["admin"],
  "/dashboard/settings": ["admin", "editor"],
  "/dashboard/analytics": ["admin", "editor"],
};

export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // Check if route requires specific roles
  for (const [route, allowedRoles] of Object.entries(roleRoutes)) {
    if (pathname.startsWith(route)) {
      const session = request.cookies.get("session")?.value;
      if (!session) {
        return NextResponse.redirect(new URL("/login", request.url));
      }

      // Decode the session to get the role
      // In production, verify the token properly
      const userRole = await getRoleFromSession(session);

      if (!allowedRoles.includes(userRole)) {
        return NextResponse.redirect(new URL("/dashboard", request.url));
      }
    }
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/dashboard/:path*"],
};

Step 5: Protect Server Actions

// app/actions.ts
"use server";

import { requirePermission } from "@/lib/auth";
import { revalidatePath } from "next/cache";

export async function createPost(formData: FormData) {
  const user = await requirePermission("content:create");

  const title = formData.get("title") as string;
  const content = formData.get("content") as string;

  await db.insert(posts).values({
    title,
    content,
    authorId: user.id,
  });

  revalidatePath("/dashboard/posts");
}

export async function deletePost(postId: string) {
  await requirePermission("content:delete");

  await db.delete(posts).where(eq(posts.id, postId));
  revalidatePath("/dashboard/posts");
}

Step 6: Protect API Routes

// app/api/users/route.ts
import { requirePermission } from "@/lib/auth";
import { NextResponse } from "next/server";

export async function GET() {
  try {
    await requirePermission("users:read");
  } catch {
    return NextResponse.json({ error: "Forbidden" }, { status: 403 });
  }

  const users = await db.query.users.findMany();
  return NextResponse.json(users);
}

Step 7: Conditionally Render UI

Server-Side

import { getCurrentUser } from "@/lib/auth";
import { hasPermission } from "@/lib/permissions";

export default async function DashboardNav() {
  const user = await getCurrentUser();
  if (!user) return null;

  return (
    <nav>
      <a href="/dashboard">Dashboard</a>
      <a href="/dashboard/posts">Posts</a>

      {hasPermission(user.role, "analytics:read") && (
        <a href="/dashboard/analytics">Analytics</a>
      )}

      {hasPermission(user.role, "users:manage") && (
        <a href="/dashboard/users">Users</a>
      )}

      {hasPermission(user.role, "settings:update") && (
        <a href="/dashboard/settings">Settings</a>
      )}
    </nav>
  );
}

Client-Side

"use client";

import { useUser } from "@/hooks/use-user";
import { hasPermission } from "@/lib/permissions";

export function DeleteButton({ postId }: { postId: string }) {
  const { user } = useUser();

  if (!user || !hasPermission(user.role, "content:delete")) {
    return null;
  }

  return (
    <button
      onClick={() => deletePost(postId)}
      className="text-sm text-red-600 hover:text-red-700"
    >
      Delete
    </button>
  );
}

Step 8: Higher-Order Component for Protection

import { getCurrentUser } from "@/lib/auth";
import { hasPermission, type Permission } from "@/lib/permissions";
import { redirect } from "next/navigation";

export function withPermission(permission: Permission) {
  return function ProtectedPage<P extends object>(
    Component: React.ComponentType<P & { user: User }>
  ) {
    return async function Protected(props: P) {
      const user = await getCurrentUser();

      if (!user) {
        redirect("/login");
      }

      if (!hasPermission(user.role, permission)) {
        redirect("/dashboard");
      }

      return <Component {...props} user={user} />;
    };
  };
}

Security Best Practices

  • Always check permissions on the server (never trust client-side checks alone)
  • Use middleware for route-level protection
  • Validate permissions in server actions before mutating data
  • Client-side checks are only for UX (hiding buttons), not security
  • Log access attempts for auditing
  • Use the principle of least privilege (start with minimal permissions)

Need a Secure Web Application?

We build web applications with robust authentication and authorization. Contact us to discuss your security requirements.

RBACauthenticationauthorizationNext.jstutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles