Overview#
Dashboard is a complete admin interface for the fictional outdoor-gear shop behind Northstar Admin: KPI cards and charts, analytics, a full orders table, customers with a detail panel, a product catalogue with inventory, settings (profile, team, billing, notifications) and a sign-in screen, in light and dark themes. Charts are SVG React components with no chart library. This is the Next.js 16 (App Router) + React 19 + TypeScript + Tailwind CSS v4 version, version 1.1.3. It exports to static files. See it live at freeframe.dev/templates/dashboard.
Quick start#
Requirements: Node.js 24 or newer and pnpm (npm works too).
pnpm install
pnpm dev # http://localhost:3000| Command | What it does |
|---|---|
pnpm dev |
Development server at http://localhost:3000 |
pnpm build |
Static export to out/ |
pnpm start |
Serve out/ locally (npx serve out) |
pnpm lint |
ESLint |
pnpm typecheck |
Generate route types, then type-check with tsc (TypeScript 7) |
pnpm build:demo |
Build for /demos/nextjs/dashboard into demo/ (only used for the freeframe.dev demo) |
Pages#
| Route | File | What's on it |
|---|---|---|
/ |
src/app/(app)/page.tsx |
KPI cards with sparklines, revenue chart (7d / 30d / 12m), traffic donut, recent orders, activity |
/analytics |
src/app/(app)/analytics/page.tsx |
Visitors line chart, customer-type bars, checkout funnel, order heatmap, top pages, countries |
/orders |
src/app/(app)/orders/page.tsx |
Orders table: status tabs, search, sorting, pagination, selection and bulk actions |
/customers |
src/app/(app)/customers/page.tsx |
Customer list with segments and search, slide-over detail panel with notes |
/products |
src/app/(app)/products/page.tsx |
Catalogue with category filter, grid/table view, inventory |
/settings |
src/app/(app)/settings/page.tsx |
Profile, team (invite modal), billing, notifications; /settings#billing opens a tab |
/login |
src/app/login/page.tsx |
Sign-in screen (demo) |
| 404 | src/app/not-found.tsx |
Not-found page |
(app) is a route group: its layout.tsx wraps those pages in the dashboard chrome (sidebar, top bar)
without adding /app to the URL. The sign-in and 404 pages use the root layout only.
Project structure#
dashboard/
├─ src/
│ ├─ app/
│ │ ├─ layout.tsx fonts, metadata, theme/sidebar boot script, toast provider, back to top
│ │ ├─ globals.css brand and chart tokens, component classes
│ │ ├─ icon.svg favicon
│ │ ├─ (app)/layout.tsx dashboard chrome (AppShell) and scroll reveals
│ │ ├─ (app)/<page>/page.tsx overview, analytics, orders, customers, products, settings
│ │ ├─ login/page.tsx
│ │ └─ not-found.tsx
│ ├─ components/
│ │ ├─ shell/ app-shell (sidebar, drawer, top bar), dropdown, global-search, theme-toggle
│ │ ├─ charts/ line-chart, bar-chart, donut, funnel, heatmap, sparkline, tooltip
│ │ ├─ pages/ interactive page sections (orders table, customers list, products,
│ │ │ settings tabs, revenue and visitors cards, login form)
│ │ ├─ modal.tsx, toast.tsx, ui.tsx, icons.tsx, reveal.tsx, query-param.tsx, back-to-top.tsx
│ ├─ fonts/ Inter and JetBrains Mono (Latin .woff2) with their OFL licences
│ ├─ data/demo.ts all demo data (orders, customers, products, chart series, search index)
│ └─ lib/
│ ├─ use-validation.tsx form validation hook
│ ├─ format.ts number/date formatting and chart maths
│ ├─ metadata.ts pageMetadata() helper
│ └─ dom.ts focus trap and scroll lock helpers
├─ public/og-image.png 1200×630 social sharing image
├─ next.config.ts, eslint.config.mjs, postcss.config.mjs, tsconfig.json
├─ package.json, pnpm-workspace.yaml, pnpm-lock.yaml
└─ README.md, DOCS.md, CHANGELOG.mdCustomising#
Brand and colours#
src/app/globals.css defines the brand-* colour scale (indigo by default) in @theme, used for buttons,
links, the active navigation item and focus rings. Chart colours are CSS variables defined for light mode
and again for dark mode: --chart-1 to --chart-5, --chart-grid, --chart-text and --chart-surface.
Charts read them at paint time, so they follow the theme automatically. The browser theme colours are the
viewport.themeColor export in src/app/layout.tsx.
Typography#
Inter and JetBrains Mono are self-hosted with next/font/local in src/app/layout.tsx (variables
--font-inter and --font-jetbrains-mono), from the Latin .woff2 files in src/fonts/: the same files Google
Fonts serves to the HTML version, so text sets exactly like it. Their SIL Open Font License texts are the
*-OFL.txt files beside them. To change a font, add its .woff2 (and licence) to src/fonts/ and point src at it,
or import a family from next/font/google, keeping the variable name; or update the --font-sans / --font-mono
tokens in globals.css.
Content#
All demo data lives in src/data/demo.ts:
| Export | What it is |
|---|---|
TODAY |
The fixed "current" date of the demo (3 October 2026, UTC) |
NAMES, STATUS_TONE |
Customer names; order status → badge colour |
PRODUCTS |
Products (name, SKU, category, price, stock, capacity, units sold, hue, icon) |
ORDERS |
48 generated orders |
CUSTOMERS |
Customers derived from NAMES and their orders, with a Segment |
REVENUE |
Revenue series for the 7d, 30d and 12m ranges |
TRAFFIC_SOURCES, VISITOR_SERIES, CUSTOMER_TYPE_SERIES, FUNNEL |
Analytics chart data |
heatmapDays(), heatmapTotal() |
Order heatmap data |
SEARCH_INDEX |
Everything global search can find |
Add a product:
{ name: "Summit Trail Cap", sku: "AP-402", cat: "Apparel", price: 29, stock: 140, cap: 200, sold: 610, hue: "emerald", icon: "jacket" },hue and icon must be values of the Hue and ProductIcon types. To use real data, replace these
exports with data from your API. With the static export, fetch it in client components (useEffect +
fetch); if you remove the static export, page components can await it on the server instead. Set
TODAY to new Date() once data is real.
Images and artwork#
There are no photos: avatars are generated initials, product images are SVG icons on coloured tiles
(src/components/pages/products-catalog.tsx), and charts are SVG. The favicon is src/app/icon.svg; the
social image is public/og-image.png (1200×630).
Navigation, sidebar and top bar#
src/components/shell/app-shell.tsx holds the sidebar (the WORKSPACE link list plus the Settings
link), the mobile drawer, the top bar with search, notifications (NOTIFICATIONS), the account menu and the
theme toggle. To add a page:
- Create
src/app/(app)/<route>/page.tsxwithexport const metadata = pageMetadata("Title", "Description"). - Add it to
WORKSPACE. - Add it to
SEARCH_INDEXinsrc/data/demo.ts.
Dark mode#
bootScript in src/app/layout.tsx applies the saved theme (ns-theme) and sidebar state (ns-sidebar)
before the first paint. ThemeToggle (src/components/shell/theme-toggle.tsx) flips the dark class and
saves the choice. To default to light, remove ||(!t&&matchMedia('(prefers-color-scheme: dark)').matches)
from bootScript.
Features#
Sidebar and mobile drawer#
On wide screens the sidebar collapses to icons (saved as ns-sidebar); collapsed links keep accessible
names and tooltips. On small screens it's a drawer: focus is trapped, Escape or the backdrop closes it and
focus returns to the menu button.
Global search#
Ctrl K / ⌘ K or / focuses search (src/components/shell/global-search.tsx). Results come from
SEARCH_INDEX and are navigable with arrow keys and Enter. Entries link to filtered pages such as
/orders?q=NS-4821; pages read the query with the QueryParam component, which is wrapped in Suspense so
they stay statically rendered.
Menus, modals and toasts#
Dropdown menus close with Escape or a click outside. Modal (src/components/modal.tsx) traps focus and
returns it. Toasts come from ToastProvider: call useToast() to get the toast(message) function, or use
ToastButton / ToastLink. The back-to-top button moves up while a toast is visible.
Charts#
src/components/charts/ contains LineChart, BarChart, Donut, Funnel, Heatmap and Sparkline,
all responsive SVG with hover tooltips. LineChart is also keyboard-accessible: focus it and use the
Left/Right arrow keys to move between points, Escape to hide the tooltip.
<LineChart
ariaLabel="Revenue, last 30 days"
labels={REVENUE["30d"].labels}
series={[{ name: "This period", color: "var(--chart-1)", values: REVENUE["30d"].now, area: true }]}
format={(v) => money0.format(v)}
/>Orders table#
OrdersTable (src/components/pages/orders-table.tsx) has status tabs with counts, search (also from
?q=), sortable columns with aria-sort, PER_PAGE (10) rows per page, row selection and a bulk-action
bar (mark as paid, export, clear). Export is a demo toast.
Customers and products#
CustomersList filters by segment and search and opens a slide-over detail panel with a validated notes
form. ProductsCatalog filters by category and switches between grid and table views, remembered as
ns-products-view.
Settings#
SettingsTabs keeps the selected tab in the URL hash (/settings#team, /settings#billing) and follows
hash changes. Tabs support arrow keys, Home and End. It includes the profile form with a bio counter, the
team list with an invite modal, plan cards with a switch button, invoices and notification switches.
Back to top button#
BackToTop (mounted in src/app/layout.tsx) appears after scrolling about one screen, returns to the top
(instantly with reduced motion), moves focus to #main, lifts above a visible toast (anything matching
.toast or [data-back-to-top-avoid]), steps aside while an in-page "Back to top" link or an element marked
data-back-to-top-hide is in the way, and is hidden when printing. Its styles are .back-to-top in
src/app/globals.css. Remove <BackToTop /> and those styles to drop it.
Forms#
Forms use the useValidation() hook from src/lib/use-validation.tsx:
const form = useValidation();
<form noValidate onSubmit={form.submit((el) => toast("Profile saved"))}>
<input className="field" type="email" {...form.field("profile-email", { label: "Email", required: true, email: true })} />
{form.error("profile-email")}
</form>field(id, rules) returns the props for an input (id, name, required, aria-invalid,
aria-describedby and blur/input validation); rules are label, required, email and minLength.
submit(onValid) validates every registered field, focuses the first invalid one, and otherwise calls
onValid(formElement).
| Form | Component | Behaviour |
|---|---|---|
| Sign in | LoginForm (src/components/pages/login-form.tsx) |
Email and password (8+ characters), then redirects to / |
| Profile | SettingsTabs |
Profile fields; shows a "Profile saved" toast |
| Invite member | SettingsTabs (modal) |
Email and role; adds the member to the team list |
| Customer note | CustomersList (panel) |
Note text; shows a toast |
Nothing is saved or sent. To save for real, post the data in onValid:
onSubmit={form.submit(async (el) => {
const res = await fetch("/api/profile", { method: "POST", body: new FormData(el) });
toast(res.ok ? "Profile saved" : "Something went wrong");
})}With the static export, point fetch at your existing API. To write the API inside this project, remove
output: "export" from next.config.ts and add Route Handlers (e.g. src/app/api/profile/route.ts) or
Server Actions. Replace the timeout in LoginForm with your authentication provider, and protect the
dashboard routes on the server (for example with a session check in a layout or proxy.ts); hiding the
link to a page isn't protection.
Saved data (localStorage)#
| Key | Values | Used for |
|---|---|---|
ns-theme |
"dark" or "light" |
Colour theme |
ns-sidebar |
"collapsed" or "open" |
Desktop sidebar state |
ns-products-view |
"grid" or "table" |
Products page view |
Values are compared with the exact expected strings when read, so a missing or tampered value falls back to the default (system theme, open sidebar, grid view).
SEO and sharing#
- Every page uses
pageMetadata(title, description)fromsrc/lib/metadata.ts, which sets the title (Title · Northstar Admin), description, Open Graph and Twitter card fields and adds the base path to the social image URL. metadataBaseinsrc/app/layout.tsxreadsSITE_URL(defaulthttps://northstar.example). Set it at build time so the image URL is absolute:SITE_URL=https://example.com pnpm build.- An admin area usually shouldn't be indexed: add
robots: { index: false }topageMetadataand asrc/app/robots.tsthat disallows everything.
Accessibility#
Built in: a skip link, landmarks, visible focus rings, focus-trapped drawer and modals, menus that close with
Escape, tabs with arrow keys / Home / End, keyboard-readable line charts, aria-sort on sortable columns,
aria-pressed toggles and role="switch" switches, form errors linked with aria-describedby,
reduced-motion support, and WCAG AA contrast in both themes.
When editing, keep one <h1> per page, label every control, and give each chart a descriptive
ariaLabel.
Building and deploying#
pnpm build # static site in out/
pnpm start # preview out/next.config.ts sets output: "export", images.unoptimized and basePath from DEMO_BASE_PATH (for
sub-folder hosting, e.g. DEMO_BASE_PATH=/admin pnpm build). Deploy out/ to any static host, or import
the repository into Vercel. For a real admin area, remove the static export, add authentication and your
API, and deploy to a host that runs Next.js. demo/, if present, is the freeframe.dev demo build and can be
deleted.
TypeScript 7 setup#
@typescript/native provides TypeScript 7's tsc for pnpm typecheck; typescript is aliased to
@typescript/typescript6 because typescript-eslint and parts of the Next.js toolchain need the TypeScript 6
API. Replace both with typescript@7 once typescript-eslint supports it. eslint.config.mjs sets
settings.react.version, which ESLint 10 needs.
Troubleshooting#
useSearchParamsbuild error after adding a page. Read query parameters withQueryParam(it adds the Suspense boundary static export needs).- Social previews have no image. Build with
SITE_URLset to your domain. - A chart is blank after you change its data. Every series needs as many values as there are
labels. - The sidebar stays collapsed. Clear
ns-sidebarfromlocalStorageor click the collapse button. - Dates look stuck in October 2026.
TODAYis fixed for the demo; set it tonew Date(). - ESLint crashes with
getFilename is not a function. Keepsettings.react.versionineslint.config.mjs. next buildfails after adding an API route. Removeoutput: "export".
License#
MIT. The code and artwork are free for personal and commercial use; the download includes the license text in
LICENSE.txt. Inter and JetBrains Mono are licensed under the SIL Open Font License.