A dashboard layout needs a sidebar for navigation, a header, and a main content area. Here is how to build one that collapses on mobile and persists across routes.
Step 1: Route Structure
app/
dashboard/
layout.tsx ← Dashboard shell
page.tsx ← Dashboard home
loading.tsx ← Loading state
analytics/
page.tsx
settings/
page.tsx
customers/
page.tsx
[id]/
page.tsx
Step 2: Define Navigation Items
// lib/dashboard-nav.ts
export type NavItem = {
label: string;
href: string;
icon: string;
badge?: number;
};
export const navItems: NavItem[] = [
{ label: "Dashboard", href: "/dashboard", icon: "home" },
{ label: "Analytics", href: "/dashboard/analytics", icon: "chart" },
{ label: "Customers", href: "/dashboard/customers", icon: "users", badge: 12 },
{ label: "Orders", href: "/dashboard/orders", icon: "package" },
{ label: "Products", href: "/dashboard/products", icon: "box" },
{ label: "Settings", href: "/dashboard/settings", icon: "settings" },
];
Step 3: Build the Sidebar
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { navItems } from "@/lib/dashboard-nav";
import { cn } from "@/lib/utils";
export function Sidebar({
collapsed,
onClose,
}: {
collapsed: boolean;
onClose?: () => void;
}) {
const pathname = usePathname();
return (
<aside
className={cn(
"flex h-full flex-col border-r bg-white dark:border-gray-800 dark:bg-gray-950",
collapsed ? "w-16" : "w-64"
)}
>
{/* Logo */}
<div className="flex h-16 items-center border-b px-4 dark:border-gray-800">
<Link href="/dashboard" className="flex items-center gap-2">
<div className="h-8 w-8 rounded-lg bg-blue-600" />
{!collapsed && (
<span className="text-lg font-bold">Dashboard</span>
)}
</Link>
</div>
{/* Navigation */}
<nav className="flex-1 overflow-y-auto p-3">
<ul className="space-y-1">
{navItems.map((item) => {
const isActive =
item.href === "/dashboard"
? pathname === "/dashboard"
: pathname.startsWith(item.href);
return (
<li key={item.href}>
<Link
href={item.href}
onClick={onClose}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400"
: "text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
)}
>
<NavIcon name={item.icon} className="h-5 w-5 flex-shrink-0" />
{!collapsed && (
<>
<span className="flex-1">{item.label}</span>
{item.badge && (
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs text-blue-600 dark:bg-blue-900/30">
{item.badge}
</span>
)}
</>
)}
</Link>
</li>
);
})}
</ul>
</nav>
{/* User */}
<div className="border-t p-3 dark:border-gray-800">
<div className="flex items-center gap-3 rounded-lg px-3 py-2">
<div className="h-8 w-8 rounded-full bg-gray-200 dark:bg-gray-700" />
{!collapsed && (
<div className="flex-1 truncate">
<p className="text-sm font-medium">John Doe</p>
<p className="text-xs text-gray-500">john@example.com</p>
</div>
)}
</div>
</div>
</aside>
);
}
Step 4: Build the Header
"use client";
export function DashboardHeader({
onMenuToggle,
}: {
onMenuToggle: () => void;
}) {
return (
<header className="flex h-16 items-center justify-between border-b px-4 dark:border-gray-800 lg:px-6">
{/* Mobile menu button */}
<button
onClick={onMenuToggle}
className="rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-gray-800 lg:hidden"
aria-label="Toggle menu"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
{/* Search */}
<div className="flex-1 px-4">
<div className="relative max-w-md">
<svg className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
placeholder="Search..."
className="w-full rounded-lg border bg-gray-50 py-2 pl-10 pr-4 text-sm dark:border-gray-700 dark:bg-gray-800"
/>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<button className="relative rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-gray-800">
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
<span className="absolute right-1.5 top-1.5 h-2 w-2 rounded-full bg-red-500" />
</button>
</div>
</header>
);
}
Step 5: Dashboard Layout
// app/dashboard/layout.tsx
"use client";
import { useState } from "react";
import { Sidebar } from "@/components/dashboard/Sidebar";
import { DashboardHeader } from "@/components/dashboard/Header";
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [collapsed, setCollapsed] = useState(false);
return (
<div className="flex h-screen bg-gray-50 dark:bg-gray-900">
{/* Desktop sidebar */}
<div className="hidden lg:block">
<Sidebar collapsed={collapsed} />
</div>
{/* Mobile sidebar overlay */}
{sidebarOpen && (
<div className="fixed inset-0 z-40 lg:hidden">
<div
className="absolute inset-0 bg-black/50"
onClick={() => setSidebarOpen(false)}
/>
<div className="relative z-50 h-full w-64">
<Sidebar collapsed={false} onClose={() => setSidebarOpen(false)} />
</div>
</div>
)}
{/* Main content */}
<div className="flex flex-1 flex-col overflow-hidden">
<DashboardHeader onMenuToggle={() => setSidebarOpen(!sidebarOpen)} />
<main className="flex-1 overflow-y-auto p-4 lg:p-6">{children}</main>
</div>
</div>
);
}
Step 6: Dashboard Home Page
// app/dashboard/page.tsx
export default function DashboardHome() {
return (
<div>
<h1 className="text-2xl font-bold">Dashboard</h1>
{/* Stats grid */}
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{[
{ label: "Total Revenue", value: "$45,231", change: "+12%" },
{ label: "New Customers", value: "2,350", change: "+8%" },
{ label: "Active Projects", value: "12", change: "+2" },
{ label: "Conversion Rate", value: "3.2%", change: "+0.5%" },
].map((stat) => (
<div
key={stat.label}
className="rounded-lg border bg-white p-6 dark:border-gray-800 dark:bg-gray-950"
>
<p className="text-sm text-gray-500">{stat.label}</p>
<p className="mt-1 text-2xl font-bold">{stat.value}</p>
<p className="mt-1 text-sm text-green-600">{stat.change}</p>
</div>
))}
</div>
</div>
);
}
Responsive Behavior
- Desktop (lg+): Fixed sidebar + scrollable content area
- Tablet: Collapsed sidebar (icons only) or hidden with toggle
- Mobile: Sidebar hidden by default, opens as overlay when menu button is tapped
Need a Custom Dashboard?
We build custom dashboards and admin interfaces for SaaS products and business tools. Contact us for a consultation.