Switch Language
Toggle Theme

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:

  1. Layout isolation: Marketing pages don’t need Sidebar, dashboard pages do—Route Groups naturally separate them
  2. Clean URLs: (dashboard) doesn’t appear in the URL, /users is just /users
  3. 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 (
    &lt;html lang="en"&gt;
      &lt;body className={inter.className}&gt;
        &lt;SidebarProvider&gt;
          {children}
        &lt;/SidebarProvider&gt;
      &lt;/body&gt;
    &lt;/html&gt;
  );
}

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 (
    &lt;&gt;
      &lt;AppSidebar /&gt;
      &lt;SidebarInset&gt;
        &lt;header className="flex h-16 shrink-0 items-center gap-2 border-b px-4"&gt;
          &lt;SidebarTrigger className="-ml-1" /&gt;
          &lt;Separator orientation="vertical" className="mr-2 h-4" /&gt;
          &lt;Breadcrumb&gt;
            &lt;BreadcrumbList&gt;
              &lt;BreadcrumbItem className="hidden md:block"&gt;
                &lt;BreadcrumbLink href="/dashboard"&gt;
                  Dashboard
                &lt;/BreadcrumbLink&gt;
              &lt;/BreadcrumbItem&gt;
              &lt;BreadcrumbSeparator className="hidden md:block" /&gt;
              &lt;BreadcrumbItem&gt;
                &lt;BreadcrumbPage&gt;Overview&lt;/BreadcrumbPage&gt;
              &lt;/BreadcrumbItem&gt;
            &lt;/BreadcrumbList&gt;
          &lt;/Breadcrumb&gt;
        &lt;/header&gt;
        &lt;main className="flex-1 p-4 pt-6"&gt;{children}&lt;/main&gt;
      &lt;/SidebarInset&gt;
    &lt;/&gt;
  );
}

This layout contains:

  1. AppSidebar: Custom sidebar component (next section)
  2. SidebarInset: Main content wrapper, automatically handles width when Sidebar collapses
  3. Header: Top navigation bar with SidebarTrigger and breadcrumbs
  4. 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&lt;{ className?: string }&gt;;
  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 (
    &lt;Sidebar&gt;
      &lt;SidebarHeader className="border-b border-border"&gt;
        &lt;Logo /&gt;
      &lt;/SidebarHeader&gt;

      &lt;SidebarContent&gt;
        &lt;SidebarGroup&gt;
          &lt;SidebarGroupLabel&gt;Navigation&lt;/SidebarGroupLabel&gt;
          &lt;SidebarGroupContent&gt;
            &lt;SidebarMenu&gt;
              {navConfig.map((item) =&gt; {
                const isActive = pathname === item.href;

                return (
                  &lt;SidebarMenuItem key={item.href}&gt;
                    &lt;SidebarMenuButton
                      asChild
                      isActive={isActive}
                      tooltip={item.title}
                    &gt;
                      &lt;Link href={item.href}&gt;
                        &lt;item.icon className="h-4 w-4" /&gt;
                        &lt;span&gt;{item.title}&lt;/span&gt;
                        {item.badge && (
                          &lt;span className="ml-auto text-xs bg-primary text-primary-foreground rounded-full px-2 py-0.5"&gt;
                            {item.badge}
                          &lt;/span&gt;
                        )}
                      &lt;/Link&gt;
                    &lt;/SidebarMenuButton&gt;
                  &lt;/SidebarMenuItem&gt;
                );
              })}
            &lt;/SidebarMenu&gt;
          &lt;/SidebarGroupContent&gt;
        &lt;/SidebarGroup&gt;
      &lt;/SidebarContent&gt;

      &lt;SidebarFooter className="border-t border-border"&gt;
        &lt;UserNav /&gt;
      &lt;/SidebarFooter&gt;
    &lt;/Sidebar&gt;
  );
}

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
&lt;Collapsible defaultOpen&gt;
  &lt;SidebarMenuItem&gt;
    &lt;CollapsibleTrigger asChild&gt;
      &lt;SidebarMenuButton&gt;
        &lt;Settings className="h-4 w-4" /&gt;
        &lt;span&gt;Settings&lt;/span&gt;
        &lt;ChevronDown className="ml-auto h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-180" /&gt;
      &lt;/SidebarMenuButton&gt;
    &lt;/CollapsibleTrigger&gt;
    &lt;CollapsibleContent&gt;
      &lt;SidebarMenuSub&gt;
        &lt;SidebarMenuSubItem&gt;
          &lt;SidebarMenuSubButton href="/settings/general"&gt;
            &lt;span&gt;General&lt;/span&gt;
          &lt;/SidebarMenuSubButton&gt;
        &lt;/SidebarMenuSubItem&gt;
        &lt;SidebarMenuSubItem&gt;
          &lt;SidebarMenuSubButton href="/settings/security"&gt;
            &lt;span&gt;Security&lt;/span&gt;
          &lt;/SidebarMenuSubButton&gt;
        &lt;/SidebarMenuSubItem&gt;
      &lt;/SidebarMenuSub&gt;
    &lt;/CollapsibleContent&gt;
  &lt;/SidebarMenuItem&gt;
&lt;/Collapsible&gt;

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&lt;{ className?: string }&gt;;
  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) =&gt; {
    if (!item.roles) return true; // No role restriction = everyone
    return item.roles.some((role) =&gt; user?.roles?.includes(role));
  });

  return (
    &lt;Sidebar&gt;
      {/* ... */}
      &lt;SidebarMenu&gt;
        {filteredNav.map((item) =&gt; {
          // ...
        })}
      &lt;/SidebarMenu&gt;
      {/* ... */}
    &lt;/Sidebar&gt;
  );
}

Now regular users won’t see the “Permissions” menu item after logging in.

Sometimes the sidebar needs external links (like docs, help center), or dividers to group menus. shadcn/ui Sidebar supports this:

&lt;SidebarGroup&gt;
  &lt;SidebarGroupLabel&gt;Main Features&lt;/SidebarGroupLabel&gt;
  &lt;SidebarGroupContent&gt;
    &lt;SidebarMenu&gt;
      {/* Main menu items */}
    &lt;/SidebarMenu&gt;
  &lt;/SidebarGroupContent&gt;
&lt;/SidebarGroup&gt;

&lt;SidebarGroup&gt;
  &lt;SidebarGroupLabel&gt;Help &amp; Support&lt;/SidebarGroupLabel&gt;
  &lt;SidebarGroupContent&gt;
    &lt;SidebarMenu&gt;
      &lt;SidebarMenuItem&gt;
        &lt;SidebarMenuButton asChild&gt;
          &lt;a href="https://docs.example.com" target="_blank" rel="noopener"&gt;
            &lt;BookOpen className="h-4 w-4" /&gt;
            &lt;span&gt;Documentation&lt;/span&gt;
            &lt;ExternalLink className="ml-auto h-3 w-3" /&gt;
          &lt;/a&gt;
        &lt;/SidebarMenuButton&gt;
      &lt;/SidebarMenuItem&gt;
      &lt;SidebarMenuItem&gt;
        &lt;SidebarMenuButton asChild&gt;
          &lt;a href="mailto:[email protected]"&gt;
            &lt;HelpCircle className="h-4 w-4" /&gt;
            &lt;span&gt;Contact Us&lt;/span&gt;
          &lt;/a&gt;
        &lt;/SidebarMenuButton&gt;
      &lt;/SidebarMenuItem&gt;
    &lt;/SidebarMenu&gt;
  &lt;/SidebarGroupContent&gt;
&lt;/SidebarGroup&gt;

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";

&lt;SidebarGroup&gt;
  &lt;SidebarGroupContent&gt;
    &lt;Command className="rounded-lg border shadow-md"&gt;
      &lt;CommandInput placeholder="Search menu..." /&gt;
      &lt;CommandList&gt;
        &lt;CommandEmpty&gt;No results found&lt;/CommandEmpty&gt;
        &lt;CommandGroup heading="Suggestions"&gt;
          {navConfig.map((item) =&gt; (
            &lt;CommandItem key={item.href} onSelect={() =&gt; router.push(item.href)}&gt;
              &lt;item.icon className="mr-2 h-4 w-4" /&gt;
              {item.title}
            &lt;/CommandItem&gt;
          ))}
        &lt;/CommandGroup&gt;
      &lt;/CommandList&gt;
    &lt;/Command&gt;
  &lt;/SidebarGroupContent&gt;
&lt;/SidebarGroup&gt;

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:

  • AppSidebar marked as "use client" (because it uses usePathname)
  • Static parts in SidebarHeader, SidebarFooter extracted 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(() =&gt; import("./admin-menu"), {
  loading: () =&gt; &lt;SidebarMenuSkeleton /&gt;,
});

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:

  1. Icon + Text: Don’t use icons alone—screen reader users won’t see them
  2. Focus visible: Don’t override default focus styles
  3. 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();

&lt;SidebarMenuButton
  onClick={() =&gt; setOpenMobile(false)}
&gt;

Q3: How to customize Sidebar width?

Use CSS variables:

&lt;Sidebar
  style={{
    "--sidebar-width": "280px",
    "--sidebar-width-mobile": "100%",
  }}
&gt;

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:

  1. Component system: Understand the responsibilities of SidebarProvider, Sidebar, SidebarContent, etc.
  2. Layout integration: Use Route Groups to isolate different layouts, put SidebarProvider in Root Layout
  3. Config-driven: Store navigation menu in config file, component renders based on it—easy to maintain
  4. Route highlighting: usePathname() + isActive prop—simple and direct
  5. RBAC: Add roles field 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. 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. 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. 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. 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. 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. 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?
shadcn/ui uses a Copy-paste model where component code lives in your project—you have full control. Ant Design is a complete design system that's ready to use but expensive to customize. If you need high customization, choose shadcn/ui; for rapid development, choose Ant Design.
Should SidebarProvider go in Root Layout or Dashboard Layout?
Put it in Root Layout (app/layout.tsx). This keeps Sidebar's collapsed state persistent across pages. If placed in Dashboard Layout, the state resets on every route change.
How do I auto-close Sidebar on mobile?
Mobile Sidebar automatically becomes a Sheet drawer. You need to call this when menu items are clicked:

```tsx
const { setOpenMobile } = useSidebar();
&lt;SidebarMenuButton onClick={() =&gt; setOpenMobile(false)}&gt;
```

This closes the drawer after clicking a menu item.
How do I customize Sidebar width?
Two approaches:

1. Use CSS variables (recommended):
```tsx
&lt;Sidebar style={{ "--sidebar-width": "280px" }} /&gt;
```

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?
Use Collapsible component to wrap SidebarMenuItem:

• 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?
Built on Radix UI with complete accessibility support: keyboard navigation (Tab/arrow keys), ARIA attributes, Focus management. You just need to ensure icons and text are both present—don't use icons alone.

References

8 min read · Published on: Mar 27, 2026 · Modified on: Mar 27, 2026

Comments

Sign in with GitHub to leave a comment

Related Posts