Tooni Olaniyan

Software (Frontend) Engineer

From Role-Based to Permission-Based Middleware: A Next.js Transformation

When building modern web applications, managing user access and route protection is crucial for maintaining security and providing the right user experience. In this article, I'll walk you through how I transformed a rigid role-based middleware system into a flexible permission-based one that can adapt to any user access scenario taking advantage of Nextjs middleware.

The Problem with Rigid Role-Based Routing

Initially, the middleware relied on predefined arrays of routes for each user role, stored in cookies. If a user tried to access a route not in their role's list, they'd be redirected to the homepage or sign-in page.

Here's the original code:

import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function middleware(request: NextRequest) {
  const superAgentsRoutes = [
    "/",
    "/sub-agents",
    "/sub-agents/:id",
    "/other-routes"
  ];}
  const partnerRoutes = [
    "/",
    "/companies",
    "/companies/:id",
    "/other-routes"
  ];

  // ... other role route arrays

  const accessToken = request.cookies.get("access_token")?.value;
  const userRole = request.cookies.get("user_role")?.value;

  if (userRole) {
    const pathname = request.nextUrl.pathname;

    const isRouteAllowed = (routes: string[]) =>
      routes.some((route) =>
        new RegExp(
          `^${route.replace(/:\w+/, "[^/]+").replace(/\[\w+\]/, "[^/]+")}$`
        ).test(pathname)
      );

    if (userRole === "super_agent" && !isRouteAllowed(superAgentsRoutes)) {
      return NextResponse.redirect(new URL("/", request.url));
    }
    if (userRole === "partner" && !isRouteAllowed(partnerRoutes)) {
      return NextResponse.redirect(new URL("/", request.url));
    }
    // ...other role checks
  }

return NextResponse.next();
export const config = {
  matcher: [
    "/",
   "/all-other-routes"]}

Why this is a problem

  • Rigid structure: Every time a new route is added, You would imagine you have to manually update the arrays for multiple roles.
  • Scalability issues: As the number of roles and routes grows, managing these lists will became a nightmare.
  • Maintenance Overhead: Overlapping routes (like spreading arrays) led to redundancy and potential errors.

Identifying the Need for Change

When requirements shifted: permissions could be assigned granularly to any user, regardless of role. For example, a "company" user might gain access to "/business-analytics" temporarily. Manually predefined routes couldn't handle this dynamism without constant updates. Which would make the code messy and un-maintainable.

The solution?

Decode permissions from the JWT access token and map them to allowed routes via a constant. This decouples roles from routes, enabling flexible, backend-driven access control.

A way to decode the accessToken

import { jwtDecode } from 'jwt-decode'

Predefining your permissionRoute

export const permissionRouteMap: Record<string, string[]> = {
  "admin.dashboardstats": ["/business-analytics", "/products"],
  "admin.getCurrent": ["/", "/settings"],
  "partners.get": ["/partners", "/partners/:id"],
  //other route map
};
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { jwtDecode } from "jwt-decode";
import { permissionRouteMap } from "./constant";

export function middleware(request: NextRequest) {
  const accessToken = request.cookies.get("access_token")?.value;
  const userRole = request.cookies.get("user_role")?.value;
  if (!accessToken) {
    return NextResponse.redirect(new URL("/signin", request.url));
  }
  let permissions: string[] = [];
  try {
    const decoded: any = jwtDecode(accessToken);
    permissions = decoded.permissions || [];
  } catch (e) {
    return NextResponse.redirect(new URL("/signin", request.url));
  }
  const pathname = request.nextUrl.pathname;
  if (!permissions.length) {
    return NextResponse.redirect(new URL("/signin", request.url));
  }
  // Check if the requested path is allowed by permissions using the mapping
  const isRouteAllowed = (permissions: string[]) => {
    return permissions.some((permission) => {
      const allowedRoutes = permissionRouteMap[permission] || [];
      return allowedRoutes.some((route) =>
        new RegExp(
          `^${route.replace(/:\w+/, "[^/]+").replace(/\[\w+\]/, "[^/]+")}$`
        ).test(pathname)
      );
    });
  };
  if (!isRouteAllowed(permissions)) {
    return NextResponse.redirect(new URL("/", request.url));
  }
  return NextResponse.next();
}

export const config = {
  matcher: [
    "/",
    "/business-analytics",
    "/business-analytics/:path*",
    "/other-routes"
  ],
};

Look at how clean and maintainable this is compared to the original! No more role checking, no more hardcoded arrays, just pure permission-based logic.

How This Works

  • Extract permissions from the JWT

  • The middleware decodes the access token.

  • Permissions are retrieved from the token payload.

  • Map permissions to routes dynamically

  • PermissionRouteMap holds the mapping between permissions and allowed routes.

  • The middleware checks if the requested path matches any allowed route for the given permissions.

  • Flexible route protection is achieved

  • Permissions can be updated in the backend without modifying the middleware.

  • Any user type can access any route as long as they have the right permission.

Benefit of this approach

No more manual route lists for each role

Dynamic permission assignment — easily updated in backend.

Scalable for large apps — works with hundreds of routes.

Cleaner middleware logic — avoids repetitive role checks.

Final thoughts

If you’re working on a growing application where route access rules change often, permission-based route protection is the way to go.

Further Reading