ZeroStarter

Dashboard

The protected app shell: sidebar, org switcher, and the layout wrappers to build on.

This is a shell, not a product

The /dashboard page renders a single "Dashboard" heading with its "Intentionally empty." description and nothing else. It is an empty, auth-gated starting point. The sidebar, org switching, and layout wrappers are real and wired; the content is yours to build.

/dashboard is a route group at web/next/src/app/(protected)/. Its layout.tsx does two things: it guards the group behind a session, and wraps every page in the shared sidebar. The privileged /console area reuses the exact same chrome.

The guard

The layout checks the session server-side and bounces anyone without one:

const session = await auth.api.getSession()
if (!session?.user) redirect("/")

auth.api.getSession() (in web/next/src/lib/auth/index.ts) calls the API with the request cookies. The redirect happens on the server, so an unauthenticated request never renders the protected page; there is no client-side flash.

The sidebar

The chrome is not inlined in the layout. It lives in SidebarShell (web/next/src/components/shell/sidebar-shell.tsx), a shadcn/ui Sidebar in collapsible="icon" mode, shared by both the dashboard and console layouts. The layout fills three slots:

  • Header: the brand (site.name, linking to homeHref, which stays inside the app: /dashboard by default), the collapse trigger, and a caller node: for the dashboard, the org switcher.
  • Content: a caller nav node. Empty for the dashboard; add your navigation items here.
  • Footer: a caller node: for the dashboard, the user actions.

The console layout is the same shell with two props flipped: a badge ("Console") and homeHref="/console"; both default to dashboard values. A SidebarRail handles drag-to-resize, and open/closed state persists in a sidebar_state cookie.

Org switcher and active-org persistence

The org switcher (web/next/src/components/dashboard/sidebar.tsx) lists the user's organizations, switches between them, and offers a Create Organization dialog (the slug is auto-generated from the name). The active org survives reloads:

  1. On switch, the component writes a last-active-org_{userId} cookie.
  2. On load, the dashboard reads that cookie and calls POST /api/auth/organization/set-active to restore it.

So a user returns to the same org context every session.

User actions

The footer (web/next/src/components/dashboard/sidebar.tsx) holds a docs link with a version badge, a cross-link to /console for anyone the ladder admits, which is member and above (canAccessConsole), and the shared SidebarUserMenu (web/next/src/components/shell/sidebar-user-menu.tsx): avatar, name, email, a Home link back to / (the sidebar brand stays inside the app, so this is the one way back to the landing page), an optional feedback link (only when NEXT_PUBLIC_USERJOT_URL is set), and sign-out (which returns to the home page).

Page content

Pages never hand-roll centering, width, or padding. They wrap content in two components:

  • PageShell (web/next/src/components/shell/page-shell.tsx): the container. Owns mx-auto, width, and p-4 sm:p-6 via a size variant: sm, md (default), lg, full.
  • PageHeader (web/next/src/components/shell/page-header.tsx): the title row. Takes title, an optional description, and optional actions, and renders the page's single <h1>.

A new protected page is just a file under (protected)/ (it inherits the guard and sidebar automatically) with its content wrapped in the shell:

import { PageHeader } from "@/components/shell/page-header"
import { PageShell } from "@/components/shell/page-shell"

export default function Page() {
  return (
    <PageShell>
      <PageHeader
        title="Dashboard"
        description="Intentionally empty. Auth, orgs, and the API are wired; this page is where your product begins."
      />
      {/* your content here */}
    </PageShell>
  )
}

Next

  • Authentication: the sessions, orgs, and roles behind the guard.
  • Theming: restyle the shell with design tokens.