DEV Community

David Madrid
David Madrid

Posted on • Edited on

Role-Based Sidebar Navigation in React Applications

Building dynamic sidebars is far from trivial. A maintainable and scalable solution requires a modern, well-structured approach — especially when dealing with multiple user roles and navigation scenarios. Without solid architecture, hardcoded components quickly become unmanageable as your application grows.

Instead of relying on hardcoded items, you can define a sidebar configuration as data and render items dynamically based on user roles. Here’s a clean and extensible approach using TypeScript:

type SidebarItem = {
  id: string
  title: string
  icon: keyof typeof Icons
  to: string
  allowedRoles: UserRole[]
  activeUrls?: string[]
}
Enter fullscreen mode Exit fullscreen mode
const sidebarItems: SidebarItem[] = [
  {
    id: "dashboard",
    title: t("labels.dashboard"),
    icon: "dashboard",
    to: "/dashboard",
    allowedRoles: [UserRole.COLLABORATOR, UserRole.USER, UserRole.WORKER],
  },
  {
    id: "myJobs",
    title: t("labels.myJobs"),
    icon: "briefcase",
    to: "/my-jobs/current",
    activeUrls: ["/my-jobs/current", "/my-jobs/draft", "/my-jobs/completed"],
    allowedRoles: [UserRole.COLLABORATOR, UserRole.USER, UserRole.WORKER],
  },
  {
    id: "exploreServices",
    title: t("labels.exploreServices"),
    icon: "search",
    to: "/post-a-job",
    allowedRoles: [UserRole.USER],
  },
  {
    id: "myProfile",
    title: t("labels.myProfile"),
    icon: "user",
    to: "/my-profile",
    allowedRoles: [UserRole.WORKER],
  },
]
Enter fullscreen mode Exit fullscreen mode

The SidebarItem type defines each item's structure — including id, title, icon, destination route (to), and the allowedRoles that determine visibility. The optional activeUrls helps match nested routes to highlight the correct item.

🔧 Icons is a key-value object where each key (e.g., "dashboard", "briefcase") maps to a corresponding icon component. This allows dynamic rendering like const Icon = Icons[item.icon].

By defining each item in a structured array, you're free to map over them and render only what the current user is allowed to see:

{sidebarItems.map(item => {
  const Icon = Icons[item.icon]

  return (
    item.allowedRoles?.includes(user?.role as UserRole) && (
      <Tooltip key={item.to}>
        <TooltipTrigger asChild>
          <Button
            asChild
            variant="ghost"
            className={cn(
              "text-sm",
              open && "justify-start",
              (location.pathname === item.to ||
                item.activeUrls?.includes(location.pathname)) &&
                "bg-accent"
            )}
          >
            <Link to={item.to}>
              <Icon className={cn("h-5 w-5 shrink-0", open && "mr-2")} />
              <span className={open ? "block" : "hidden"}>
                {item.title}
              </span>
            </Link>
          </Button>
        </TooltipTrigger>
        <TooltipContent side="right" className={open ? "hidden" : ""}>
          <p>{item.title}</p>
        </TooltipContent>
      </Tooltip>
    )
  )
})}

Enter fullscreen mode Exit fullscreen mode
  • This conditionally renders each sidebar item only if the current user's role is included in the allowedRoles array for that item.
  • item.allowedRoles?.includes(...) checks whether user.role exists in the allowedRoles array. The optional chaining (?.) prevents errors if allowedRoles is undefined.
  • user?.role as UserRole safely accesses the user's role and asserts its type in TypeScript.

This setup is flexible enough to support localization, permissions, tooltips, role conditions, active state tracking, and more — all without making your component hard to read or extend.

Top comments (0)