Dashboard Layout
How the dashboard sidebar, breadcrumbs, mobile header, and nested layouts work together in Next Starter, including auth gating, admin-only nav items, and responsive behavior.
Overview
The dashboard uses a persistent sidebar layout provided by the shadcn/ui Sidebar primitive. The layout is defined in app/dashboard/layout.tsx and applies to every route under /dashboard.
The top-level structure is:
app/dashboard/
├── layout.tsx # Root dashboard layout (sidebar + header)
├── page.tsx # /dashboard home page
├── billing/
│ └── page.tsx # /dashboard/billing
├── settings/
│ └── page.tsx # /dashboard/settings
├── profile/
│ ├── page.tsx # /dashboard/profile
│ ├── change-email/
│ │ └── page.tsx # /dashboard/profile/change-email
│ └── change-password/
│ └── page.tsx # /dashboard/profile/change-password
└── (admin)/ # Route group — no URL segment
├── layout.tsx # Admin role check — redirects non-admins to /dashboard
├── users/
│ └── page.tsx # /dashboard/users
└── files/
└── page.tsx # /dashboard/filesLayout File
app/dashboard/layout.tsx is a Server Component that:
- Fetches the session with
getSession()and redirects to sign-in if unauthenticated - Exports page
metadataviagenerateMeta()withnoIndex: trueso search engines skip dashboard pages - Wraps the layout in
SubscriptionProviderfor billing state - Renders the sidebar, header bar, and main content area
// app/dashboard/layout.tsx
import { redirect } from "next/navigation";
import { getSession } from "@/lib/server/auth-helpers";
import { DashboardSidebar } from "@/components/dashboard/sidebar";
import { DashboardMobileHeader } from "@/components/dashboard/mobile-header";
import {
SidebarProvider,
SidebarInset,
SidebarTrigger,
} from "@/components/ui/sidebar";
import { Separator } from "@/components/ui/separator";
import { DashboardBreadcrumb } from "@/components/dashboard/breadcrumb";
import { SubscriptionProvider } from "@/components/subscription-provider";
import { generateMeta } from "@/lib/config";
import type { Metadata } from "next";
export const metadata: Metadata = generateMeta({
title: "Dashboard",
description: "Manage your account and settings",
pathname: "/dashboard",
noIndex: true,
});
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getSession();
if (!session) redirect("/auth/sign-in");
return (
<SubscriptionProvider>
<SidebarProvider>
<DashboardSidebar session={session} />
<SidebarInset>
<header className="relative z-50 flex h-16 shrink-0 items-center border-b bg-background px-2 sm:px-6">
<SidebarTrigger className="-ml-1" />
<div className="hidden md:flex md:items-center">
<Separator orientation="vertical" className="mx-2 h-4" />
<DashboardBreadcrumb />
</div>
<DashboardMobileHeader user={session.user} />
</header>
<main className="flex flex-1 flex-col p-2 sm:p-6">{children}</main>
</SidebarInset>
</SidebarProvider>
</SubscriptionProvider>
);
}SidebarProvider manages open/closed state. SidebarInset is the main content area that sits beside the sidebar on desktop and expands to full width on mobile.
Sidebar Component
The sidebar lives in components/dashboard/sidebar.tsx. It is a Client Component because it reads the current pathname, handles navigation events, and manages mobile open state.
Navigation items
Navigation items are defined as a typed array inside components/dashboard/sidebar.tsx. This array is self-contained in the sidebar file. It is not imported from lib/navigation.ts, which defines public site nav links and user menu items for the marketing pages.
const navigationItems: NavigationItem[] = [
{ name: "Dashboard", href: "/dashboard", icon: Home, group: "main" },
{ name: "Files", href: "/dashboard/files", icon: Files, group: "admin" },
{ name: "Users", href: "/dashboard/users", icon: Users, group: "admin" },
{
name: "Billing",
href: "/dashboard/billing",
icon: CreditCard,
group: "secondary",
},
{
name: "Settings",
href: "/dashboard/settings",
icon: Settings,
group: "secondary",
},
];Items are grouped into three sections:
| Group | Position | Visibility |
|---|---|---|
main | Top of sidebar | All users |
admin | Middle, labeled "ADMIN" | Admin users only |
secondary | Bottom of content area | All users |
Adding a new sidebar item
Add an entry to navigationItems in components/dashboard/sidebar.tsx:
import { BarChart } from "lucide-react";
const navigationItems: NavigationItem[] = [
// ... existing items
{
name: "Analytics",
href: "/dashboard/analytics",
icon: BarChart,
group: "main", // or "admin" or "secondary"
},
];Then create the corresponding page at app/dashboard/analytics/page.tsx. The sidebar automatically highlights the active item based on the current pathname.
Active state
An item is considered active when:
- For
/dashboardexactly:pathname === "/dashboard" - For all other items:
pathname === item.hreforpathname.startsWith(item.href + "/")
A parent item like /dashboard/users stays active when the user navigates to a nested route like /dashboard/users/abc-123.
Admin-only items
The sidebar reads session.user.role to determine whether to show the "admin" group:
const isAdmin = session?.user?.role === "admin";
const navGroups = {
main: byGroup("main"),
admin: isAdmin ? byGroup("admin") : [],
secondary: byGroup("secondary"),
};Admin routes inside the (admin) route group are protected by app/dashboard/(admin)/layout.tsx, which re-validates the session server-side and redirects any non-admin user to /dashboard. Hiding admin items in the sidebar is a UI convenience only. The layout is the real enforcement gate.
Sidebar footer
The footer contains a DropdownMenu triggered by the user's avatar and name. It shows:
- Profile link
- Theme toggle (light/dark)
- Sign out
The subscription plan name appears below the user's email in the trigger button (the always-visible footer area). It loads from SubscriptionProvider and displays a skeleton while loading.
Breadcrumb Navigation
components/dashboard/breadcrumb.tsx is a Client Component that reads usePathname() and generates breadcrumb items automatically.
/dashboard -> Dashboard (non-clickable BreadcrumbPage)
/dashboard/settings -> Dashboard > Settings
/dashboard/users/550e8400-e29b-41d4-a716-446655440000 -> Dashboard > Users (UUID segments are stripped)Behavior:
- At
/dashboard, the single "Dashboard" item renders asBreadcrumbPage(non-clickable) - For deeper routes, a hardcoded
BreadcrumbLinkpointing to/dashboardalways renders as the first item. Thedashboardsegment is filtered out of the path array so it does not appear again in the mapped items - Each remaining URL segment becomes a breadcrumb item with title-cased display text (hyphens replaced with spaces)
- UUID segments are filtered out so resource IDs do not appear in the trail
- The last segment renders as
BreadcrumbPage(non-clickable); all preceding segments areBreadcrumbLink
The breadcrumb is only visible on md screens and above (hidden md:flex). Mobile uses a centered logo and a MobileNav overlay menu in the header instead.
Mobile Responsive Behavior
On small screens (below md):
- The sidebar is hidden and slides in as a sheet when
SidebarTriggeris tapped - The header shows a centered logo via
DashboardMobileHeader, along with aMobileNavmenu on the right - The breadcrumb is hidden
- Main content padding adjusts:
p-2on mobile,p-6on desktop (p-2 sm:p-6)
When a sidebar link is tapped on mobile, the sidebar closes automatically:
const { setOpenMobile, isMobile } = useSidebar();
const handleMobileClose = () => isMobile && setOpenMobile(false);
// Used in each Link's onClick
<Link href={item.href} onClick={handleMobileClose}>Nested Layouts
Pages within the dashboard can define their own nested layouts without affecting the outer sidebar layout.
For example, to add a layout shared only by profile-related pages:
app/dashboard/profile/
├── layout.tsx # Nested layout (tabs, sub-nav, etc.)
├── page.tsx # /dashboard/profile
└── edit/
└── page.tsx # /dashboard/profile/edit// app/dashboard/profile/layout.tsx
export default function ProfileLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="max-w-2xl">
{/* profile-specific tabs or secondary nav here */}
{children}
</div>
);
}Nested layouts compose naturally. The outer dashboard/layout.tsx provides the sidebar and header. The inner profile/layout.tsx adds layout only within the profile section. No session check is needed in nested layouts because the parent layout already redirects unauthenticated users.
Route Groups
The (admin) directory is a Next.js route group. The parentheses mean the segment is not part of the URL. Routes inside resolve to /dashboard/users and /dashboard/files, not /dashboard/(admin)/users.
The (admin) group has a layout.tsx that enforces admin access server-side:
// app/dashboard/(admin)/layout.tsx
import { redirect } from "next/navigation";
import { getSession } from "@/lib/server/auth-helpers";
export default async function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getSession();
if (!session) redirect("/auth/sign-in");
if (session.user.role !== "admin") redirect("/dashboard");
return <>{children}</>;
}Any page added inside (admin)/ is automatically protected. No per-page role check is needed. Place the route in the group and the layout handles enforcement.
File Uploads
Upload files directly from the browser to Cloudflare R2 using presigned URLs. Includes server utilities, a client image pipeline, and an admin file manager.
UI Components
Pre-installed shadcn/ui components in Next Starter, including theming with CSS variables, Tailwind CSS v4, the cn() utility, and how to add new components with the shadcn CLI.