Implement Role-Based Access Control with Next.js and NestJS
Learn how to implement role-based access control across a full-stack app with NestJS guards, permission decorators, Next.js middleware, and role-aware UI.
Tags
Implement Role-Based Access Control with Next.js and NestJS
In this tutorial, you will implement a complete role-based access control system across a full-stack application using NestJS for the backend and Next.js for the frontend. You will define roles and permissions, create NestJS guards with custom decorators, protect frontend routes with Next.js middleware, and build a role-aware UI that shows or hides elements based on the user's permissions. The result is a security model that is enforced on the server and reflected in the user interface.
TL;DR
Define roles and permissions in a shared TypeScript file. On the backend, use NestJS custom decorators and guards to enforce permissions at the endpoint level. On the frontend, use Next.js middleware to redirect unauthorized users and conditional rendering to hide restricted UI elements. Always enforce authorization on the server — frontend checks are for UX only.
Prerequisites
- ›A NestJS backend with JWT authentication already implemented
- ›A Next.js frontend that communicates with the NestJS backend
- ›Basic understanding of JWT tokens and authentication flows
- ›TypeScript knowledge for both frameworks
Step 1: Define Roles and Permissions
Start by creating a shared contract that both your frontend and backend can reference. This is a plain TypeScript file that defines your roles, permissions, and the mapping between them.
// shared/types/rbac.ts
export enum Role {
ADMIN = "admin",
EDITOR = "editor",
VIEWER = "viewer",
}
export enum Permission {
// Article permissions
ARTICLES_CREATE = "articles:create",
ARTICLES_READ = "articles:read",
ARTICLES_UPDATE = "articles:update",
ARTICLES_DELETE = "articles:delete",
ARTICLES_PUBLISH = "articles:publish",
// User management permissions
USERS_READ = "users:read",
USERS_MANAGE = "users:manage",
// Settings permissions
SETTINGS_MANAGE = "settings:manage",
}
export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
[Role.ADMIN]: Object.values(Permission), // Admin has all permissions
[Role.EDITOR]: [
Permission.ARTICLES_CREATE,
Permission.ARTICLES_READ,
Permission.ARTICLES_UPDATE,
Permission.ARTICLES_PUBLISH,
Permission.USERS_READ,
],
[Role.VIEWER]: [
Permission.ARTICLES_READ,
Permission.USERS_READ,
],
};
export function hasPermission(role: Role, permission: Permission): boolean {
return ROLE_PERMISSIONS[role]?.includes(permission) ?? false;
}
export function hasAnyPermission(
role: Role,
permissions: Permission[]
): boolean {
return permissions.some((permission) => hasPermission(role, permission));
}This pattern has several advantages. Permissions are granular (action-level), roles are groupings of permissions, and the mapping is centralized. When you need to adjust what an editor can do, you change one object. The hasPermission function is the single point of truth for authorization checks.
Step 2: Include the Role in JWT Tokens
When a user authenticates, include their role in the JWT payload. Update your NestJS auth service:
// src/auth/auth.service.ts
import { Injectable } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import { UsersService } from "../users/users.service";
@Injectable()
export class AuthService {
constructor(
private jwtService: JwtService,
private usersService: UsersService
) {}
async login(userId: number) {
const user = await this.usersService.findOne(userId);
const payload = {
sub: user.id,
email: user.email,
role: user.role, // Include the role in the token
};
return {
accessToken: this.jwtService.sign(payload),
user: {
id: user.id,
email: user.email,
role: user.role,
},
};
}
}Update your JWT strategy to extract the role:
// src/auth/strategies/jwt.strategy.ts
import { Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
import { ConfigService } from "@nestjs/config";
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: configService.get<string>("JWT_SECRET"),
});
}
async validate(payload: { sub: number; email: string; role: string }) {
return {
id: payload.sub,
email: payload.email,
role: payload.role,
};
}
}Now every authenticated request has the user's role available on request.user.role.
Step 3: Create the Permissions Decorator
NestJS custom decorators let you annotate controller methods with metadata. Create a decorator that specifies which permissions a route requires:
// src/auth/decorators/permissions.decorator.ts
import { SetMetadata } from "@nestjs/common";
import { Permission } from "../../../shared/types/rbac";
export const PERMISSIONS_KEY = "permissions";
export const RequirePermissions = (...permissions: Permission[]) =>
SetMetadata(PERMISSIONS_KEY, permissions);This decorator attaches an array of required permissions to the route handler's metadata, which the guard will read.
Step 4: Build the Permissions Guard
The guard reads the required permissions from metadata, gets the user's role from the request, and checks whether the role has the necessary permissions:
// src/auth/guards/permissions.guard.ts
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Permission, hasPermission, Role } from "../../../shared/types/rbac";
import { PERMISSIONS_KEY } from "../decorators/permissions.decorator";
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredPermissions = this.reflector.getAllAndOverride<Permission[]>(
PERMISSIONS_KEY,
[context.getHandler(), context.getClass()]
);
// If no permissions are specified, allow access
if (!requiredPermissions || requiredPermissions.length === 0) {
return true;
}
const { user } = context.switchToHttp().getRequest();
if (!user || !user.role) {
throw new ForbiddenException("No role assigned to user");
}
const userRole = user.role as Role;
const hasAllPermissions = requiredPermissions.every((permission) =>
hasPermission(userRole, permission)
);
if (!hasAllPermissions) {
throw new ForbiddenException(
"You do not have permission to perform this action"
);
}
return true;
}
}The Reflector service reads metadata set by decorators. The guard checks that the user's role has every required permission. If any permission is missing, it throws a ForbiddenException which NestJS converts to a 403 HTTP response.
Step 5: Apply Guards to Controller Endpoints
Use the decorator and guard together on your controller methods:
// src/articles/articles.controller.ts
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
ParseIntPipe,
UseGuards,
} from "@nestjs/common";
import { JwtAuthGuard } from "../auth/guards/jwt-auth.guard";
import { PermissionsGuard } from "../auth/guards/permissions.guard";
import { RequirePermissions } from "../auth/decorators/permissions.decorator";
import { Permission } from "../../shared/types/rbac";
import { ArticlesService } from "./articles.service";
import { CreateArticleDto } from "./dto/create-article.dto";
import { UpdateArticleDto } from "./dto/update-article.dto";
@Controller("articles")
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class ArticlesController {
constructor(private readonly articlesService: ArticlesService) {}
@Get()
@RequirePermissions(Permission.ARTICLES_READ)
findAll() {
return this.articlesService.findAll();
}
@Post()
@RequirePermissions(Permission.ARTICLES_CREATE)
create(@Body() createArticleDto: CreateArticleDto) {
return this.articlesService.create(createArticleDto);
}
@Patch(":id")
@RequirePermissions(Permission.ARTICLES_UPDATE)
update(
@Param("id", ParseIntPipe) id: number,
@Body() updateArticleDto: UpdateArticleDto
) {
return this.articlesService.update(id, updateArticleDto);
}
@Patch(":id/publish")
@RequirePermissions(Permission.ARTICLES_PUBLISH)
publish(@Param("id", ParseIntPipe) id: number) {
return this.articlesService.publish(id);
}
@Delete(":id")
@RequirePermissions(Permission.ARTICLES_DELETE)
remove(@Param("id", ParseIntPipe) id: number) {
return this.articlesService.remove(id);
}
}The @UseGuards(JwtAuthGuard, PermissionsGuard) decorator at the class level ensures both guards run for every endpoint. The JwtAuthGuard runs first to verify the token and populate request.user. Then the PermissionsGuard checks the user's role against the required permissions.
An editor can create, read, update, and publish articles, but cannot delete them. A viewer can only read. An admin can do everything. These rules are enforced entirely through the role-permission mapping defined in Step 1.
Step 6: Protect Frontend Routes with Next.js Middleware
On the frontend, middleware checks the user's role before rendering protected pages. This prevents unauthorized users from seeing admin pages, but remember: this is a UX improvement, not a security boundary.
// src/middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { jwtVerify } from "jose";
import { Role } from "../shared/types/rbac";
const PROTECTED_ROUTES: Record<string, Role[]> = {
"/admin": [Role.ADMIN],
"/dashboard/articles/new": [Role.ADMIN, Role.EDITOR],
"/dashboard/articles/edit": [Role.ADMIN, Role.EDITOR],
"/dashboard/settings": [Role.ADMIN],
"/dashboard/users": [Role.ADMIN],
};
export async function middleware(request: NextRequest) {
const token = request.cookies.get("accessToken")?.value;
// Check if the current path requires role-based protection
const matchedRoute = Object.entries(PROTECTED_ROUTES).find(([path]) =>
request.nextUrl.pathname.startsWith(path)
);
if (!matchedRoute) {
return NextResponse.next();
}
const [, allowedRoles] = matchedRoute;
if (!token) {
return NextResponse.redirect(new URL("/sign-in", request.url));
}
try {
const secret = new TextEncoder().encode(process.env.JWT_SECRET!);
const { payload } = await jwtVerify(token, secret);
const userRole = payload.role as Role;
if (!allowedRoles.includes(userRole)) {
return NextResponse.redirect(new URL("/unauthorized", request.url));
}
return NextResponse.next();
} catch {
return NextResponse.redirect(new URL("/sign-in", request.url));
}
}
export const config = {
matcher: ["/admin/:path*", "/dashboard/:path*"],
};The middleware decodes the JWT token, extracts the user's role, and checks it against the allowed roles for the requested path. If the role does not match, the user is redirected to an unauthorized page. If the token is invalid or expired, they are redirected to sign in.
Step 7: Build Role-Aware UI Components
Create a context and hook that makes the user's role available throughout the frontend:
// src/contexts/AuthContext.tsx
"use client";
import { createContext, useContext, ReactNode } from "react";
import { Role, Permission, hasPermission } from "../../shared/types/rbac";
interface AuthContextType {
user: {
id: number;
email: string;
role: Role;
} | null;
can: (permission: Permission) => boolean;
}
const AuthContext = createContext<AuthContextType>({
user: null,
can: () => false,
});
export function AuthProvider({
children,
user,
}: {
children: ReactNode;
user: AuthContextType["user"];
}) {
const can = (permission: Permission) => {
if (!user) return false;
return hasPermission(user.role, permission);
};
return (
<AuthContext.Provider value={{ user, can }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
return useContext(AuthContext);
}Now use the can function to conditionally render UI elements:
// src/components/ArticleActions.tsx
"use client";
import { useAuth } from "@/contexts/AuthContext";
import { Permission } from "../../shared/types/rbac";
interface ArticleActionsProps {
articleId: number;
onEdit: () => void;
onDelete: () => void;
onPublish: () => void;
}
export default function ArticleActions({
articleId,
onEdit,
onDelete,
onPublish,
}: ArticleActionsProps) {
const { can } = useAuth();
return (
<div className="flex gap-2">
{can(Permission.ARTICLES_UPDATE) && (
<button
onClick={onEdit}
className="px-3 py-1 bg-blue-100 text-blue-700 rounded hover:bg-blue-200"
>
Edit
</button>
)}
{can(Permission.ARTICLES_PUBLISH) && (
<button
onClick={onPublish}
className="px-3 py-1 bg-green-100 text-green-700 rounded hover:bg-green-200"
>
Publish
</button>
)}
{can(Permission.ARTICLES_DELETE) && (
<button
onClick={onDelete}
className="px-3 py-1 bg-red-100 text-red-700 rounded hover:bg-red-200"
>
Delete
</button>
)}
</div>
);
}A viewer sees no action buttons. An editor sees Edit and Publish. An admin sees Edit, Publish, and Delete. The UI adapts automatically based on the user's permissions, and the backend independently enforces the same rules.
Step 8: Build a Navigation Guard Component
Create a reusable component that wraps content requiring specific permissions:
// src/components/PermissionGate.tsx
"use client";
import { useAuth } from "@/contexts/AuthContext";
import { Permission } from "../../shared/types/rbac";
import { ReactNode } from "react";
interface PermissionGateProps {
children: ReactNode;
permission: Permission;
fallback?: ReactNode;
}
export default function PermissionGate({
children,
permission,
fallback = null,
}: PermissionGateProps) {
const { can } = useAuth();
if (!can(permission)) {
return <>{fallback}</>;
}
return <>{children}</>;
}Use it anywhere in your application:
<PermissionGate
permission={Permission.SETTINGS_MANAGE}
fallback={<p>You do not have permission to view settings.</p>}
>
<SettingsPanel />
</PermissionGate>This component pattern keeps authorization checks declarative and readable throughout your codebase.
The Complete RBAC Architecture
Here is how all the pieces work together:
- ›Roles and permissions are defined in a shared TypeScript file used by both frontend and backend
- ›The JWT token includes the user's role, set at login time
- ›NestJS guards read the role from the token and check it against endpoint permission requirements
- ›Next.js middleware reads the role from the token and redirects unauthorized users away from protected pages
- ›The AuthContext provides a
can()function for conditional rendering in React components - ›The backend is the security boundary — the frontend provides a better user experience
Next Steps
- ›Dynamic roles: Store roles and permissions in the database instead of code for runtime configuration
- ›Resource-level authorization: Check ownership (e.g., only the author can edit their own articles)
- ›Audit logging: Log all authorization decisions for security monitoring
- ›Role hierarchy: Implement inheritance so admin automatically includes all editor permissions
- ›API key permissions: Extend the system to support machine-to-machine API keys with their own permission sets
FAQ
What is role-based access control (RBAC)?
RBAC is a security model where access to resources is determined by a user's assigned role rather than their individual identity. Common roles include admin, editor, and viewer. Each role has a set of permissions that define what actions the user can perform. RBAC simplifies permission management because you assign roles to users rather than managing individual permissions for each user.
How do NestJS guards work for authorization?
NestJS guards are classes that implement the CanActivate interface and run before a route handler executes. They have access to the execution context, which includes the request object and route metadata. For RBAC, a guard reads the required roles from decorator metadata, checks the authenticated user's role against the requirements, and either allows or blocks the request.
Should authorization be enforced on the frontend or backend?
Authorization must always be enforced on the backend because frontend checks can be bypassed. Frontend authorization (middleware, conditional rendering) improves user experience by preventing users from seeing pages or actions they cannot use, but it is not a security boundary. Every API endpoint must independently verify that the requesting user has the required permissions.
How do you handle permissions that are more granular than roles?
For granular permissions, map each role to a set of specific permissions like "articles:create", "articles:delete", "users:manage". Your guard checks whether the user's role includes the required permission rather than checking the role directly. This approach lets you add fine-grained permissions without changing the role structure, and you can combine multiple permissions for a single endpoint.
How do you pass user roles from NestJS to Next.js?
The most common approach is to include the user's role in the JWT token or session data. When the user authenticates, the backend includes their role in the token payload. Next.js middleware reads the role from the token to make routing decisions, and client components read it from the session context to conditionally render UI elements.
Collaboration
Need help with a project?
Let's Build It
I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI — let's bring your vision to life.
Related Articles
How to Add Observability to a Node.js App with OpenTelemetry
Learn how to instrument a Node.js app with OpenTelemetry for traces, metrics, and logs, and build a practical observability setup for production debugging.
How to Build a Backend-for-Frontend (BFF) with Next.js and Node.js
A practical guide to building a Backend-for-Frontend with Next.js and Node.js for API aggregation, auth handling, caching, and frontend-specific data shaping.
How I Structure CI/CD for Next.js, Docker, and GitHub Actions
A practical CI/CD blueprint for Next.js apps using Docker and GitHub Actions, including testing, image builds, deployment stages, cache strategy, and release safety.