Building Admin Skeleton with shadcn/ui: Sidebar + Layout Best Practices
A step-by-step guide to building a scalable admin dashboard skeleton, from Sidebar components to Next.js Layout integration, with complete code you can use right away.
Last week I picked up an admin dashboard project, and my first thought was shadcn/ui. Honestly, I’ve used Ant Design and MUI before, and styling always felt like a struggle—either overriding piles of styles or being locked into the framework’s design philosophy.
shadcn/ui is different. It’s a “Copy-paste” model—the code goes straight into your project, and you can modify it however you want. After two weeks of using it, I have to say it’s pretty great. Especially the Sidebar component combined with Next.js App Router—building an admin skeleton became remarkably clean.
This article documents my practical experience. Starting from scratch, I’ll walk you through building a scalable admin layout.
1. Why Choose shadcn/ui Sidebar?
Let me start with the pitfalls I’ve encountered.
Before this, I used Ant Design Pro. Out of the box, it’s great, but as the project grows, headaches appear: changing the sidebar style means digging through documentation for hours; trying to add custom interactions reveals the framework’s limitations. MUI has similar issues—theme customization is flexible, but only if you know how to write Material Design.
Pain Points of Traditional Solutions
Ant Design: Feature-complete, but customization is expensive. Want to change a sidebar’s border radius? You might need three layers of style overrides.
MUI: Complete design system, but steep learning curve. The Styled Components approach takes new team members about a week to pick up.
Build Your Own: Fully controllable, but writing a sidebar from scratch with responsive design, accessibility, and keyboard navigation? That’s at least three days of work.
shadcn/ui’s Approach
shadcn/ui takes a different path:
- Copy-paste model: Component code lives in your project, no black-box dependencies
- Radix UI foundation: Accessibility built-in, keyboard navigation and ARIA attributes handled for you
- Tailwind CSS-driven: Styles are just class names, modify them freely without style override headaches
I’ve seen many teams migrate from MUI to shadcn/ui for one simple reason: they want control, not an out-of-the-box template.
Use Cases
If you’re building:
- Small to medium admin dashboards
- SaaS product consoles
- Internal tools or operations platforms
shadcn/ui Sidebar is worth trying. It won’t give you a complete admin template, but it will give you a flexible enough skeleton.
2. Sidebar Component Architecture
Before diving in, let’s understand the shadcn/ui Sidebar component system. This part is well-documented in the official docs, so I’ll cover it quickly.
Core Component List
Sidebar consists of a complete set of components, each with its own responsibility:
SidebarProvider // State context, wraps the entire app
Sidebar // Sidebar container
SidebarHeader // Fixed top area, for Logo
SidebarContent // Scrollable content area, for menu
SidebarGroup // Menu grouping
SidebarMenu // Menu list
SidebarMenuItem // Menu item
SidebarMenuButton // Menu button (supports Link)
SidebarFooter // Fixed bottom area, for user info
SidebarTrigger // Collapse/expand button
SidebarInset // Main content wrapper
It looks like a lot of components, but the relationships are clear:
SidebarProvider
├── Sidebar
│ ├── SidebarHeader
│ ├── SidebarContent
│ │ └── SidebarGroup
│ │ └── SidebarMenu
│ │ └── SidebarMenuItem
│ │ └── SidebarMenuButton
│ └── SidebarFooter
└── SidebarInset
└── {children}
State Management
Sidebar’s collapsed state is managed by SidebarProvider. There are two modes:
Uncontrolled mode (recommended):
<SidebarProvider defaultOpen={true}>
<Sidebar />
</SidebarProvider>
Controlled mode:
const [open, setOpen] = useState(true);
<SidebarProvider open={open} onOpenChange={setOpen}>
<Sidebar />
</SidebarProvider>
Most of the time, uncontrolled mode is enough. Use controlled mode only if you need to control Sidebar state elsewhere (like a toggle in user settings).
Responsive Design
Sidebar has built-in responsive support:
- Desktop: Fixed on the left side, can be collapsed via
SidebarTrigger - Mobile: Automatically becomes a drawer (Sheet), pops up when Trigger is clicked
This logic is handled internally—you just need to configure it in SidebarProvider, and the component does the rest.
3. Next.js Layout Integration
Alright, core concepts covered. Now for the main event—integrating Sidebar into Next.js’s Layout system.
3.1 Project Structure Design
I recommend using Next.js Route Groups to organize layouts. This allows different pages to have different layouts without affecting URL structure.
app/
├── layout.tsx # Root Layout (global)
├── (marketing)/ # Marketing page group (Landing, About)
│ ├── layout.tsx # No Sidebar
│ └── page.tsx # Homepage
├── (dashboard)/ # Dashboard page group
│ ├── layout.tsx # Layout with Sidebar
│ ├── page.tsx # Dashboard main
│ ├── users/
│ │ └── page.tsx # User management
│ └── settings/
│ └── page.tsx # System settings
└── (auth)/ # Auth page group
├── layout.tsx # Centered layout
├── login/
│ └── page.tsx # Login page
└── register/
└── page.tsx # Register page
Benefits of this structure:
- Layout isolation: Marketing pages don’t need Sidebar, dashboard pages do—Route Groups naturally separate them
- Clean URLs:
(dashboard)doesn’t appear in the URL,/usersis just/users - Easy to extend: Adding a new page group just means creating a new folder
3.2 Root Layout Configuration
Root Layout is the app’s entry point. Here we configure global things: theme, fonts, SidebarProvider.
// app/layout.tsx
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { SidebarProvider } from "@/components/ui/sidebar";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Admin Dashboard",
description: "Built with shadcn/ui and Next.js",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>
<SidebarProvider>
{children}
</SidebarProvider>
</body>
</html>
);
}
Notice SidebarProvider is in Root Layout, not Dashboard Layout. This keeps Sidebar state persistent across pages (e.g., navigating from /users to /settings won’t lose collapse state).
3.3 Dashboard Layout Implementation
Dashboard Layout is the core admin layout, where Sidebar is introduced.
// app/(dashboard)/layout.tsx
import { AppSidebar } from "@/components/app-sidebar";
import { SidebarInset, SidebarTrigger } from "@/components/ui/sidebar";
import { Separator } from "@/components/ui/separator";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
<AppSidebar />
<SidebarInset>
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem className="hidden md:block">
<BreadcrumbLink href="/dashboard">
Dashboard
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="hidden md:block" />
<BreadcrumbItem>
<BreadcrumbPage>Overview</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</header>
<main className="flex-1 p-4 pt-6">{children}</main>
</SidebarInset>
</>
);
}
This layout contains:
- AppSidebar: Custom sidebar component (next section)
- SidebarInset: Main content wrapper, automatically handles width when Sidebar collapses
- Header: Top navigation bar with SidebarTrigger and breadcrumbs
- Main: Main content area
3.4 AppSidebar Component Implementation
Now let’s implement the sidebar itself. I recommend a config-driven approach: navigation menu lives in a config file, and the component renders based on it.
First, define the navigation config:
// lib/navigation.ts
import {
Home,
Users,
Settings,
FileText,
BarChart3,
Shield,
} from "lucide-react";
export interface NavItem {
title: string;
href: string;
icon: React.ComponentType<{ className?: string }>;
badge?: string;
}
export const navConfig: NavItem[] = [
{
title: "Overview",
href: "/dashboard",
icon: Home,
},
{
title: "User Management",
href: "/users",
icon: Users,
badge: "12", // Badge
},
{
title: "Analytics",
href: "/analytics",
icon: BarChart3,
},
{
title: "Content",
href: "/content",
icon: FileText,
},
{
title: "Settings",
href: "/settings",
icon: Settings,
},
{
title: "Permissions",
href: "/permissions",
icon: Shield,
},
];
Then implement AppSidebar:
// components/app-sidebar.tsx
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { navConfig } from "@/lib/navigation";
import { Logo } from "@/components/logo";
import { UserNav } from "@/components/user-nav";
export function AppSidebar() {
const pathname = usePathname();
return (
<Sidebar>
<SidebarHeader className="border-b border-border">
<Logo />
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Navigation</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{navConfig.map((item) => {
const isActive = pathname === item.href;
return (
<SidebarMenuItem key={item.href}>
<SidebarMenuButton
asChild
isActive={isActive}
tooltip={item.title}
>
<Link href={item.href}>
<item.icon className="h-4 w-4" />
<span>{item.title}</span>
{item.badge && (
<span className="ml-auto text-xs bg-primary text-primary-foreground rounded-full px-2 py-0.5">
{item.badge}
</span>
)}
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="border-t border-border">
<UserNav />
</SidebarFooter>
</Sidebar>
);
}
Key point here: route highlighting. I use usePathname() to get the current path, then compare it with item.href. When matched, I pass isActive={true} to SidebarMenuButton, and the component applies the active style automatically.
3.5 Multi-level Menu Implementation
If your dashboard has submenus, you can wrap SidebarGroup with Collapsible:
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { ChevronDown } from "lucide-react";
// Inside SidebarMenu
<Collapsible defaultOpen>
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton>
<Settings className="h-4 w-4" />
<span>Settings</span>
<ChevronDown className="ml-auto h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-180" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
<SidebarMenuSubItem>
<SidebarMenuSubButton href="/settings/general">
<span>General</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
<SidebarMenuSubItem>
<SidebarMenuSubButton href="/settings/security">
<span>Security</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
4. Advanced Features
Basic layout is set up. Let’s look at some practical advanced features.
4.1 Role-Based Access Control (RBAC)
Many dashboards need to show different menus based on user roles. Implementation is straightforward: add a roles field to the navigation config, then filter during render.
First, update the config:
// lib/navigation.ts
export interface NavItem {
title: string;
href: string;
icon: React.ComponentType<{ className?: string }>;
roles?: string[]; // Allowed roles
}
export const navConfig: NavItem[] = [
{
title: "Overview",
href: "/dashboard",
icon: Home,
// No roles = everyone can see
},
{
title: "User Management",
href: "/users",
icon: Users,
roles: ["admin", "manager"], // Only admin and manager
},
{
title: "Permissions",
href: "/permissions",
icon: Shield,
roles: ["admin"], // Only admin
},
];
Then filter by user role in AppSidebar:
// components/app-sidebar.tsx
import { useAuth } from "@/hooks/use-auth"; // Your auth hook
export function AppSidebar() {
const pathname = usePathname();
const { user } = useAuth(); // Get current user
const filteredNav = navConfig.filter((item) => {
if (!item.roles) return true; // No role restriction = everyone
return item.roles.some((role) => user?.roles?.includes(role));
});
return (
<Sidebar>
{/* ... */}
<SidebarMenu>
{filteredNav.map((item) => {
// ...
})}
</SidebarMenu>
{/* ... */}
</Sidebar>
);
}
Now regular users won’t see the “Permissions” menu item after logging in.
4.2 External Links and Dividers
Sometimes the sidebar needs external links (like docs, help center), or dividers to group menus. shadcn/ui Sidebar supports this:
<SidebarGroup>
<SidebarGroupLabel>Main Features</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{/* Main menu items */}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Help & Support</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild>
<a href="https://docs.example.com" target="_blank" rel="noopener">
<BookOpen className="h-4 w-4" />
<span>Documentation</span>
<ExternalLink className="ml-auto h-3 w-3" />
</a>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild>
<a href="mailto:[email protected]">
<HelpCircle className="h-4 w-4" />
<span>Contact Us</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
4.3 Search Box and Quick Actions
Many dashboards have a search box in the sidebar, or global search (Cmd+K). shadcn/ui has a Command component for this:
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command";
<SidebarGroup>
<SidebarGroupContent>
<Command className="rounded-lg border shadow-md">
<CommandInput placeholder="Search menu..." />
<CommandList>
<CommandEmpty>No results found</CommandEmpty>
<CommandGroup heading="Suggestions">
{navConfig.map((item) => (
<CommandItem key={item.href} onSelect={() => router.push(item.href)}>
<item.icon className="mr-2 h-4 w-4" />
{item.title}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</SidebarGroupContent>
</SidebarGroup>
5. Performance Optimization & Best Practices
Let’s wrap up with some practical optimization tips.
Server Components First
Next.js App Router defaults all components to Server Components. The static parts of Sidebar (like Logo, fixed menu items) can stay as Server Components—only interactive parts (route highlighting, collapse state) need "use client".
My approach:
AppSidebarmarked as"use client"(because it usesusePathname)- Static parts in
SidebarHeader,SidebarFooterextracted to separate Server Components - Navigation config generated on the server, passed to client component
This reduces client JS bundle size.
Lazy Loading Large Menus
If your dashboard has dozens of menu items, consider lazy loading. Use React.lazy or Next.js dynamic:
import dynamic from "next/dynamic";
const AdminMenu = dynamic(() => import("./admin-menu"), {
loading: () => <SidebarMenuSkeleton />,
});
Honestly though, most dashboards don’t have that many menu items, so this optimization is rarely needed.
Accessibility Points
shadcn/ui’s Sidebar is built on Radix UI, so accessibility is mostly built-in. Still, keep these in mind:
- Icon + Text: Don’t use icons alone—screen reader users won’t see them
- Focus visible: Don’t override default focus styles
- Keyboard navigation: Tab and arrow keys should work properly
Radix UI handles most of this, but if you customize components, remember to test keyboard navigation.
6. Common Issues
Q1: Sidebar state lost after refresh?
If you put SidebarProvider in Dashboard Layout instead of Root Layout, the state resets on route changes. Move the Provider to Root Layout.
Q2: How to auto-close Sidebar on mobile?
shadcn/ui Sidebar automatically becomes a Sheet on mobile. You need to manually close it when menu items are clicked:
const { setOpenMobile } = useSidebar();
<SidebarMenuButton
onClick={() => setOpenMobile(false)}
>
Q3: How to customize Sidebar width?
Use CSS variables:
<Sidebar
style={{
"--sidebar-width": "280px",
"--sidebar-width-mobile": "100%",
}}
>
Or modify the SIDEBAR_WIDTH constant in sidebar.tsx.
Summary
shadcn/ui Sidebar combined with Next.js Layout makes building admin skeletons genuinely efficient. Key takeaways:
- Component system: Understand the responsibilities of SidebarProvider, Sidebar, SidebarContent, etc.
- Layout integration: Use Route Groups to isolate different layouts, put
SidebarProviderin Root Layout - Config-driven: Store navigation menu in config file, component renders based on it—easy to maintain
- Route highlighting:
usePathname()+isActiveprop—simple and direct - RBAC: Add
rolesfield to config, filter during render
I’ve used this architecture across several projects, and the extensibility is solid. Adding a new page just means adding one entry to navConfig—the component handles the rest.
Questions? Drop a comment. Next time I’ll cover shadcn/ui DataTable in practice—stay tuned.
Building shadcn/ui Sidebar + Next.js Layout Admin Skeleton
Build a scalable admin dashboard layout from scratch, including sidebar, route highlighting, and RBAC
⏱️ Estimated time: 45 min
- 1
Step1: Install shadcn/ui and add Sidebar component
Run CLI commands to initialize project and add component:
```bash
npx shadcn@latest init
npx shadcn@latest add sidebar
```
The installation will ask about style configuration—default is fine. After completion, sidebar.tsx will be generated in the components/ui directory. - 2
Step2: Configure Root Layout
Wrap SidebarProvider in app/layout.tsx:
• Import SidebarProvider component
• Wrap {children} inside body tag
• Set lang="en" attribute
This ensures Sidebar state persists globally. - 3
Step3: Create Dashboard Layout
Create admin-specific layout in app/(dashboard)/layout.tsx:
• Use Route Groups syntax (dashboard)
• Import AppSidebar and SidebarInset
• Add top Header with breadcrumb navigation
Route Groups don't affect URL structure—the /dashboard path maps directly to root. - 4
Step4: Define Navigation Config
Create lib/navigation.ts config file:
• Define NavItem interface (title, href, icon, badge)
• Export navConfig array
• Optionally add roles field for RBAC
Config-driven approach means adding menus requires changing just one place. - 5
Step5: Implement AppSidebar Component
Create components/app-sidebar.tsx:
• Mark with "use client" for client component
• Get current route via usePathname
• Map through navConfig to render menu items
• Set isActive prop when route matches for highlighting - 6
Step6: Add RBAC (Optional)
Implement role-based filtering:
• Add roles field to NavItem interface
• Get user roles via useAuth in AppSidebar
• Use filter method on menu items
Menus without roles field are visible to all users by default.
FAQ
What's the difference between shadcn/ui Sidebar and Ant Design sidebar?
Should SidebarProvider go in Root Layout or Dashboard Layout?
How do I auto-close Sidebar on mobile?
```tsx
const { setOpenMobile } = useSidebar();
<SidebarMenuButton onClick={() => setOpenMobile(false)}>
```
This closes the drawer after clicking a menu item.
How do I customize Sidebar width?
1. Use CSS variables (recommended):
```tsx
<Sidebar style={{ "--sidebar-width": "280px" }} />
```
2. Modify the SIDEBAR_WIDTH constant in sidebar.tsx
CSS variables are more flexible—you can set different widths for different Sidebars.
How do I implement multi-level menus?
• CollapsibleTrigger contains the first-level menu button
• CollapsibleContent contains SidebarMenuSub and second-level items
• ChevronDown icon indicates expandable state
See section 3.5 for complete code.
How is shadcn/ui Sidebar's accessibility?
References
8 min read · Published on: Mar 27, 2026 · Modified on: Mar 27, 2026
Related Posts
Tailwind Responsive Layout in Practice: Container Queries and Breakpoint Strategies
Tailwind Responsive Layout in Practice: Container Queries and Breakpoint Strategies
Ubuntu Server Initialization: User Management, SSH Hardening, and fail2ban Security Setup
Ubuntu Server Initialization: User Management, SSH Hardening, and fail2ban Security Setup
What is shadcn/ui? How to Choose Between MUI, Chakra, and Ant Design

Comments
Sign in with GitHub to leave a comment