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.