User dashboard
ShipVeryFast ships a complete authenticated dashboard under app/dashboard/, nine routes, a sidebar/topbar shell, a command palette and a single nav config, wired to deterministic demo data you can swap for your own Supabase queries. This page maps it out and shows how to extend it.
Mental model
Every page is a 'use client' component that reads static demo data from libs/dashboard/demo-data.ts. The navigation, breadcrumbs and command palette are all driven by one file, libs/dashboard/nav.ts, so adding a route is a two-line change.
Route map
The dashboard lives entirely under app/dashboard/. A shared app/dashboard/layout.tsx wraps every page with the sidebar, topbar, mobile drawer, command palette and toaster, then renders each route inside a <main>:
| Route | File | What it shows |
|---|---|---|
/dashboard | app/dashboard/page.tsx | Overview: KPI stat cards, revenue chart, activity feed, plan card |
/dashboard/analytics | app/dashboard/analytics/page.tsx | Traffic, sources, cohorts, funnel, geo breakdown |
/dashboard/assistant | app/dashboard/assistant/page.tsx | Streaming AI chat (provider/model picker) |
/dashboard/customers | app/dashboard/customers/page.tsx | Customer table with status, plan, MRR and LTV |
/dashboard/billing | app/dashboard/billing/page.tsx | Current plan, invoices, MRR movement, metered usage |
/dashboard/team | app/dashboard/team/page.tsx | Team members, roles and pending invites |
/dashboard/api-keys | app/dashboard/api-keys/page.tsx | API keys with masked secrets, scopes and usage |
/dashboard/notifications | app/dashboard/notifications/page.tsx | Notification feed with read/unread state |
/dashboard/settings | app/dashboard/settings/page.tsx | Account and workspace settings |
Navigation config (nav.ts)
One file, libs/dashboard/nav.ts, is the single source of truth for the sidebar, the mobile drawer, the command palette and the breadcrumbs. It exports NAV_GROUPS (the grouped sidebar items, each with a lucide-react icon), ALL_NAV (a flattened list the CommandPalette uses), ROUTE_LABELS (path-segment → human label for breadcrumbs) and the isActive helper.
import {
LayoutDashboard, BarChart3, Users, CreditCard, UsersRound,
KeyRound, Bell, Settings, Sparkles, type LucideIcon,
} from 'lucide-react';
export interface NavItem {
href: string;
label: string;
icon: LucideIcon;
}
export const NAV_GROUPS: { title: string; items: NavItem[] }[] = [
{
title: 'Workspace',
items: [
{ href: '/dashboard', label: 'Overview', icon: LayoutDashboard },
{ href: '/dashboard/analytics', label: 'Analytics', icon: BarChart3 },
{ href: '/dashboard/assistant', label: 'Assistant', icon: Sparkles },
{ href: '/dashboard/customers', label: 'Customers', icon: Users },
{ href: '/dashboard/billing', label: 'Billing', icon: CreditCard },
],
},
{
title: 'Account',
items: [
{ href: '/dashboard/team', label: 'Team', icon: UsersRound },
{ href: '/dashboard/api-keys', label: 'API keys', icon: KeyRound },
{ href: '/dashboard/notifications', label: 'Notifications', icon: Bell },
{ href: '/dashboard/settings', label: 'Settings', icon: Settings },
],
},
];
export const ALL_NAV: NavItem[] = NAV_GROUPS.flatMap((g) => g.items);
export function isActive(pathname: string, href: string): boolean {
return href === '/dashboard'
? pathname === '/dashboard'
: pathname.startsWith(href);
}DashboardSidebar and MobileDrawer both render NAV_GROUPS and call isActive to highlight the current page; CommandPalette maps over ALL_NAV; and Breadcrumbs looks each path segment up in ROUTE_LABELS.
State & demo data
Two libs back every page. libs/dashboard/store.ts holds the shared client state as Zustand stores, and libs/dashboard/demo-data.ts holds the static seed data the pages render. There is also libs/dashboard/chart-theme.ts, a single CHART palette and a set of shared recharts props so every chart reads as one family on both light and dark backgrounds.
Zustand stores (store.ts)
The store exposes four hooks:
useFilters, the analyticsperiod(7d/30d/90d/12m) andcomparemode, shared so every chart re-renders together.useShell, shell UI state:sidebarCollapsed,mobileOpenandpaletteOpenwith their setters.useToasts, atoast()dispatcher and the renderedtoastsarray consumed byToaster.useNotifications, notification items (seeded from the demo data) withmarkReadandmarkAllRead.
Deterministic demo data (demo-data.ts)
The demo data is intentionally deterministic: it anchors "now" to a fixed NOW constant and uses a seeded PRNG so charts never jitter between renders and relative timestamps never cause hydration mismatches. It exports ready-made datasets, KPIS, revenueSeries, trafficSeries, customers, invoices, mrrMovement, team, apiKeys, activity, notifications, plus formatters (formatCurrency, formatNumber, formatPct, relativeTime).
import { KPIS, revenueSeries, activity, currentPlan } from '@/libs/dashboard/demo-data';
import { useFilters } from '@/libs/dashboard/store';
export default function OverviewPage() {
const { period, compare } = useFilters();
const data = revenueSeries.slice(-PERIOD_DAYS[period]);
// ...render StatCards, charts and the activity feed from the seed data
}How pages are gated
Access control lives in middleware.ts, which is wrapped in NextAuth's withAuth (its authorized callback requires a JWT for any matched route). The matcher protects /admin/:path* and a few other paths.
Heads up: the dashboard guard ships disabled for local preview
In the boilerplate, the /dashboard/:path* entry in the middleware.ts matcher is commented out so you can browse the dashboard without logging in. Uncomment it before you ship so the routes require a session:
// middleware.ts
export const config = {
matcher: [
'/admin/:path*', // Protect all admin routes (require authentication)
// LOCAL-PREVIEW: /dashboard guard disabled for browsing without login.
// Uncomment the line below before shipping:
// '/dashboard/:path*',
'/app/dashboard/:path*',
'/app/profile/:path*',
],
};Note that the dashboard pages themselves are all 'use client' components and do not call getServerSession, gating is handled by the middleware matcher, not per-page. See Authentication for how sessions are read on the server elsewhere.
Adding a new dashboard page
Because nav.ts drives the whole shell, adding a route is three small steps. Say you want a /dashboard/reports page:
- Create the route file
app/dashboard/reports/page.tsx, it will inherit the sidebar/topbar shell fromapp/dashboard/layout.tsxautomatically. - Add a nav item to the right group in
NAV_GROUPS(pick any lucide-react icon). - Add a breadcrumb label to
ROUTE_LABELSso the topbar reads nicely.
// 1. app/dashboard/reports/page.tsx
'use client';
export default function ReportsPage() {
return (
<div className="mx-auto max-w-4xl space-y-6">
<h1 className="font-display text-2xl">Reports</h1>
{/* ...your content */}
</div>
);
}
// 2. libs/dashboard/nav.ts, add to the Workspace group's items:
import { FileBarChart } from 'lucide-react';
// { href: '/dashboard/reports', label: 'Reports', icon: FileBarChart },
// 3. libs/dashboard/nav.ts, add a breadcrumb label:
// reports: 'Reports',The new entry shows up in the sidebar, the mobile drawer and the command palette with no further wiring, and isActivehighlights it when you're on the page.
Swapping demo data for real queries
Every page imports its data from libs/dashboard/demo-data.ts, so that module is the one seam to replace. The file's own comment says it plainly: "Swap this module for your real data source." Replace the exported arrays/objects with live queries against your Supabase tables using the clients from libs/supabase.ts, the supabase anon client on the client side, or supabaseAdmin from a Server Component or route handler.
// Example: fetch real customers in a Server Component wrapper,
// then pass them into a client child that renders the table.
import { supabaseAdmin } from '@/libs/supabase';
export async function getCustomers() {
const { data, error } = await supabaseAdmin
.from('customers')
.select('id, name, email, plan, status, mrr');
if (error) throw error;
return data;
}Keep the same shapes the demo data uses (the Customer, Kpi and other types are exported from demo-data.ts) and the existing components and charts keep working unchanged. See Database & data models for the Supabase clients and Zod schemas.
Next steps
- The AI assistant, the streaming chat behind
/dashboard/assistant - Database & data models, swap demo data for real Supabase queries
- Theming & dark mode, the tokens behind the dashboard shell
