Next Starter Logo
Components

Dashboard Layout

How the dashboard layout is built: sidebar navigation, header, breadcrumb, mobile menu, and past-due banner, plus adding nav items and gating them to admins.

The shell

Every route under /dashboard is wrapped by app/dashboard/layout.tsx. It's a Server Component that guards access first, then renders the shell around your page.

The guard runs before anything else:

  1. getSession() reads the current session. No session redirects to /auth/sign-in.
  2. If session.user.onboardingComplete is false, it redirects 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>
      <PastDueBanner />
      <main>{children}</main>
    </SidebarInset>
  </SidebarProvider>
</SubscriptionProvider>
PieceWhat it does
SubscriptionProviderLoads subscription state (planName, activeSubscription) for the sidebar footer and the past-due banner
SidebarProviderHolds the open/closed state, saves it to a cookie, and binds the Cmd/Ctrl+B shortcut
DashboardSidebarThe nav rail (components/dashboard/sidebar.tsx)
SidebarInsetThe main content column. It sits beside the sidebar on desktop and goes full-width on mobile
SidebarTriggerToggles the sidebar (collapse on desktop, slide-in sheet on mobile)
DashboardBreadcrumbThe trail at the top, desktop only
DashboardMobileHeaderA centered logo plus a menu button, mobile only
PastDueBannerA warning bar shown only when the subscription status is past_due

The sidebar primitives in components/ui/sidebar.tsx are stock shadcn/ui. SidebarProvider defaults to defaultOpen={true}. It saves the open state to a sidebar_state cookie, so the sidebar stays the way you left it after a reload.

Nav items live in one typed array, navigationItems, at the top of components/dashboard/sidebar.tsx. (The public marketing nav is separate. It lives inline in components/header.tsx.)

ItemRouteIconGroupAdmin-only
Dashboard/dashboardHomemainNo
Files/dashboard/filesFilessystemYes
Users/dashboard/usersUserssystemYes
Billing/dashboard/billingCreditCardsecondaryNo
Settings/dashboard/settingsSettingssecondaryNo

Items render in three groups, based on each item's group field:

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

The system group is 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.

Active state

The sidebar highlights one item based on the current path:

  • /dashboard is active only on an exact match (pathname === "/dashboard").
  • Any other item is active when the path equals its href, or starts with its href plus a slash. So /dashboard/users stays highlighted on a nested route like /dashboard/users/abc-123.

The footer is a DropdownMenu. Its trigger button shows the user's avatar, their display name, and {planName} Plan below the name. The plan name comes from SubscriptionProvider. The display name is user.name, or the part of the email before the @ if there is no name.

Opening the menu shows:

  • The avatar, name, and email again at the top
  • Profile/dashboard/profile
  • A theme toggle (the ThemeToggle component)
  • Sign out → calls signOut(), then redirects to /auth/sign-in

While SubscriptionProvider is still loading, the sidebar content and footer render as skeletons (gray placeholders) instead.

The sidebar header (above the nav) holds the app logo linking to /dashboard, and an ExternalLink icon linking to / ("View homepage").

components/dashboard/breadcrumb.tsx builds the trail from usePathname(). There's no manual config to keep in sync; it reads the current URL.

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

The rules:

  • At /dashboard, it shows a single non-clickable Dashboard label.
  • Deeper routes always start with a Dashboard link. The dashboard segment is filtered out of the rest of the path, so it is never repeated.
  • UUID segments are filtered out, so resource IDs never show in the trail.
  • Each remaining segment is title-cased (hyphens become spaces). The last segment is plain text; earlier ones are links.

The breadcrumb is desktop-only (hidden md:flex).

Mobile

Below the md breakpoint the shell changes:

  • The sidebar collapses to a slide-in Sheet (18rem wide), opened by SidebarTrigger.
  • The header hides the breadcrumb and shows DashboardMobileHeader instead: a centered logo, plus a MobileNav menu button on the right.
  • Tapping any sidebar link closes the sheet. The handler only fires on mobile:
const { setOpenMobile, isMobile } = useSidebar();
const handleMobileClose = () => isMobile && setOpenMobile(false);

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.

Set its group to "system" so it hides for non-admins in the sidebar.

The sidebar is a UI convenience only. It does not protect the page. Enforce access on the server by putting the page inside the app/dashboard/(admin)/ route group. That group's layout.tsx re-checks the session and redirects anyone who isn't an admin to /dashboard. The (admin) parentheses keep that segment out of the URL, so the page still resolves to /dashboard/<name>. The built-in files and users pages already live there.

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 (for example, custom labels), 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.

On this page