Next Starter Logo
Components

Dashboard Layout

How the dashboard shell is built: sidebar navigation, header, breadcrumb, and mobile menu, plus adding nav items and gating them to admins.

The shell

app/dashboard/layout.tsx wraps every route under /dashboard. It's a Server Component, and it guards access before rendering anything. No session redirects to /auth/sign-in, and a user whose onboardingComplete is false goes to /onboarding. The file also exports a static metadata built with generateMeta({ noIndex: true, ... }), so search engines skip dashboard pages.

Once the guard passes, the layout renders these pieces:

<SubscriptionProvider>
  <SidebarProvider>
    <DashboardSidebar session={session} />
    <SidebarInset>
      <header>
        <SidebarTrigger />
        <DashboardBreadcrumb />               {/* desktop only */}
        <DashboardMobileHeader user={...} />  {/* mobile only */}
      </header>
      {isPastDue && <PastDueBanner />}
      <div>{children}</div>
    </SidebarInset>
  </SidebarProvider>
</SubscriptionProvider>
PieceWhat it does
SubscriptionProviderLoads subscription state (planName, activeSubscription) for the sidebar footer, the dashboard home, the billing page and the profile form
SidebarProviderHolds the open/closed state and binds the Cmd/Ctrl+B shortcut
DashboardSidebarThe nav column (components/dashboard/sidebar.tsx)
SidebarInsetThe main content column. It sits beside the sidebar on desktop and goes full-width on mobile
DashboardBreadcrumbThe trail at the top, desktop only
DashboardMobileHeaderA centered logo plus the shared MobileNav menu, mobile only
PastDueBannerA warning bar with an "Update Billing" link. The layout renders it only when hasPastDueSubscription() from lib/server/auth-helpers.ts finds a past_due subscription for the user. See Billing

app/dashboard/layout.tsx renders a bare <SidebarProvider>, so defaultOpen falls back to true. Each desktop toggle writes a sidebar_state cookie, but nothing reads it back, so the sidebar comes back open after every reload. To make the choice stick, read the cookie in that layout and pass its value as defaultOpen. The mobile sheet is plain component state and always starts closed.

Nav items live in one typed array, navigationItems, at the top of components/dashboard/sidebar.tsx. Five ship by default: Dashboard, Files, Users, Billing and Settings. The public marketing nav is separate. Its links are NAV_LINKS in lib/client/navigation.ts, shared by components/header.tsx and components/mobile-nav.tsx.

Each item's group field decides where it lands:

GroupPositionLabelWho sees it
mainTop(none)Everyone
systemMiddle"SYSTEM"Admins only
secondaryBottom (mt-auto)(none)Everyone

The system group holds Files and Users, and it's filled in only when session.user.role === "admin". For everyone else it's an empty array, so the labeled group doesn't render at all.

Highlighting follows the URL. /dashboard lights up on an exact match only; every other item matches its own href or any path beneath it, so /dashboard/users stays highlighted on a nested route like /dashboard/users/abc-123.

The footer is a DropdownMenu holding Profile, a theme toggle, and Sign out. Sign out calls useSignOut() from lib/client/navigation.ts, which loads the auth client lazily, signs out, and pushes /auth/sign-in once it succeeds. Its trigger button shows the avatar, the display name, and {planName} Plan below that. The display name is user.name, falling back to the part of the email before the @. While SubscriptionProvider is still loading, the plan line in the footer renders as a skeleton.

components/dashboard/breadcrumb.tsx builds the trail from usePathname(), so there's no manual config to keep in sync.

/dashboard                       -> Dashboard            (not a link)
/dashboard/settings              -> Dashboard > Settings
/dashboard/users/<uuid>          -> Dashboard > Users    (UUID dropped)

Each segment is title-cased with hyphens turned into spaces, and the last one renders as plain text instead of a link. Only canonical UUIDs get filtered out. If your record IDs are numeric, CUIDs or slugs, widen the regex at the top of that file or they'll show up in the trail. The breadcrumb is desktop-only (hidden md:flex).

Mobile

Below the md breakpoint, 768px, the sidebar turns into a slide-in Sheet, 18rem wide, and tapping any link inside closes it again. The header drops the breadcrumb and shows DashboardMobileHeader instead. That's a centered logo with, on the right, the same MobileNav overlay from components/mobile-nav.tsx that the marketing header uses, opened from the user's avatar. On desktop the same SidebarTrigger slides the sidebar off-canvas rather than opening a sheet.

What you can change

Add a nav item

  1. Add one entry to navigationItems in components/dashboard/sidebar.tsx. Give it a lucide-react icon and a group:

    { name: "Analytics", href: "/dashboard/analytics", icon: BarChart, group: "main" },
  2. Create the page at app/dashboard/analytics/page.tsx. The active highlight works on its own.

Setting group: "system" hides the link from non-admins, and that's all it does. The page itself is still reachable by typing the URL. Put it inside the app/dashboard/(admin)/ route group to get a real server-side role check, the way the built-in files and users pages do. See Admin Dashboard.

Change the header

Edit the <header> block in app/dashboard/layout.tsx to add controls next to SidebarTrigger, or to swap out DashboardBreadcrumb. To change what the breadcrumb shows, edit components/dashboard/breadcrumb.tsx.

Add a section-only layout

Drop a layout.tsx into any dashboard subfolder (for example app/dashboard/profile/layout.tsx) to add tabs or a sub-nav scoped to that section. It nests inside the outer shell, so you don't need a session check. The parent layout already guarded the route.

Delete the demo card

The dashboard home page shows a sample PlanCard on paid plans, with a bronze look on Plus and a gold look on Pro. It lives in components/dashboard/plan-card.tsx and is wired up in app/dashboard/dashboard-content.tsx. The figures inside it are invented ("7 / 10" projects this month, "42 GB / unlimited" storage used), so swap the contents or delete the file before you launch. If you delete it, also drop the PlanCard import and the <PlanCard plan={plan} /> line in app/dashboard/dashboard-content.tsx. Its colors are written inline instead of pulled from the theme, so nothing else is left behind.

On this page