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>| Piece | What it does |
|---|---|
SubscriptionProvider | Loads subscription state (planName, activeSubscription) for the sidebar footer, the dashboard home, the billing page and the profile form |
SidebarProvider | Holds the open/closed state and binds the Cmd/Ctrl+B shortcut |
DashboardSidebar | The nav column (components/dashboard/sidebar.tsx) |
SidebarInset | The main content column. It sits beside the sidebar on desktop and goes full-width on mobile |
DashboardBreadcrumb | The trail at the top, desktop only |
DashboardMobileHeader | A centered logo plus the shared MobileNav menu, mobile only |
PastDueBanner | A 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.
Navigation
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:
| Group | Position | Label | Who sees it |
|---|---|---|---|
main | Top | (none) | Everyone |
system | Middle | "SYSTEM" | Admins only |
secondary | Bottom (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.
Breadcrumb
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
-
Add one entry to
navigationItemsincomponents/dashboard/sidebar.tsx. Give it alucide-reacticon and agroup:{ name: "Analytics", href: "/dashboard/analytics", icon: BarChart, group: "main" }, -
Create the page at
app/dashboard/analytics/page.tsx. The active highlight works on its own.
Make a link admin-only
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.
File Uploads
How Next Starter handles file uploads: direct browser-to-Cloudflare R2 transfers via presigned URLs, with server-side validation and an S3-compatible config.
UI Components
The 30 components pre-installed in Next Starter: shadcn/ui new-york style, Radix primitives where behavior needs them, Tailwind CSS v4 styling and CSS-variable theming.