Dynamic Sidebar Navigation
The sidebar in DBS is fully database-driven. Instead of hardcoding routes in a React component, the sidebar fetches its structure from GET /api/menus — which filters menu items based on the current user’s permissions before returning the list.
Why Database-Driven?
Without a dynamic sidebar, adding a new module requires editing the sidebar component’s JSX directly. With RBAC in the mix, that code quickly becomes a maze of conditional checks.
The database-driven approach means:
- Adding a new page = adding one record to the
Menutable (via seeding or admin UI) - Permission filtering happens on the server — users only receive the items they’re allowed to see
- No sidebar frontend code changes needed for new modules
Data Flow
1. Dashboard layout mounts
2. Sidebar component calls GET /api/menus
3. Server reads current user's permissions from session
4. Server filters Menu table: only items where user has requiredPermission
5. Returns { groups: [...menus] } JSON
6. Sidebar renders the filtered listThe Menu Data Model
// Prisma schema (simplified)
model Menu {
id Int @id @default(autoincrement())
title String
url String
icon String // Lucide icon name (e.g., "Users")
order Int @default(0)
requiredPermission String? // null = always visible
groupLabel String? // Optional section header
parentId Int? // For nested menus
parent Menu? @relation("MenuChildren", fields: [parentId], references: [id])
children Menu[] @relation("MenuChildren")
}API Endpoint
// src/app/api/menus/route.ts
import { apiGuard } from '@/lib/api-guard';
import { prisma } from '@/lib/prisma';
export async function GET() {
const guard = await apiGuard(); // session only, no specific permission
if (guard.error) return guard.error;
const { session } = guard;
const userPermissions = session.user.permissions as string[];
const isSuperAdmin = session.user.roles?.includes('super_admin');
const allMenus = await prisma.menu.findMany({
orderBy: { order: 'asc' },
where: { parentId: null }, // top-level items only
include: { children: { orderBy: { order: 'asc' } } },
});
// Filter by permission
const filtered = allMenus.filter(menu => {
if (!menu.requiredPermission) return true;
if (isSuperAdmin) return true;
return userPermissions.includes(menu.requiredPermission);
});
return Response.json({ menus: filtered });
}Adding a New Menu Item
Add the entry to prisma/seed.ts
await prisma.menu.upsert({
where: { url: '/dashboard/reports' },
update: {},
create: {
title: 'Reports',
url: '/dashboard/reports',
icon: 'BarChart2', // lucide-react icon name
order: 8,
requiredPermission: 'reports.read',
},
});Re-seed or use the admin UI
npm run db:seedCreate the corresponding page
src/app/dashboard/reports/page.tsxIcon Resolution
The icon field in the database is a string. The sidebar component dynamically resolves it to a Lucide icon:
// src/components/dashboard/Sidebar.tsx (excerpt)
import * as LucideIcons from 'lucide-react';
function SidebarIcon({ name }: { name: string }) {
const Icon = LucideIcons[name as keyof typeof LucideIcons] as LucideIcons.LucideIcon;
if (!Icon) return <LucideIcons.Circle />;
return <Icon className="h-4 w-4" />;
}Browse all available icon names at lucide.dev/icons . Use the exact PascalCase name shown (e.g., BarChart2, ShieldCheck, UsersRound).
Nested Menu Groups
For grouped sidebar sections (e.g., “Management” containing Users and Teams), use the groupLabel or parentId fields:
// A group header (no URL, just a label)
{ title: 'Management', url: '#', icon: 'Folder', order: 2 }
// Children reference the parent
{ title: 'Users', url: '/dashboard/users', parentId: managementMenuId, order: 1 }
{ title: 'Teams', url: '/dashboard/teams', parentId: managementMenuId, order: 2 }