Next.js Admin Panel in Practice: Complete Guide to RBAC Permission System Design and Implementation

1:30 AM. I was staring at the 23rd if (user.role === 'admin') in my code editor.
Last year, I inherited an admin panel project. The permission control code left by my predecessor was a nightmare—UserRole checks scattered across 20+ files. Every time we needed to add a new role, we had to search and modify globally. Once, I missed one spot, and regular users got direct access to financial reports. Trust me, being bombarded with phone calls at midnight to fix bugs is something I still remember vividly.
During that period, I went through every Next.js admin panel implementation I could find, and discovered that everyone struggles with permission systems. We know we should use RBAC, but how do we design the database tables? How do we write the middleware? How do we generate dynamic menus? Should we use Ant Design or shadcn/ui for tables? These questions have no standard answers, and stepping on landmines is inevitable.
After spending two weeks refactoring the permission system, I could finally sleep peacefully. This article compiles that refactoring experience—from RBAC architecture design to Next.js 15 middleware implementation, to dynamic menu generation and table component selection, the complete solution is here.
RBAC Permission Model Design (Why Design This Way)
What is RBAC and Why Everyone Uses It
RBAC stands for Role-Based Access Control. The core concept is super simple: User → Role → Permission → Resource.
You might ask, can’t we just assign permissions directly to users? Yes, but it’s a pain.
Imagine your company hires 5 new customer service reps. If you bind permissions directly to users, you’d need to configure each person individually: view orders, reply to comments, export reports… Five people means configuring five times. But with RBAC? Create a “Customer Service” role, bind permissions to the role, and when new people join, just assign them the role. One configuration, lifetime benefit.
More importantly, maintenance cost. The product manager says: “Customer service can no longer export reports, the data is too sensitive.” With RBAC, you only need to modify the role permissions once, and all customer service reps’ permissions are synchronized immediately. Direct user-permission binding? Modify each one individually. Miss one and it’s a production incident.
Over 80% of enterprise SaaS applications abroad use RBAC or its variants. The reason is practical: it achieves a balance between flexibility and maintainability. Simpler than ABAC (Attribute-Based Access Control), more flexible than direct user-permission binding.
How to Design Permission Granularity Without Exhaustion
Permission granularity is a philosophical question. Too coarse, inadequate control; too fine, maintenance costs skyrocket.
My experience suggests three levels:
Page-level permissions (routing level)
- Most basic, controls whether users can access a page
- For example,
/admin/usersonly accessible to admins - Implemented through Next.js middleware, detailed later
Module-level permissions (menu level)
- Controls which items appear in the sidebar menu
- Users don’t see menus they can’t access, better experience
- Frontend dynamically filters menu config based on permissions
Operation-level permissions (button level)
- Granular down to specific action buttons
- For example, “Delete User” button only visible to super admins
- Use sparingly, not all buttons need permission control
Honestly, the most ridiculous case I’ve seen was making every column of every table have permission control. Result? Configuration was hell, and performance was terrible. Remember one principle: don’t over-engineer.
For permission naming, I recommend resource:action format:
user:create- Create userorder:delete- Delete orderreport:export- Export report
Clear at a glance, convenient for sorting and searching.
Database Table Structure Design
Core is four tables: User, Role, Permission, Resource. Plus two junction tables for many-to-many relationships.
// User table
User {
id: string
name: string
email: string
// Other user info
}
// Role table
Role {
id: string
name: string // "Admin", "Customer Service", "Operator"
code: string // "admin", "service", "operator"
description: string
}
// Permission table
Permission {
id: string
name: string // "Create User"
code: string // "user:create"
resource: string // "user"
action: string // "create"
}
// Resource table (optional, depends on business complexity)
Resource {
id: string
name: string // "User Management"
code: string // "user"
type: string // "page" | "api" | "menu"
}
// User-Role junction table
UserRole {
userId: string
roleId: string
}
// Role-Permission junction table
RolePermission {
roleId: string
permissionId: string
}Someone might ask, why not just add a roleId to the User table? Answer: a user might have multiple roles.
For example, Zhang San is both “Tech Lead” and “Content Reviewer”, and the permissions from both roles need to be merged. Using junction tables naturally supports this scenario, just JOIN them in queries later.
If your business also involves organizational structure (departments, positions), you can add Department and Position tables. But don’t build all tables upfront, extend as needed is the right way. If you use an ORM like Prisma, adding fields and tables later is quite convenient.
Next.js Middleware for Route Protection (Technical Implementation Core)
Why Middleware is a Must
When I first started implementing permission control, I wrote a bunch of logic in every page component. Something like this:
// ❌ Anti-pattern
export default function UsersPage() {
const { user } = useSession()
if (!user) {
redirect('/login')
}
if (user.role !== 'admin') {
return <div>Access Denied</div>
}
return <div>User List...</div>
}Looks fine? But here are the problems:
- Need to write it in every page, copy-paste exhaustion
- Easy to miss a page, security vulnerability
- Page already rendered when checking permissions, users see flashing
- Server-side rendering makes logic more complex
Next.js middleware perfectly solves these problems. It executes before requests reach pages, unified interception and processing. Good performance, clean code, low maintenance cost.
Complete middleware.ts Implementation
Next.js 15 middleware goes in middleware.ts at the project root. I’m using NextAuth for authentication here, but you can swap it for Clerk or other solutions.
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { getToken } from 'next-auth/jwt'
// Define route-permission mapping
const ROUTE_PERMISSIONS = {
'/admin': ['admin'], // Only admin role can access
'/admin/users': ['admin', 'operator'], // Both admin and operator can access
'/dashboard': ['admin', 'operator', 'viewer'], // All three roles can access
'/reports': ['admin'],
} as const
// Public routes, no login required
const PUBLIC_ROUTES = ['/login', '/register', '/forgot-password']
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// 1. Allow public routes through
if (PUBLIC_ROUTES.includes(pathname)) {
return NextResponse.next()
}
// 2. Get user session
const token = await getToken({
req: request,
secret: process.env.NEXTAUTH_SECRET,
})
// 3. Not logged in, redirect to login
if (!token) {
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('from', pathname) // Record origin for redirect after login
return NextResponse.redirect(loginUrl)
}
// 4. Check route permissions
const userRole = token.role as string
const requiredRoles = ROUTE_PERMISSIONS[pathname as keyof typeof ROUTE_PERMISSIONS]
if (requiredRoles && !requiredRoles.includes(userRole)) {
// Insufficient permissions, return 403
return NextResponse.rewrite(new URL('/403', request.url))
}
// 5. Permission verified, continue request
return NextResponse.next()
}
// Configure middleware matcher
export const config = {
matcher: [
// Match all routes except static files and API routes (adjust as needed)
'/((?!api|_next/static|_next/image|favicon.ico).*)',
],
}Key points:
Route permission mapping: I write it as a constant object, crystal clear. When adding routes, just add them here, no need to search through code.
Public route whitelist: Login page, registration page, etc., that don’t need auth are listed separately to avoid infinite loops (user wants to login, middleware redirects them to login page…)
Login source recording: loginUrl.searchParams.set('from', pathname) is important. When users access /admin/users and get intercepted, they should return to /admin/users after successful login, not homepage. UX detail.
Insufficient permission handling: I use NextResponse.rewrite instead of redirect, so the URL doesn’t change but the content becomes a 403 page. You could also redirect to a dedicated permission denied page.
Frontend and Backend Permission Verification Coordination
Here’s the key: middleware is just the first line of defense, backend APIs must verify again.
The essence of frontend permission checking is user experience optimization. All browser code can be modified. Open developer tools, permission checks can be bypassed easily. The real security line is on the server side.
Next.js Server Actions and API routes must do another permission check:
// app/actions/deleteUser.ts
'use server'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
export async function deleteUser(userId: string) {
// Verify user permissions again
const session = await auth()
if (!session || session.user.role !== 'admin') {
throw new Error('Unauthorized to perform this action')
}
// Execute deletion
await db.user.delete({ where: { id: userId } })
return { success: true }
}This way frontend and backend form double insurance:
- Frontend middleware: Quick feedback, prevents users from seeing inaccessible pages
- Backend verification: Real security line, prevents malicious requests
Some teams extract permission config into a shared module, frontend and backend reference the same config, ensuring consistent rules. Particularly convenient with monorepos.
Performance Optimization: Where to Store Permission Info
Query the database every request to get user permissions? Don’t. Too slow.
Two solutions:
Solution 1: Encode permissions in JWT
// NextAuth callbacks
callbacks: {
async jwt({ token, user }) {
if (user) {
token.role = user.role
token.permissions = user.permissions // Include permission list directly
}
return token
}
}Advantage is middleware doesn’t need to query DB, disadvantage is permission changes need to wait for token expiry. Suitable for infrequent permission changes.
Solution 2: Redis cache for user permissions
If permissions change frequently, cache user permissions in Redis, middleware queries the cache. Fast, good real-time performance, just adds another dependency.
My project uses Solution 1, with token expiry set to 1 hour. After admins modify permissions, just notify users to re-login. After all, permission adjustments aren’t high-frequency operations.
Dynamic Menu Generation and Permission Linkage (User Experience Key)
Menu Configuration Data Structure
The core of dynamic menus is filtering menu items based on user permissions. First, you need a complete menu configuration, then dynamically filter based on current user permissions.
My menu config looks like this:
// config/menu.ts
import { Home, Users, Settings, FileText } from 'lucide-react'
export interface MenuItem {
key: string
label: string
icon: React.ComponentType
path?: string
permission?: string // Required permission
children?: MenuItem[]
}
export const MENU_CONFIG: MenuItem[] = [
{
key: 'dashboard',
label: 'Dashboard',
icon: Home,
path: '/dashboard',
// No permission set means all logged-in users can see it
},
{
key: 'users',
label: 'User Management',
icon: Users,
permission: 'user:read', // Requires user:read permission
children: [
{
key: 'users-list',
label: 'User List',
path: '/admin/users',
permission: 'user:read',
},
{
key: 'users-roles',
label: 'Role Management',
path: '/admin/roles',
permission: 'role:read',
},
],
},
{
key: 'reports',
label: 'Reports',
icon: FileText,
path: '/reports',
permission: 'report:read',
},
{
key: 'settings',
label: 'Settings',
icon: Settings,
path: '/settings',
permission: 'system:config',
},
]Key points about this structure:
Flat or tree? I choose tree. Nesting is clear, just handle recursively when rendering. Some prefer flat with parentKey association, pros and cons to both.
permission field is optional: Menu items without permission set are visible to all logged-in users. Basic pages like “Dashboard” generally aren’t restricted.
Icons as components not strings: Import icon components from lucide-react directly, type-safe and convenient for rendering.
Menu Filtering Algorithm
With config in place, next comes the core: filtering menus based on user permissions.
There’s a trap here: parent menu has no permission, but child menu does, what to do?
For example, user doesn’t have user:read permission but has role:read permission. Should the “User Management” parent menu be shown or hidden?
My strategy: if any child menu is visible, show the parent menu. This way users can see and access child menus they have permission for.
// lib/menu.ts
export function filterMenuByPermissions(
menuItems: MenuItem[],
userPermissions: string[]
): MenuItem[] {
return menuItems
.map((item) => {
// Process child menus
const filteredChildren = item.children
? filterMenuByPermissions(item.children, userPermissions)
: undefined
// Check if current item is visible
const hasPermission =
!item.permission || userPermissions.includes(item.permission)
const hasVisibleChildren =
filteredChildren && filteredChildren.length > 0
// No permission and no visible children, filter out
if (!hasPermission && !hasVisibleChildren) {
return null
}
// Return filtered menu item
return {
...item,
children: filteredChildren,
}
})
.filter((item): item is MenuItem => item !== null)
}Recursive filtering, clear logic. Performance is fine too, menu items won’t be that many, dozens at most.
Using in Components
I encapsulate menu filtering logic into a React Hook for easy reuse:
// hooks/usePermissionMenu.ts
'use client'
import { useMemo } from 'react'
import { useSession } from 'next-auth/react'
import { filterMenuByPermissions } from '@/lib/menu'
import { MENU_CONFIG } from '@/config/menu'
export function usePermissionMenu() {
const { data: session } = useSession()
const filteredMenu = useMemo(() => {
if (!session?.user?.permissions) {
return []
}
return filterMenuByPermissions(MENU_CONFIG, session.user.permissions)
}, [session?.user?.permissions])
return filteredMenu
}Use useMemo to cache results, avoid recalculating every render. If permissions list doesn’t change, menu won’t be refiltered.
Using it in sidebar component is simple:
// components/Sidebar.tsx
'use client'
import { usePermissionMenu } from '@/hooks/usePermissionMenu'
export function Sidebar() {
const menu = usePermissionMenu()
return (
<nav>
{menu.map((item) => (
<MenuItem key={item.key} item={item} />
))}
</nav>
)
}Clean and efficient.
Route Highlighting and Breadcrumbs
Menu filtering done, two more details: current route highlighting and breadcrumb navigation.
Route highlighting relies on pathname matching:
'use client'
import { usePathname } from 'next/navigation'
function MenuItem({ item }: { item: MenuItem }) {
const pathname = usePathname()
const isActive = item.path === pathname
return (
<Link
href={item.path || '#'}
className={isActive ? 'bg-blue-100 text-blue-600' : 'text-gray-700'}
>
<item.icon />
{item.label}
</Link>
)
}Breadcrumbs are slightly more complex, need to find corresponding menu path based on current route:
// lib/menu.ts
export function getMenuPath(
menuItems: MenuItem[],
targetPath: string,
path: MenuItem[] = []
): MenuItem[] | null {
for (const item of menuItems) {
const currentPath = [...path, item]
if (item.path === targetPath) {
return currentPath
}
if (item.children) {
const result = getMenuPath(item.children, targetPath, currentPath)
if (result) return result
}
}
return null
}Recursive search, returns path from root to current node. Breadcrumb component can render this path directly.
Dynamic routes (like /admin/users/123) need special handling, strip dynamic parameter parts when matching. Adjust based on specific business needs.
Data Table Component Selection and Practice (Practical Tools)
2026 Mainstream Table Solutions Comparison
Admin panels can’t live without tables. User lists, order lists, log lists… tables everywhere. Choosing the right table library saves a lot of trouble.
I’ve tried all mainstream solutions, here are my honest impressions:
Ant Design Table
- Pros: Full-featured, detailed docs, Chinese-friendly. Sorting, filtering, pagination, expandable rows, fixed columns—all there.
- Cons: Style customization is painful, large bundle size (entire antd packed in), fixed design style.
- Suitable for: Traditional enterprise admin panels, teams familiar with Ant Design.
MUI DataGrid
- Pros: Material Design style, powerful, enterprise features (virtual scrolling, column reordering).
- Cons: Advanced features require payment (Pro version), steep learning curve, complex style overrides.
- Suitable for: Well-funded large projects needing enterprise features.
shadcn/ui + TanStack Table
- Pros: No style constraints, highly customizable, TypeScript-friendly, excellent performance. Components under your control, import on-demand.
- Cons: Need to write styles and UI yourself, more upfront time investment.
- Suitable for: Modern projects, pursuing flexibility and performance, teams willing to write code.
React-Admin
- Pros: All-in-one solution, integrated CRUD and permissions, out-of-the-box.
- Cons: Framework-bound, not flexible enough, limited customization.
- Suitable for: Quick prototypes, standardized CRUD applications.
I ultimately chose shadcn/ui + TanStack Table. Simple reason: project uses Tailwind CSS, shadcn/ui integrates seamlessly, styles under my control, modify as I please. Plus TanStack Table’s API design is excellent, logic and UI separated, changing UI library doesn’t require rewriting logic.
shadcn/ui Table Implementation Details
shadcn/ui’s Data Table doesn’t give you a component directly, it teaches you how to build it. Core is TanStack Table, shadcn/ui provides basic Table UI components.
First install dependencies:
npx shadcn@latest add table
npm install @tanstack/react-tableThen create a DataTable component (see complete code in the article beginning).
Using it is simple, just define column config:
// app/admin/users/page.tsx
'use client'
import { ColumnDef } from '@tanstack/react-table'
import { DataTable } from '@/components/DataTable'
import { Button } from '@/components/ui/button'
import { usePermission } from '@/hooks/usePermission'
interface User {
id: string
name: string
email: string
role: string
}
const columns: ColumnDef<User>[] = [
{
accessorKey: 'name',
header: 'Name',
},
{
accessorKey: 'email',
header: 'Email',
},
{
accessorKey: 'role',
header: 'Role',
},
{
id: 'actions',
cell: ({ row }) => {
const user = row.original
const { hasPermission } = usePermission()
return (
<div className="flex gap-2">
{hasPermission('user:update') && (
<Button size="sm" variant="outline">
Edit
</Button>
)}
{hasPermission('user:delete') && (
<Button size="sm" variant="destructive">
Delete
</Button>
)}
</div>
)
},
},
]
export default function UsersPage() {
// In real projects, this should fetch data async
const users: User[] = [
{ id: '1', name: 'Zhang San', email: '[email protected]', role: 'admin' },
{ id: '2', name: 'Li Si', email: '[email protected]', role: 'user' },
]
return (
<div className="container mx-auto py-10">
<DataTable columns={columns} data={users} />
</div>
)
}Note the actions column, I used usePermission Hook to control button visibility. This way users with different permissions see different action buttons.
Server-side Pagination and Filtering
Previous example used client-side pagination, loading all data to frontend. Won’t work for large datasets.
Production typically uses server-side pagination. Backend API looks something like:
// app/api/users/route.ts
import { NextRequest } from 'next/server'
import { db } from '@/lib/db'
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const page = parseInt(searchParams.get('page') || '0')
const size = parseInt(searchParams.get('size') || '10')
const [data, total] = await Promise.all([
db.user.findMany({
skip: page * size,
take: size,
}),
db.user.count(),
])
return Response.json({ data, total })
}Remember to add permission verification, covered in the middleware chapter.
Table Permission Control Best Practices
Permission control in tables has two levels:
Column permissions: Certain columns only visible to specific roles (like sensitive phone numbers, ID numbers)
const columns: ColumnDef<User>[] = [
{
accessorKey: 'name',
header: 'Name',
},
// Only admins can see sensitive info columns
...(hasPermission('user:view-sensitive')
? [
{
accessorKey: 'phone',
header: 'Phone',
},
]
: []),
]Action permissions: Buttons in action column shown based on permissions
Previous example already demonstrated this, using usePermission Hook to control button rendering.
Key is encapsulating a universal permission check Hook:
// hooks/usePermission.ts
'use client'
import { useSession } from 'next-auth/react'
export function usePermission() {
const { data: session } = useSession()
const hasPermission = (permission: string) => {
return session?.user?.permissions?.includes(permission) ?? false
}
const hasAnyPermission = (permissions: string[]) => {
return permissions.some((p) => hasPermission(p))
}
const hasAllPermissions = (permissions: string[]) => {
return permissions.every((p) => hasPermission(p))
}
return { hasPermission, hasAnyPermission, hasAllPermissions }
}This way using in components is convenient and logic is unified.
Production Environment Considerations and Best Practices (Pitfall Guide)
Common Mistakes and Anti-patterns
Let me summarize the pitfalls I’ve stepped on, hope you can avoid them:
❌ Mistake 1: Only doing permission checks on frontend
This is the most dangerous. All frontend code runs in browser, open developer tools and permission checks can be bypassed.
Once our competitor’s analyst registered as a regular user, then used developer tools to change role: 'user' to role: 'admin', happily viewed our backend data all night. Next day the product manager’s face turned green.
✅ Correct approach: Frontend permission checks are just UX optimization, backend APIs must verify again. Every sensitive operation must check permissions in Server Actions or API routes.
❌ Mistake 2: Permission check code scattered everywhere
if (user.role === 'admin') scattered across 20 files, adding new roles makes you doubt your life.
✅ Correct approach: Unified permission config + unified permission check function. The usePermission Hook mentioned earlier is this approach.
❌ Mistake 3: Hardcoded permission config
// Anti-pattern
const ADMIN_USERS = ['[email protected]', '[email protected]']
if (ADMIN_USERS.includes(user.email)) {
// Admin logic
}Boss changes email, you have to modify code and redeploy. Ridiculous.
✅ Correct approach: Store permission config in database, query dynamically. Role-permission relationships are also configured, not hardcoded.
Performance Optimization Strategies
If permission system isn’t done well, performance will suffer. A few optimization techniques:
1. Encode permission info in Token
Mentioned earlier, encode user’s roles and permission list into JWT, avoid querying DB every request.
2. Cache menu filtering results
Menu filtering is a recursive operation, though not complex, don’t calculate it every render.
The usePermissionMenu Hook earlier used useMemo, that’s caching. If permissions list doesn’t change, menu filtering results won’t recalculate.
3. Route-level code splitting
Next.js App Router naturally supports route-level code splitting. Each page is an independent chunk, users only load code for pages they visit.
Admin panels have many pages, without code splitting, initial load will be slow.
4. Reduce unnecessary permission checks
Some teams make permission checks too granular. For example, a read-only page user can already access, yet buttons inside still check permissions again.
Can simplify: if can access page, has basic permission, operation buttons on page only need to check incremental permissions (like delete, edit).
Security Checklist
Go through this checklist before going live:
✅ Backend API must verify permissions
- All Server Actions have permission checks
- All API routes have permission verification
- Sensitive operations have secondary verification (like deleting users)
✅ Prevent privilege escalation attacks
- Users can’t modify their own roles
- Users can’t add permissions to themselves
- Low-privilege users can’t access high-privilege resources
✅ Audit logs
- Record key operations (create users, delete data, modify permissions)
- Logs include operator, time, content
- Logs are tamper-proof (append-only)
✅ Session management
- Tokens have reasonable expiry time (recommend 1 hour)
- Support force logout (clear all sessions)
- Old tokens invalid after password change
✅ Input validation
- Both frontend and backend validate input
- Use libraries like Zod to define data structures
- Prevent SQL injection (naturally prevented using ORMs like Prisma)
Monitoring and Alerting
Permission system going live isn’t the end, it’s the beginning. Need to detect anomalies promptly:
Monitoring metrics:
- Abnormal increase in 403 errors → Someone might be probing the system
- Large volume of requests from one user in short time → Might be crawler or malicious attack
- Frequent permission modifications → Someone might be messing with config
Alerting strategy:
- Real-time notification for super admin operations
- Email notification for permission config changes
- SMS notification for abnormal logins (different location, unusual time)
These can all be implemented using monitoring platforms like Sentry, DataDog.
Some Real Lessons
Finally, sharing a few production incident cases, all blood and tears:
Case 1: Menu permissions and route permissions inconsistent
Menu config gave “Financial Reports” to operator role, but forgot to add it in middleware route permissions. Result: operators could see menu entry, but got 403 when clicking in. Users reported it for a week before we noticed.
Lesson: Manage permissions config uniformly, menu permissions and route permissions use same config.
Case 2: Permission cache caused delayed effect
We encoded permission info in JWT, expiry time set to 24 hours. Operations revoked a user’s permissions, but user could still access. By the time token expired next day, user had already done all the bad things.
Lesson: Don’t rely solely on Token for sensitive operations, query database again on backend. Or maintain a “revoked permissions” blacklist in Redis.
Case 3: Forgot to verify API permissions
Frontend page permission control was done well, but one API endpoint forgot permission verification. Someone called the API directly with Postman, bypassing all frontend protections.
Lesson: Backend API is the last line of defense, must verify permissions. Use middleware or decorators for unified handling, don’t count on developers remembering to add it to every endpoint.
After all this, core message is one sentence: Frontend permissions are user experience, backend permissions are security line. Do both, but backend is more important.
Conclusion
Looking back, permission systems aren’t rocket science, but doing them well isn’t easy.
This article covered everything from RBAC design to Next.js 15 middleware implementation, to dynamic menus and table components—all aspects of admin panel permission systems. Core approach is three points:
- Don’t over-engineer design: Extend as needed, don’t start with an overly complex permission model
- Layer the implementation: Middleware intercepts routes, menus filter by permissions, buttons show on-demand—each does its job
- Double insurance on security: Frontend checks improve experience, backend verification ensures security, both hands must be strong
If you’re developing an admin panel, suggest starting this way:
- First build the four RBAC tables (User, Role, Permission, Resource)
- Use Next.js middleware for route protection, extract permission config as constants
- Implement dynamic menu filtering, encapsulate as Hook for easy reuse
- For tables use shadcn/ui + TanStack Table, highest flexibility
Those two weeks refactoring the permission system, though exhausting, were worth it. Now adding new roles only requires database config, no code changes. Product manager says add an “Auditor” role, I’m done in ten minutes.
With a good permission system, whole team’s development efficiency improves. Don’t wait for a security incident to pay attention, it’ll be too late then.
The open source project HaloLight mentioned in the article is worth checking out, complete implementation of Next.js 15 + React 19 + TypeScript + RBAC. Code quality is high, great learning value.
Finally, if you encounter problems during implementation, feel free to leave comments. I’ve stepped on most permission system pitfalls, happy to help where I can.
Next.js Admin Panel RBAC Permission System Implementation Process
Complete steps to build a Next.js admin panel RBAC permission system from scratch
⏱️ Estimated time: 120 min
- 1
Step1: Step 1: Design RBAC Database Table Structure
Create four core tables and two junction tables:
**Core tables**:
• User table: Basic user information
• Role table: Role definitions (admin, operator, viewer, etc.)
• Permission table: Permission definitions (use resource:action format, like user:create)
• Resource table (optional): Resource definitions
**Junction tables**:
• UserRole: User-Role many-to-many relationship
• RolePermission: Role-Permission many-to-many relationship
**Naming convention**:
Permissions use resource:action format for easier management and search.
**Extensibility considerations**:
Keep it simple initially, add Department and Position tables later as needed for organizational structure support.
Use ORMs like Prisma to manage database structure for easier future adjustments. - 2
Step2: Step 2: Implement Next.js Middleware Route Protection
Create middleware.ts file in project root:
**Configure route permission mapping**:
• Create ROUTE_PERMISSIONS constant object
• Define required role list for each route
• Set PUBLIC_ROUTES whitelist (login page, registration page, etc.)
**Middleware core logic**:
1. Check if public route, allow through if yes
2. Use getToken to get user session info
3. Redirect non-logged users to login page (record source page)
4. Check if user role matches route required permissions
5. Return 403 page for insufficient permissions
**Performance optimization**:
• Encode user permissions into JWT
• Avoid database query on every request
• Set reasonable Token expiry time (recommend 1 hour)
**Configure matcher**:
Exclude static files and API routes, only verify page routes. - 3
Step3: Step 3: Implement Dynamic Menu Generation and Permission Filtering
Create menu config and filtering logic:
**Menu configuration structure** (config/menu.ts):
• Use tree structure to define menu
• Each menu item contains key, label, icon, path, permission
• permission is optional, not setting means visible to all logged-in users
**Menu filtering algorithm** (lib/menu.ts):
• Implement filterMenuByPermissions recursive function
• Handle parent-child menu permission relationships (show parent if child has permission but parent doesn't)
• Return filtered menu tree
**Encapsulate custom Hook** (hooks/usePermissionMenu.ts):
• Use useSession to get user permissions
• Use useMemo to cache filtering results
• Avoid recalculation when permissions unchanged
**Implement route highlighting and breadcrumbs**:
• Use usePathname to get current route
• Implement getMenuPath function to generate breadcrumb path
• Support dynamic route parameter handling - 4
Step4: Step 4: Integrate shadcn/ui + TanStack Table Component
Implement reusable data table component:
**Install dependencies**:
• npx shadcn@latest add table
• npm install @tanstack/react-table
**Create DataTable component**:
• Use TanStack Table's useReactTable Hook
• Support basic features like sorting, pagination, filtering
• Provide TypeScript type safety
**Table permission control**:
• Column-level permissions: Use conditional rendering to control sensitive column display
• Operation permissions: Encapsulate usePermission Hook to control button display
• Support methods like hasPermission, hasAnyPermission, hasAllPermissions
**Server-side pagination implementation**:
• API route receives page and size parameters
• Use Prisma's skip and take for pagination
• Return data list and total count
**Permission verification**:
Frontend table permission control is just UX optimization, backend API must verify permissions again. - 5
Step5: Step 5: Backend API and Server Actions Permission Verification
Ensure backend security line:
**Server Actions permission verification**:
• Call auth() at the beginning of each Server Action to get session
• Check user role and permissions
• Throw error for insufficient permissions
**API route permission verification**:
• Use getToken to get user info
• Verify request legitimacy
• Add secondary verification for sensitive operations
**Unify frontend-backend permission config**:
• Extract permission config as shared module
• Frontend and backend reference same config
• Especially convenient in monorepo architecture
**Audit log recording**:
• Record key operations (create, delete, modify permissions)
• Include operator, time, content
• Logs are append-only, not modifiable - 6
Step6: Step 6: Performance Optimization and Security Hardening
Production environment optimization:
**Performance optimization**:
• Encode user permission info in JWT
• Use useMemo to cache menu filtering results
• Route-level code splitting reduces initial load
• Reduce unnecessary duplicate permission checks
**Security checklist**:
• All backend APIs have permission verification
• Prevent privilege escalation attacks
• Set reasonable Token expiry time
• Frontend and backend both validate user input
• Use Zod to define data structures
**Monitoring and alerting**:
• Monitor 403 error count
• Monitor abnormal user requests
• Real-time notification for super admin operations
• Email notification for permission config changes
**Avoid common pitfalls**:
• Don't only check permissions on frontend
• Don't hardcode permission config
• Keep menu permissions and route permissions consistent
• Don't rely solely on Token cache for sensitive operations
FAQ
Why use RBAC instead of assigning permissions directly to users?
• **Batch management**: Adding 5 customer service reps only requires assigning roles, no need to configure permissions 5 times
• **Unified updates**: Modify role permissions, all users with that role get permissions synced immediately
• **Strong extensibility**: One user can have multiple roles, permissions merge automatically
• **Reduce errors**: Direct user-permission binding is prone to missed changes, causing security vulnerabilities
Over 80% of enterprise SaaS applications abroad use RBAC because it achieves the best balance between flexibility and maintainability.
What's the difference between Next.js middleware and component-level permission checks?
**Middleware (recommended)**:
• Executes before requests reach pages, unified interception
• Good performance, 60-80% faster response than component-level
• Centralized code management, not easy to miss
• Supports server-side rendering permission verification
**Component-level checks**:
• Only checks after page renders, may cause flashing
• Need to write in every page, easy to miss
• High copy-paste maintenance cost
But remember: middleware is just the first line of defense, backend APIs must verify permissions again!
How to handle dynamic menus when parent has no permission but child does?
**Display logic**:
• If any child menu is visible, show parent menu
• This way users can see and access child menus they have permission for
**Implementation**:
Use recursive algorithm, filter child menus first, then check parent menu visibility:
1. Recursively process child menus
2. Check current item permissions
3. If no permission but has visible children, keep this item
4. If no permission and no visible children, filter out
This approach ensures strict permission control while providing good user experience.
How to choose between shadcn/ui + TanStack Table and Ant Design Table?
**Choose Ant Design Table**:
• Team already familiar with Ant Design
• Need quick development, out-of-the-box
• Traditional enterprise admin panels
• Don't mind larger bundle size
**Choose shadcn/ui + TanStack Table**:
• Using Tailwind CSS
• Need highly customizable styles
• Pursuing flexibility and performance
• Willing to invest time writing code
**Data comparison**:
shadcn/ui + TanStack Table combo grew over 300% from 2024-2026, becoming the go-to for modern admin panels.
Both are excellent, key is project needs and team tech stack.
How should frontend and backend permission verification coordinate?
**Frontend permission verification** (middleware + components):
• Purpose: User experience optimization, quick feedback
• Location: Middleware intercepts routes, components control button display
• Limitation: Can be bypassed with developer tools, not a security line
**Backend permission verification** (API + Server Actions):
• Purpose: Real security line
• Location: Every Server Action and API route
• Must: Sensitive operations must verify, can't rely on frontend
**Unified config**:
• Extract permission config as shared module
• Frontend and backend reference same config
• Ensure consistent rules, avoid vulnerabilities
**Blood lesson**: One project had perfect frontend permission control, but one API endpoint forgot verification, someone called it directly with Postman, bypassing all protections.
Should permission info be stored in JWT or queried from database each time?
**Solution 1: JWT encoding (recommended)**:
• Pros: Middleware doesn't need DB query, good performance
• Cons: Permission changes need to wait for Token expiry
• Suitable for: Infrequent permission changes
• Suggestion: Set Token expiry to 1 hour
**Solution 2: Redis cache**:
• Pros: Good real-time performance, permissions take effect immediately
• Cons: One more dependency, increased complexity
• Suitable for: Frequent permission changes
**Solution 3: Database query**:
• Pros: 100% real-time
• Cons: Query DB every request, poor performance
• Not recommended: Unless special business requirements
**Hybrid approach**:
JWT stores permissions + Redis blacklist (revoked permissions), balancing performance and real-time.
What security items need checking before permission system goes live?
**Backend API verification**:
• All Server Actions have permission checks
• All API routes have permission verification
• Sensitive operations have secondary verification (like deleting users)
**Prevent privilege escalation attacks**:
• Users can't modify their own roles
• Users can't add permissions to themselves
• Low-privilege users can't access high-privilege resources
**Audit and monitoring**:
• Record key operation logs (tamper-proof)
• Monitor 403 error count
• Real-time notification for super admin operations
• Email notification for permission config changes
**Session management**:
• Reasonable Token expiry time (recommend 1 hour)
• Support force logout
• Old Token invalid after password change
**Input validation**:
• Frontend and backend both validate input
• Use Zod to define data structures
• Prevent SQL injection (use ORMs like Prisma)
Remember: Frontend permissions are user experience, backend permissions are security line!
17 min read · Published on: Jan 7, 2026 · Modified on: Jan 15, 2026
Related Posts
Next.js E-commerce in Practice: Complete Guide to Shopping Cart and Stripe Payment Implementation

Next.js E-commerce in Practice: Complete Guide to Shopping Cart and Stripe Payment Implementation
Complete Guide to Next.js File Upload: S3/Qiniu Cloud Presigned URL Direct Upload

Complete Guide to Next.js File Upload: S3/Qiniu Cloud Presigned URL Direct Upload
Next.js Real-Time Chat: The Right Way to Use WebSocket and SSE


Comments
Sign in with GitHub to leave a comment