Skip to main content
Back to Blog
Tutorials
4 min read
November 28, 2024

How to Build a Permissions and Roles System in Next.js

Implement a full role-based access control system with permissions, role hierarchy, and UI guards in Next.js.

Ryel Banfield

Founder & Lead Developer

Role-based access control (RBAC) is critical for multi-user applications. Here is how to implement it properly.

Step 1: Define Permissions

// lib/permissions.ts
export const PERMISSIONS = {
  // Posts
  "posts:read": "View posts",
  "posts:create": "Create posts",
  "posts:update": "Edit posts",
  "posts:delete": "Delete posts",
  "posts:publish": "Publish posts",

  // Users
  "users:read": "View users",
  "users:create": "Invite users",
  "users:update": "Edit users",
  "users:delete": "Remove users",
  "users:manage-roles": "Manage user roles",

  // Settings
  "settings:read": "View settings",
  "settings:update": "Modify settings",

  // Billing
  "billing:read": "View billing",
  "billing:manage": "Manage billing",

  // Analytics
  "analytics:read": "View analytics",
  "analytics:export": "Export analytics data",
} as const;

export type Permission = keyof typeof PERMISSIONS;

export interface Role {
  id: string;
  name: string;
  description: string;
  permissions: Permission[];
  isSystem: boolean; // System roles cannot be deleted
}

// Default role definitions
export const DEFAULT_ROLES: Omit<Role, "id">[] = [
  {
    name: "Owner",
    description: "Full access to everything",
    permissions: Object.keys(PERMISSIONS) as Permission[],
    isSystem: true,
  },
  {
    name: "Admin",
    description: "Manage content, users, and settings",
    permissions: [
      "posts:read", "posts:create", "posts:update", "posts:delete", "posts:publish",
      "users:read", "users:create", "users:update",
      "settings:read", "settings:update",
      "analytics:read", "analytics:export",
    ],
    isSystem: true,
  },
  {
    name: "Editor",
    description: "Create and edit content",
    permissions: [
      "posts:read", "posts:create", "posts:update", "posts:publish",
      "analytics:read",
    ],
    isSystem: true,
  },
  {
    name: "Viewer",
    description: "Read-only access",
    permissions: ["posts:read", "analytics:read"],
    isSystem: true,
  },
];

Step 2: Permission Checker

// lib/auth/check-permission.ts
import type { Permission } from "@/lib/permissions";

interface UserWithRole {
  id: string;
  role: {
    permissions: Permission[];
  };
}

export function hasPermission(user: UserWithRole, permission: Permission): boolean {
  return user.role.permissions.includes(permission);
}

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

export function hasAllPermissions(user: UserWithRole, permissions: Permission[]): boolean {
  return permissions.every((p) => user.role.permissions.includes(p));
}

// Resource-level permission check
export function canAccessResource(
  user: UserWithRole & { id: string },
  resource: { authorId: string },
  permission: Permission
): boolean {
  // Owner of resource can always access it
  if (user.id === resource.authorId) return true;
  return hasPermission(user, permission);
}

Step 3: Middleware Guard

// middleware.ts
import { NextRequest, NextResponse } from "next/server";
import type { Permission } from "@/lib/permissions";

// Route to permission mapping
const ROUTE_PERMISSIONS: Record<string, Permission[]> = {
  "/dashboard/posts/new": ["posts:create"],
  "/dashboard/posts/[id]/edit": ["posts:update"],
  "/dashboard/users": ["users:read"],
  "/dashboard/users/invite": ["users:create"],
  "/dashboard/settings": ["settings:read"],
  "/dashboard/billing": ["billing:read"],
  "/dashboard/analytics": ["analytics:read"],
};

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

  // Check if route requires permissions
  const matchedRoute = Object.entries(ROUTE_PERMISSIONS).find(([pattern]) => {
    const regex = new RegExp(
      "^" + pattern.replace(/\[.*?\]/g, "[^/]+") + "$"
    );
    return regex.test(pathname);
  });

  if (matchedRoute) {
    const [, requiredPermissions] = matchedRoute;

    // Get user session and check permissions
    // In production, decode session token here
    const userPermissions = request.cookies.get("user_permissions")?.value;
    if (!userPermissions) {
      return NextResponse.redirect(new URL("/login", request.url));
    }

    const permissions: Permission[] = JSON.parse(userPermissions);
    const hasAccess = requiredPermissions.every((p) => permissions.includes(p));

    if (!hasAccess) {
      return NextResponse.redirect(new URL("/dashboard/unauthorized", request.url));
    }
  }

  return NextResponse.next();
}

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

Step 4: Permission Gate Component

"use client";

import type { Permission } from "@/lib/permissions";
import { useUser } from "@/hooks/useUser";

interface PermissionGateProps {
  permission: Permission | Permission[];
  requireAll?: boolean;
  fallback?: React.ReactNode;
  children: React.ReactNode;
}

export function PermissionGate({
  permission,
  requireAll = false,
  fallback = null,
  children,
}: PermissionGateProps) {
  const { user } = useUser();
  if (!user) return fallback;

  const permissions = Array.isArray(permission) ? permission : [permission];
  const userPerms = user.role.permissions;

  const hasAccess = requireAll
    ? permissions.every((p) => userPerms.includes(p))
    : permissions.some((p) => userPerms.includes(p));

  if (!hasAccess) return fallback;

  return <>{children}</>;
}

Step 5: Role Management UI

"use client";

import { useState } from "react";
import { Check, Shield, ChevronDown, ChevronRight } from "lucide-react";
import { PERMISSIONS, type Permission, type Role } from "@/lib/permissions";

// Group permissions by category
function groupPermissions() {
  const groups: Record<string, { key: Permission; label: string }[]> = {};
  Object.entries(PERMISSIONS).forEach(([key, label]) => {
    const [category] = key.split(":");
    if (!groups[category]) groups[category] = [];
    groups[category].push({ key: key as Permission, label });
  });
  return groups;
}

interface RoleEditorProps {
  role: Role;
  onSave: (role: Role) => void;
}

export function RoleEditor({ role, onSave }: RoleEditorProps) {
  const [permissions, setPermissions] = useState<Permission[]>(role.permissions);
  const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
  const groups = groupPermissions();

  function togglePermission(perm: Permission) {
    setPermissions((prev) =>
      prev.includes(perm) ? prev.filter((p) => p !== perm) : [...prev, perm]
    );
  }

  function toggleGroup(group: string) {
    const groupPerms = groups[group].map((p) => p.key);
    const allSelected = groupPerms.every((p) => permissions.includes(p));
    setPermissions((prev) =>
      allSelected
        ? prev.filter((p) => !groupPerms.includes(p))
        : [...new Set([...prev, ...groupPerms])]
    );
  }

  function toggleExpand(group: string) {
    setExpandedGroups((prev) => {
      const next = new Set(prev);
      if (next.has(group)) next.delete(group);
      else next.add(group);
      return next;
    });
  }

  return (
    <div className="space-y-4">
      <div className="flex items-center gap-2">
        <Shield className="h-5 w-5" />
        <h3 className="text-lg font-semibold">{role.name}</h3>
        {role.isSystem && (
          <span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs dark:bg-gray-800">
            System
          </span>
        )}
      </div>

      <div className="space-y-2">
        {Object.entries(groups).map(([group, perms]) => {
          const isExpanded = expandedGroups.has(group);
          const selectedCount = perms.filter((p) => permissions.includes(p.key)).length;
          const allSelected = selectedCount === perms.length;

          return (
            <div key={group} className="rounded-lg border dark:border-gray-700">
              <button
                onClick={() => toggleExpand(group)}
                className="flex w-full items-center gap-3 px-4 py-3"
              >
                {isExpanded ? (
                  <ChevronDown className="h-4 w-4" />
                ) : (
                  <ChevronRight className="h-4 w-4" />
                )}
                <span className="flex-1 text-left text-sm font-medium capitalize">
                  {group}
                </span>
                <span className="text-xs text-gray-400">
                  {selectedCount}/{perms.length}
                </span>
                <button
                  onClick={(e) => {
                    e.stopPropagation();
                    toggleGroup(group);
                  }}
                  className={`rounded px-2 py-0.5 text-xs ${
                    allSelected
                      ? "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300"
                      : "bg-gray-100 text-gray-600 dark:bg-gray-800"
                  }`}
                >
                  {allSelected ? "Deselect all" : "Select all"}
                </button>
              </button>

              {isExpanded && (
                <div className="border-t px-4 py-2 dark:border-gray-700">
                  {perms.map(({ key, label }) => (
                    <label
                      key={key}
                      className="flex cursor-pointer items-center gap-3 rounded-lg px-2 py-2 hover:bg-gray-50 dark:hover:bg-gray-800"
                    >
                      <div
                        className={`flex h-5 w-5 items-center justify-center rounded border ${
                          permissions.includes(key)
                            ? "border-blue-600 bg-blue-600"
                            : "border-gray-300 dark:border-gray-600"
                        }`}
                      >
                        {permissions.includes(key) && (
                          <Check className="h-3 w-3 text-white" />
                        )}
                      </div>
                      <div>
                        <p className="text-sm">{label}</p>
                        <p className="text-xs text-gray-400">{key}</p>
                      </div>
                    </label>
                  ))}
                </div>
              )}
            </div>
          );
        })}
      </div>

      <button
        onClick={() => onSave({ ...role, permissions })}
        className="w-full rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
      >
        Save Permissions
      </button>
    </div>
  );
}

Step 6: Usage in Components

import { PermissionGate } from "@/components/PermissionGate";

export function PostActions({ postId }: { postId: string }) {
  return (
    <div className="flex gap-2">
      <PermissionGate permission="posts:update">
        <a href={`/dashboard/posts/${postId}/edit`} className="btn-secondary">
          Edit
        </a>
      </PermissionGate>

      <PermissionGate permission="posts:publish">
        <button className="btn-primary">Publish</button>
      </PermissionGate>

      <PermissionGate permission="posts:delete">
        <button className="btn-danger">Delete</button>
      </PermissionGate>
    </div>
  );
}

Summary

  • Define permissions as a typed constant for type safety
  • Role definitions map to permission sets
  • Server-side middleware enforces route access
  • Client-side PermissionGate component hides unauthorized UI

Need Secure Applications?

We build applications with enterprise-grade access control and security. Contact us to discuss your project.

permissionsrolesRBACauthorizationNext.jstutorial

Ready to Start Your Project?

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

Get in Touch

Related Articles