Next Starter Logo
Components

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/files

Layout File

app/dashboard/layout.tsx is a Server Component that:

  • Fetches the session with getSession() and redirects to sign-in if unauthenticated
  • Exports page metadata via generateMeta() with noIndex: true so search engines skip dashboard pages
  • Wraps the layout in SubscriptionProvider for 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.

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 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:

GroupPositionVisibility
mainTop of sidebarAll users
adminMiddle, labeled "ADMIN"Admin users only
secondaryBottom of content areaAll 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 /dashboard exactly: pathname === "/dashboard"
  • For all other items: pathname === item.href or pathname.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.

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.

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 as BreadcrumbPage (non-clickable)
  • For deeper routes, a hardcoded BreadcrumbLink pointing to /dashboard always renders as the first item. The dashboard segment 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 are BreadcrumbLink

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 SidebarTrigger is tapped
  • The header shows a centered logo via DashboardMobileHeader, along with a MobileNav menu on the right
  • The breadcrumb is hidden
  • Main content padding adjusts: p-2 on mobile, p-6 on 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.

On this page