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 catalog with inventory, settings (profile, team, billing, notifications) and a sign-in screen, in light and dark themes. Charts are SVG Vue components with no chart library. This is the Nuxt 4 + Vue 3 + TypeScript + Tailwind CSS v4 version, version 1.0.1, generated as a static site. See it live at freeframe.dev/templates/dashboard.
Quick start#
Requirements: Node.js 24 or newer and pnpm.
pnpm install
pnpm dev # http://localhost:3000| Command | What it does |
|---|---|
pnpm dev |
Development server at http://localhost:3000 |
pnpm generate |
Static site in .output/public/ (every page prerendered to <page>.html) |
pnpm build |
A Node.js server build in .output/ (pages still prerendered) |
pnpm preview |
Serve the last build locally |
pnpm lint |
ESLint (flat config from @nuxt/eslint) |
pnpm typecheck |
nuxt prepare, then type-check with vue-tsc |
pnpm build:demo |
Build for /demos/nuxt/dashboard into demo/ (only used for the freeframe.dev demo) |
pnpm install runs nuxt prepare, which writes the types and ESLint config into .nuxt/.
Pages#
| Route | File | What's on it |
|---|---|---|
/ |
app/pages/index.vue |
KPI cards with sparklines, revenue chart (7 days / 30 days / 12 months), traffic donut, recent orders, activity, top products |
/analytics |
app/pages/analytics.vue |
Visitors line chart, revenue by customer type, checkout funnel, order heatmap, top pages, visitors by state |
/orders |
app/pages/orders.vue |
Orders table: status tabs, search, sorting, pagination, selection and bulk actions |
/customers |
app/pages/customers.vue |
Customer list with segments and search, slide-over detail panel with a note |
/products |
app/pages/products.vue |
Catalog with category filter, grid or table view, inventory |
/settings |
app/pages/settings.vue |
Profile, team (invite dialog), billing, notifications; /settings#billing opens a tab |
/login |
app/pages/login.vue |
Sign-in screen (demo) |
| 404 | app/error.vue |
Not-found page, generated as 404.html |
The app pages use app/layouts/default.vue (sidebar, top bar and footer). The sign-in and 404 pages use
app/layouts/blank.vue, which has only the skip link.
Project structure#
dashboard/
├─ app/
│ ├─ app.vue <NuxtLayout><NuxtPage /></NuxtLayout>
│ ├─ error.vue the 404 page
│ ├─ router.options.ts keeps query strings written as typed (?q=Olivia%20Martin)
│ ├─ layouts/
│ │ ├─ default.vue sidebar, phone drawer, top bar (search, theme, menus), footer
│ │ └─ blank.vue sign-in and 404 pages
│ ├─ pages/ index, analytics, orders, customers, products, settings, login
│ ├─ components/
│ │ ├─ LineChart.vue, BarChart.vue, DonutChart.vue, FunnelChart.vue, HeatmapChart.vue, SparkLine.vue
│ │ ├─ RevenueCard.vue, VisitorsCard.vue, CustomerPanel.vue, ModalDialog.vue, DropdownMenu.vue
│ │ ├─ GlobalSearch.vue, ThemeToggle.vue, SiteToast.vue, BackToTop.vue
│ │ └─ AppIcon.vue, LogoMark.vue, DotsIcon.vue, StatusBadge.vue, UserAvatar.vue, ProductThumb.vue
│ ├─ composables/
│ │ ├─ useValidation.ts form validation
│ │ ├─ useToast.ts the toast message
│ │ ├─ useToday.ts today (and the copyright year) in the browser
│ │ ├─ useHtmlClass.ts dark mode / collapsed sidebar state, media queries
│ │ ├─ useWidth.ts chart container width
│ │ ├─ useQueryParam.ts ?q= on the orders, customers and products pages
│ │ └─ usePageSeo.ts per-page title, description, canonical and Open Graph tags
│ ├─ data/
│ │ ├─ demo.ts orders, customers, products, chart series, search index
│ │ ├─ nav.ts sidebar links, account menu, notifications
│ │ ├─ overview.ts, analytics.ts, customers.ts, settings.ts page content
│ │ └─ icons.ts, productArt.ts icon and product artwork paths
│ ├─ utils/ format.ts (numbers, dates, chart math), ui.ts, dom.ts, safeStorage.ts, url.ts
│ ├─ plugins/ reveal.client.ts (scroll reveals), stable-build.server.ts
│ └─ assets/
│ ├─ css/main.css fonts, brand and chart tokens, component classes
│ └─ fonts/ Inter and JetBrains Mono (Latin .woff2) with their OFL texts
├─ public/img/ favicon.svg, og-image.png (1200×630) and its og-image.svg source
├─ nuxt.config.ts, eslint.config.mjs, tsconfig.json
├─ package.json, pnpm-workspace.yaml, pnpm-lock.yaml
└─ README.md, DOCS.md, CHANGELOG.mdCustomizing#
Brand and colors#
app/assets/css/main.css defines the brand-* color scale (indigo by default) in @theme, used for buttons,
links, the active navigation item and focus rings. Chart colors are CSS variables set for light mode in :root
and again for dark mode in .dark: --chart-1 to --chart-5, --chart-grid, --chart-text,
--chart-surface and --heat-0. Charts read them when they paint, so they follow the theme without redrawing.
@theme {
--color-brand-500: #0ea5e9;
--color-brand-600: #0284c7;
}The browser theme colors are the two theme-color meta tags in nuxt.config.ts.
Typography#
Inter (400 to 700) and JetBrains Mono (400 and 500) are self-hosted: the @font-face rules at the top of
main.css load the Latin .woff2 files in app/assets/fonts/, the same files Google Fonts serves 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 license to app/assets/fonts/, add an @font-face rule and update the
--font-sans or --font-mono token.
Content#
All demo data lives in app/data/demo.ts:
| Export | What it is |
|---|---|
ANCHOR, shiftDate() |
The date the demo data is generated around (Oct 3, 2026, UTC), and the helper that moves a demo date forward to today |
NAMES, STATUS_TONE |
Customer names; order status → badge color |
PRODUCTS |
Products (name, SKU, category, price, stock, capacity, units sold, hue, icon) |
ORDERS |
48 generated orders |
CUSTOMERS |
Customers made from NAMES and their orders, with a Segment |
revenue(), dayLabels(), monthLabels() |
Revenue series for the 7d, 30d and 12m ranges and the chart labels, ending on a given day |
TRAFFIC_SOURCES, VISITOR_SERIES, CUSTOMER_TYPE_SERIES, FUNNEL |
Overview and analytics chart data |
heatmapDays(), heatmapTotal() |
Order heatmap data |
SEARCH_INDEX |
Everything global search can find |
Page copy that isn't generated is in the other data files: KPIS and ACTIVITY (overview.ts), SUMMARY,
TOP_PAGES and STATES (analytics.ts), CUSTOMER_SUMMARY and SEGMENTS (customers.ts), and MEMBERS,
PLANS, INVOICE_AMOUNTS and NOTIFICATION_GROUPS (settings.ts).
Add a product to PRODUCTS:
{ 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 (the artwork is PRODUCT_ART in
app/data/productArt.ts). To use real data, replace these exports with data from your API: fetch it in a page
with useFetch() or $fetch().
The demo never goes stale: useToday() (app/composables/useToday.ts) returns the visitor's today once the page
has loaded, and every date (charts, orders, the overview greeting, the analytics date range, the plan's renewal
date and the invoices) is worked out from it. The generated HTML shows the ANCHOR dates, or neutral text
("Last 30 days", "the 1st of next month"), until then. Preview another day with ?now=YYYY-MM-DD. With real
data, drop shiftDate() and show your own dates.
Images and artwork#
There are no photos: avatars are initials on colored circles (UserAvatar.vue, color picked from the name),
product images are SVG drawings on tinted tiles (ProductThumb.vue), and charts are SVG. To use product photos,
replace ProductThumb with an <img> at a 4:3 ratio (the grid tile) and a square (the table). The favicon is
public/img/favicon.svg; the social image is public/img/og-image.png (1200×630), drawn from
public/img/og-image.svg.
Navigation, sidebar and top bar#
app/layouts/default.vue holds the sidebar, the phone drawer and the top bar with search, the theme toggle, the
notifications menu and the account menu. The links come from app/data/nav.ts: WORKSPACE_NAV, SETTINGS_NAV,
ACCOUNT_MENU and NOTIFICATIONS. To add a page:
- Create
app/pages/<route>.vuewith<main id="main" class="flex-1 px-4 py-6 sm:px-6 lg:px-8 lg:py-8">as its root and ausePageSeo({ title, description })call. - Add it to
WORKSPACE_NAV(the icon name is a key ofICONSinapp/data/icons.ts). - Add the route to
routesinnuxt.config.tssonuxt generatewrites it, and toSEARCH_INDEX.
Dark mode#
bootScript in nuxt.config.ts runs in <head> and applies the saved theme (ns-theme) and sidebar state
(ns-sidebar) before the first paint, so there's no flash. ThemeToggle.vue flips the dark class on <html>
and saves the choice; Tailwind's dark: variant follows the class (@custom-variant dark in main.css). To
default to light, remove ||(!t&&matchMedia('(prefers-color-scheme: dark)').matches) from bootScript.
Features#
Sidebar and phone drawer#
On wide screens the sidebar collapses to icons (saved as ns-sidebar); collapsed links keep accessible names
and tooltips, and the collapse button becomes "Expand sidebar". On small screens it's a drawer: focus moves into
it and is trapped, Escape or the backdrop closes it, and focus returns to the menu button. It also closes when a
link opens another page or the window grows to desktop width.
Global search#
Ctrl K / ⌘ K or / focuses search (GlobalSearch.vue). Results come from SEARCH_INDEX, show up to eight
matches and are navigable with the arrow keys and Enter; Escape clears the field. Entries link to filtered pages
such as /orders?q=NS-4821, which read the query with useQueryParam() after the page has loaded and when a
link opens the same page with another value.
Menus, dialogs and toasts#
DropdownMenu.vue focuses the first item when it opens, moves with the arrow keys, Home and End, and closes
with Escape (returning focus to its button), Tab or a click outside. ModalDialog.vue is appended to <body>
while open, traps focus and returns it. useToast() gives a toast(message) function; the message shows for
2.8 seconds in SiteToast.vue, a polite live region. Buttons for screens the template doesn't include (help
center, new customer, CSV import, photo upload and so on) show an in-app message such as "Opening the help
center…" or "Exporting your report. The PDF will arrive by email."; point them at your own pages or actions and
change the messages to match.
Charts#
LineChart, BarChart, DonutChart, FunnelChart, HeatmapChart and SparkLine are responsive SVG drawn
from props. The width-dependent charts draw after the page has loaded at their container's width (useWidth())
and redraw when it changes. LineChart is keyboard-accessible: focus it and use the left and right arrow keys
to move between points, Escape to hide the tooltip.
<LineChart
label="Revenue, last 30 days"
:labels="labels"
:series="[{ name: 'This period', color: 'var(--chart-1)', values, area: true }]"
:format="(v) => money0.format(v)"
/>Each series needs as many values as there are labels. VisitorsCard.vue hides a line when its legend button is
pressed (one line always stays), and DonutChart highlights a segment on hover or when its legend row has focus.
Orders table#
app/pages/orders.vue has status tabs with counts (and the same filter as a select), search (also from ?q=),
sortable columns with aria-sort, PER_PAGE (10) rows per page, row selection with a select-all box, and a
bulk-action bar (mark as paid, export, clear). Export only shows a toast ("Exporting 3 orders. The CSV will
arrive by email."); connect it to your own export.
Customers and products#
app/pages/customers.vue filters by segment and search and opens CustomerPanel.vue, a slide-over dialog with
the customer's numbers, recent orders and a validated note form; Escape, the backdrop or Close shut it and focus
returns to the row. app/pages/products.vue filters by category and search and switches between a grid and a
table, remembered as ns-products-view. "Restock" shows "Restock request TC-7K2Q created for …" with a short
reference made in the browser by makeRef() in app/utils/ui.ts; use your purchasing system's own reference if
it has one.
Settings#
app/pages/settings.vue keeps the selected tab in the URL hash (/settings#team, /settings#billing) and
follows links to another tab. Tabs support the arrow keys, Home and End. It includes the profile form with a bio
counter, the team list with an invite dialog and remove buttons, plan cards with a switch button, invoices dated
from today and notification switches (role="switch").
Back to top button#
BackToTop.vue (appended to <body> by both layouts) 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 main.css.
Remove <BackToTop /> from the layouts and those styles to drop it.
Forms#
Forms use useValidation() from app/composables/useValidation.ts:
<script setup lang="ts">
const form = useValidation();
const toast = useToast();
</script>
<template>
<form novalidate @submit="form.submit($event, () => toast('Profile saved'))">
<input type="email" class="field" v-bind="form.field('profile-email', { label: 'Email address', required: true, email: true })">
<p v-if="form.errors['profile-email']" id="profile-email-error" class="field-error">{{ form.errors["profile-email"] }}</p>
</form>
</template>field(id, rules) returns the attributes for an input (id, name, required, aria-invalid,
aria-describedby and the blur and input checks); rules are label, required, email and minLength.
submit(event, onValid) checks every field, focuses the first invalid one, and otherwise calls onValid(formElement).
| Form | File | Behavior |
|---|---|---|
| Sign in | app/pages/login.vue |
Email and password (8+ characters, with a Show/Hide button), then opens / |
| Profile | app/pages/settings.vue |
Name and email; "Profile saved" toast; Cancel resets the form |
| Invite member | app/pages/settings.vue (dialog) |
Email and role; adds the member to the team list and shows "Invitation sent to …" |
| Customer note | app/components/CustomerPanel.vue |
Note text; "Note saved to customer profile" toast |
Nothing is saved or sent until you connect it, although the messages read as if it were ("Invitation sent
to …", "Plan changed to Scale. The new price starts on your next invoice."). To save for real, post the data in
onValid, for example to a form service such as Formspree or to your own API
(@submit="form.submit($event, saveProfile)"):
async function saveProfile(el: HTMLFormElement) {
try {
await $fetch("https://formspree.io/f/your-id", { method: "POST", body: new FormData(el), headers: { Accept: "application/json" } });
toast("Profile saved");
} catch {
toast("Something went wrong. Try again.");
}
}To write the API inside this project, add server routes in server/api/ (for example
server/api/profile.post.ts) and deploy with pnpm build to a host that runs Node.js; nuxt generate alone
produces static files only. Replace the timeout in signIn() (login.vue) with your authentication provider,
and protect the dashboard routes on the server (for example with route middleware that checks a session);
hiding a link isn't protection.
Saved data (localStorage)#
| Key | Values | Used for |
|---|---|---|
ns-theme |
"dark" or "light" |
Color theme |
ns-sidebar |
"collapsed" or "open" |
Desktop sidebar state |
ns-products-view |
"grid" or "table" |
Products page view |
The keys are listed in STORAGE_KEYS (app/utils/safeStorage.ts). 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). safeStorage keeps choices in memory when the browser blocks storage or it is full, so they still last
for the page view. To reset, remove the keys in the browser's developer tools
(localStorage.removeItem("ns-products-view")).
SEO and sharing#
- Every page calls
usePageSeo({ title, description })(app/composables/usePageSeo.ts), which sets the title (Orders · Northstar Admin), description, canonical URL, Open Graph tags and the Twitter card. The 404 page passesnoindex: true. - URLs are absolute:
runtimeConfig.public.siteUrlinnuxt.config.tsreadsSITE_URL(defaulthttps://northstar.example). Set it when you build:SITE_URL=https://admin.example.com pnpm generate. - An admin area usually shouldn't be indexed: pass
noindex: trueon every page and add apublic/robots.txtwithDisallow: /.
Accessibility#
Built in: a skip link, landmarks, visible focus rings, a focus-trapped drawer, dialogs and customer panel, menus
that close with Escape, tabs with the arrow keys, Home and End, keyboard-readable line charts, aria-sort on
sortable columns, aria-pressed toggles and role="switch" switches, form errors linked with
aria-describedby, a polite live region for toasts, reduced-motion support (no reveals or slide animations), and
WCAG AA contrast in both themes.
When editing, keep one <h1> per page, label every control, and give each LineChart a descriptive label.
Building and deploying#
pnpm generate # static site in .output/public
pnpm preview # serve it locallyDeploy .output/public to any static host (Netlify, Cloudflare Pages, GitHub Pages, Vercel, an S3 bucket). Every
page is a file such as orders.html; hosts that serve /orders from orders.html (most do) give clean URLs, and
404.html is the not-found page. For a sub-folder, set DEMO_BASE_PATH
(DEMO_BASE_PATH=/admin pnpm generate), which becomes Nuxt's app.baseURL. For a real admin area with server
routes and authentication, use pnpm build and a host that runs Node.js. demo/, if present, is the
freeframe.dev demo build and can be deleted.
Troubleshooting#
pnpm lintcan't find.nuxt/eslint.config.mjs. Runpnpm install(orpnpm typecheck) first:nuxt preparewrites it.- A new page is missing from
.output/public. Add its route toroutesinnuxt.config.ts. - 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 expand button. - Dates show October 2026 with JavaScript off. That is the generated HTML's
ANCHOR; in the browser they follow today (useToday()). - A hydration warning after you add a date or a stored setting. Read the clock, the URL and
localStorageinonMounted()or an event handler, never while rendering.
License#
Freeframe Commercial License: use it in unlimited websites and apps for yourself and your clients; it may not
be resold or redistributed as a template. The full terms, with your licensee details, are in LICENSE.txt and
at https://freeframe.dev/license#commercial. Third-party components keep their own licenses. Inter and
JetBrains Mono are licensed under the SIL Open Font License. Business, customer and personal details are
fictional.