RBAC in Next.js Server Components: Authorization
Role-based access control (RBAC) is the practice of granting permissions to users based on their assigned role. A user with the admin role can delete posts; a moderator can flag content; a regular user can only view and comment. In Next.js, RBAC is implemented at two levels: middleware (redirecting unauthorized requests) and Server Components (rendering role-specific content). Server Components are particularly powerful for RBAC because they run on the server, so role checks cannot be bypassed by client-side code—a malicious user cannot inspect console and fake their role.
Understanding Roles and Permissions
Roles are groups of permissions. Rather than assigning individual permissions to each user, you assign them a single role. Here is a typical role structure:
| Role | Permissions | Use Case |
|---|---|---|
user | View public posts, comment, edit own profile | Authenticated users |
moderator | View all posts, flag content, mute users | Community moderation |
admin | Delete anything, manage users, view logs | Site administration |
guest | View public posts only | Unauthenticated visitors |
This simplicity makes RBAC maintainable. If you need fine-grained control (some admins cannot delete users), use attribute-based access control (ABAC), but RBAC is sufficient for most applications.
Checking Roles in Server Components
Server Components receive user data from middleware via request headers. Extract the role and conditionally render content:
// app/admin/users/page.tsx
import { headers } from 'next/headers';
import { redirect } from 'next/navigation';
export default async function AdminUsersPage() {
const headersList = await headers();
const userRole = headersList.get('x-user-role');
const userId = headersList.get('x-user-id');
// Only admins can access this page
if (userRole !== 'admin') {
redirect('/unauthorized');
}
return (
<div>
<h1>User Management</h1>
<p>Welcome, admin {userId}. You can manage all users here.</p>
{/* Render admin-only content */}
</div>
);
}
If the user is not an admin, redirect() throws an error that Next.js catches, replacing the page with /unauthorized. This is server-side logic, so it cannot be bypassed by changing the browser's DOM or using DevTools.
Creating Reusable RBAC Utilities
For larger apps, extract role checking into utilities to avoid repeating the same logic in every component:
// lib/auth.ts
import { headers } from 'next/headers';
export interface User {
id: string;
role: 'guest' | 'user' | 'moderator' | 'admin';
}
export async function getCurrentUser(): Promise<User | null> {
const headersList = await headers();
const userId = headersList.get('x-user-id');
const userRole = headersList.get('x-user-role') as User['role'] | null;
if (!userId || !userRole) {
return null;
}
return { id: userId, role: userRole };
}
export async function requireRole(...allowedRoles: string[]): Promise<User> {
const user = await getCurrentUser();
if (!user || !allowedRoles.includes(user.role)) {
const { redirect } = await import('next/navigation');
redirect('/unauthorized');
}
return user;
}
export function hasRole(userRole: string | null, ...allowedRoles: string[]): boolean {
return userRole ? allowedRoles.includes(userRole) : false;
}
Now you can write cleaner, reusable components:
// app/admin/dashboard/page.tsx
import { requireRole } from '@/lib/auth';
export default async function AdminDashboard() {
const user = await requireRole('admin');
return (
<div>
<h1>Admin Dashboard</h1>
<p>Logged in as: {user.id}</p>
</div>
);
}
The requireRole() function handles role checking and redirection in one line, making your code DRY and easier to test.
Conditional Content Rendering
For pages that serve multiple roles with different content, conditionally render sections based on the user's role:
// app/posts/page.tsx
import { getCurrentUser } from '@/lib/auth';
import DeletePostButton from '@/components/DeletePostButton';
interface Post {
id: string;
title: string;
author: string;
}
async function getPosts(): Promise<Post[]> {
// Fetch posts from database
return [];
}
export default async function PostsPage() {
const user = await getCurrentUser();
const posts = await getPosts();
return (
<div>
<h1>Posts</h1>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>By {post.author}</p>
{/* Show delete button only to admins and moderators */}
{user && ['admin', 'moderator'].includes(user.role) && (
<DeletePostButton postId={post.id} />
)}
</article>
))}
</div>
);
}
This pattern is safe: the role check happens on the server, so even if a user manually adds the delete button to their DOM, the /api/posts/{id} DELETE endpoint will still reject the request because the server-side check fails.
Protecting API Routes with RBAC
API routes must also check roles before performing sensitive actions. Here is a DELETE endpoint that only admins can use:
// app/api/posts/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { headers } from 'next/headers';
export async function DELETE(
req: NextRequest,
{ params }: { params: { id: string } }
) {
const headersList = await headers();
const userRole = headersList.get('x-user-role');
// Only admins can delete posts
if (userRole !== 'admin') {
return NextResponse.json(
{ error: 'Only admins can delete posts' },
{ status: 403 }
);
}
// Delete the post
const { id } = params;
// TODO: Delete from database
return NextResponse.json({ success: true, message: `Post ${id} deleted` });
}
Middleware enforces initial authentication; route handlers enforce authorization (role checks). This two-layer approach is more secure because even if middleware is misconfigured, individual routes still protect sensitive operations.
Logging Role-Based Actions
For security audits, log when users perform role-based actions:
// lib/audit.ts
import { getRedis } from './redis';
export async function logAction(
userId: string,
action: string,
resource: string,
allowed: boolean
) {
const timestamp = new Date().toISOString();
const logEntry = {
timestamp,
userId,
action,
resource,
allowed,
};
// Log to a database or external service for audit trails
console.log('[AUDIT]', JSON.stringify(logEntry));
// Optionally store in Redis for real-time monitoring
const redis = await getRedis();
await redis.lPush(`audit-logs:${userId}`, JSON.stringify(logEntry));
}
Call this function in API routes to create an audit trail:
// app/api/users/[id]/delete/route.ts
import { logAction } from '@/lib/audit';
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
const userRole = headersList.get('x-user-role');
const userId = headersList.get('x-user-id');
const allowed = userRole === 'admin';
await logAction(userId!, 'delete', `user/${params.id}`, allowed);
if (!allowed) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Delete user
return NextResponse.json({ success: true });
}
Key Takeaways
- RBAC groups permissions into roles; users are assigned a single role instead of individual permissions.
- Use middleware for broad authentication (is the user logged in?); use Server Components and API routes for RBAC (does the user have this role?).
- Server Components are safe for authorization checks because role logic runs on the server, not the client.
- Create reusable utility functions like
requireRole()andgetCurrentUser()to avoid repeating role checks. - Always validate roles in API routes, even if middleware enforces them; never trust client-side role claims.
- Log role-based actions (especially denials) for security audits and incident investigation.
Frequently Asked Questions
What if a user needs multiple roles?
Use an array instead of a string: x-user-roles: admin,moderator. Then update requireRole() to check if the array includes any allowed role. Alternatively, compute a combined permission set from all assigned roles in the database.
Can I check roles in Client Components?
Yes, but only for UI hints (showing or hiding buttons). Never rely on client-side role checks for security. Always validate on the server. A malicious user can edit their client-side role using DevTools, bypassing UI checks.
How do I implement permissions more granular than roles?
Use attribute-based access control (ABAC): instead of checking userRole === 'admin', check specific permissions like user.permissions.includes('delete.post'). This requires storing a permission set (array or bitmask) per user, which is more complex but more flexible than RBAC.
Can I change a user's role without logging them out?
Yes, but they will need to make another request for the new role to take effect. When you update the database, the session stays valid, but the old role is still in the middleware headers. Either refresh the browser, or implement a re-fetch of user data in the client.