Free kit: animated pieces + 8 AI prompts → https://qodesign.dev/free?utm_source=devto&utm_medium=tutorial&utm_campaign=tutorial6&utm_content=top
Ask v0, Cursor or Lovable for "a SaaS dashboard" and you'll get roughly the same page every time. Four cards in a row, each with a small grey label, a big number and a green "+12%". A line chart. A table. A moon icon in the corner that may or may not do anything. While data loads there's a spinner in the middle of the screen, and when you click Export the button just sits there.
None of that is wrong. It's just the most common dashboard in the training data, so it's what you get when the prompt doesn't ask for anything else. Your users have seen it in a dozen other tools, though, and a page that looks unfinished makes people wonder what else got skipped.
These are the five things I change first. Each one is a few minutes of work in a Next.js App Router project with Tailwind v4. Two of them use components from my free set; everything else is plain Tailwind and React.
I tested every snippet in one fresh app before writing this: Next.js 16.4.2, React 19.3, Tailwind v4, Framer Motion 14.4, with cacheComponents on. tsc and next build both passed, and I clicked through it in headless Chromium.
1. One number leads, the rest step back
Four identical cards tell the reader that revenue, signups, churn and active users all matter equally. They almost never do. Pick the number this page exists for and make it big. The others become a quieter row next to it.
While you're there, add tabular-nums to every number. Default proportional digits mean a "1" is narrower than an "8", so values shift sideways when they update and columns of figures don't line up.
// app/kpis.tsx (the top half)
import { getStats } from "@/lib/data";
import { fmtCompact, fmtDelta, fmtMoney } from "@/lib/format";
export async function Kpis() {
const s = await getStats();
const delta = (s.revenue - s.revenuePrev) / s.revenuePrev;
return (
<section className="grid gap-4 lg:grid-cols-3">
{/* the one number this page exists for */}
<div className="rounded-2xl border border-zinc-200 p-6 lg:row-span-2 dark:border-zinc-800">
<p className="text-sm text-zinc-500">Revenue, last 30 days</p>
<p className="mt-2 text-5xl font-semibold tracking-tight tabular-nums">
{fmtMoney(s.revenue)}
</p>
<p className="mt-2 text-sm tabular-nums">
<span className={delta >= 0 ? "text-emerald-600" : "text-rose-600"}>
{fmtDelta(delta)}
</span>{" "}
<span className="text-zinc-500">vs the 30 days before</span>
</p>
</div>
{/* supporting numbers: smaller, quieter, no cards of their own */}
<dl className="grid grid-cols-3 gap-4 rounded-2xl border border-zinc-200 p-6 lg:col-span-2 dark:border-zinc-800">
<Stat label="Active users" value={fmtCompact(s.activeUsers)} />
<Stat label="New signups" value={s.signups.toLocaleString("en-US")} />
<Stat label="Churn" value={`${(s.churn * 100).toFixed(1)}%`} />
</dl>
</section>
);
}
// app/kpis.tsx, continued
function Stat({ label, value }: { label: string; value: string }) {
return (
<div>
<dt className="text-xs text-zinc-500">{label}</dt>
<dd className="mt-1 text-xl font-medium tabular-nums">{value}</dd>
</div>
);
}
Two smaller changes in there are worth copying on their own. The delta says what it's compared against ("vs the 30 days before"), since a bare "+15.1%" leaves the reader guessing. And the supporting stats sit in a <dl>, which is what they are, so assistive tech can pair each label with its value.
2. Format numbers like a person would write them
Generated dashboards tend to print whatever the API returned: 48210.4, 12873, 0.021. One small file fixes that for the whole app:
// lib/format.ts
const money = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
});
const compact = new Intl.NumberFormat("en-US", {
notation: "compact",
maximumFractionDigits: 1,
});
const pct = new Intl.NumberFormat("en-US", {
style: "percent",
maximumFractionDigits: 1,
signDisplay: "exceptZero",
});
export const fmtMoney = (n: number) => money.format(n);
export const fmtCompact = (n: number) => compact.format(n);
export const fmtDelta = (ratio: number) => pct.format(ratio);
On the sample dashboard data below, that turns the revenue metric 48210.4 into 48,210 with a dollar sign in front, the user count into 12.9K and the change into +15.1%. signDisplay: "exceptZero" puts the plus sign on growth and leaves a flat week as 0%. Create the formatters once at module level, since building a new Intl.NumberFormat on every render is slower than reusing one.
3. A skeleton shaped like the page, and an empty state with a next step
The centred spinner is the loudest tell of the five. It shows nothing about what's coming, and when the data lands the whole layout jumps.
In the App Router you can stream the slow part and show a placeholder with the same shape in its place. Here's the fake data function. connection() tells Next.js this is request-time data, so it isn't baked into the build:
// lib/data.ts
import { connection } from "next/server";
export type Stats = {
revenue: number;
revenuePrev: number;
activeUsers: number;
churn: number;
signups: number;
};
export async function getStats(): Promise<Stats> {
await connection(); // request-time data, not baked into the build
await new Promise((r) => setTimeout(r, 800)); // pretend this is your DB
return {
revenue: 48210.4,
revenuePrev: 41875.9,
activeUsers: 12873,
churn: 0.021,
signups: 342,
};
}
The skeleton reuses the real layout with grey bars where the text goes, so nothing moves when the numbers arrive:
// app/kpis.tsx (the bottom half)
export function KpisSkeleton() {
return (
<section className="grid gap-4 lg:grid-cols-3" aria-busy="true" aria-label="Loading stats">
<div className="rounded-2xl border border-zinc-200 p-6 lg:row-span-2 dark:border-zinc-800">
<div className="h-4 w-36 animate-pulse rounded bg-zinc-200 dark:bg-zinc-800" />
<div className="mt-3 h-12 w-48 animate-pulse rounded bg-zinc-200 dark:bg-zinc-800" />
<div className="mt-3 h-4 w-40 animate-pulse rounded bg-zinc-200 dark:bg-zinc-800" />
</div>
<div className="grid grid-cols-3 gap-4 rounded-2xl border border-zinc-200 p-6 lg:col-span-2 dark:border-zinc-800">
{[0, 1, 2].map((i) => (
<div key={i}>
<div className="h-3 w-20 animate-pulse rounded bg-zinc-200 dark:bg-zinc-800" />
<div className="mt-2 h-6 w-16 animate-pulse rounded bg-zinc-200 dark:bg-zinc-800" />
</div>
))}
</div>
</section>
);
}
Then wrap the async component in Suspense on the page (the full page is in section 5). With cacheComponents on, the build reported the route as partially prerendered: the header and skeleton are static HTML and the numbers stream in.
Empty states get the same treatment. "No data" in grey is what you get by default. Say why the table is empty and give one thing to do about it:
// app/invoices-empty.tsx
import Link from "next/link";
export function InvoicesEmpty() {
return (
<div className="rounded-2xl border border-dashed border-zinc-300 px-6 py-12 text-center dark:border-zinc-700">
<p className="font-medium">No invoices yet</p>
<p className="mx-auto mt-1 max-w-sm text-sm text-zinc-500">
Invoices show up here after your first paid order. Send a test one to see how it looks.
</p>
<Link
href="/invoices/new?test=1"
className="mt-4 inline-block rounded-lg bg-zinc-900 px-3 py-1.5 text-sm text-white dark:bg-white dark:text-zinc-900"
>
Create a test invoice
</Link>
</div>
);
}
Point the link at a route your app actually has.
4. Dark mode that's really there
The usual generated version is a sun/moon icon button. On Tailwind v4 that button can toggle a .dark class the dark: variant never sees, because v4's default dark: follows the OS setting. Getting this right takes three changes.
First, point the dark: variant at the class:
/* app/globals.css */
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
The toggle needs nothing here. Since qodesign-free 0.1.1 it ships its own small stylesheet, scoped to the component, so it renders the same with or without Tailwind.
Second, set the class before the first paint, or a dark-mode user gets a white flash on every load:
// app/layout.tsx
import type { Metadata } from "next";
import { Toaster } from "qodesign-free/toast-stack";
import "./globals.css";
export const metadata: Metadata = { title: "Dashboard" };
// Runs before first paint, so a dark-mode user never sees a white flash.
const themeScript = `try{var t=localStorage.getItem("theme");if(t==="dark"||(!t&&matchMedia("(prefers-color-scheme: dark)").matches))document.documentElement.classList.add("dark")}catch(e){}`;
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>
<body className="bg-white text-zinc-900 antialiased dark:bg-zinc-950 dark:text-zinc-100">
{children}
<Toaster />
</body>
</html>
);
}
suppressHydrationWarning is needed because the script changes the <html> class before React hydrates. It only applies to that one element.
Third, the switch. I use my Day/Night Toggle here: the sun slides across and turns into the moon, the clouds drop away and the stars fall in. A theme switch is one of the few spots in a dashboard where a bit of play costs nothing. Install it with Framer Motion:
npm i qodesign-free framer-motion
The <html> class is the source of truth, and useSyncExternalStore lets React read it without a useEffect that sets state after mount:
// app/theme-toggle.tsx
"use client";
import { useSyncExternalStore } from "react";
import DayNightToggle from "qodesign-free/day-night-toggle";
// The <html> class is the source of truth. React just watches it.
function subscribe(onChange: () => void) {
const observer = new MutationObserver(onChange);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}
const isDark = () => document.documentElement.classList.contains("dark");
export function ThemeToggle() {
// the server can't know the theme, so it renders "day"
const night = useSyncExternalStore(subscribe, isDark, () => false);
return (
<DayNightToggle
width={64}
label="Dark mode"
night={night}
onChange={(next) => {
document.documentElement.classList.toggle("dark", next);
localStorage.setItem("theme", next ? "dark" : "light");
}}
/>
);
}
The third argument to useSyncExternalStore is the server snapshot. Leave it out and the Next.js build fails with "Missing getServerSnapshot". The server always renders the day state, and React corrects it on the client right after hydration. The MutationObserver also keeps the switch in sync if something else changes the class.
The toggle respects prefers-reduced-motion and swaps the animation for a short cross-fade. If you'd rather own the source than depend on a package, it's also a shadcn registry item. That route copies the file into your project, where your own Tailwind setup styles it:
npx shadcn@latest add https://teodoravadani.github.io/qodesign-free/r/day-night-toggle.json
5. Buttons that answer back
In a lot of generated dashboards, Export gives no sign of life until a file shows up (or doesn't). Archive removes rows with no way back. Every action that takes time or changes data should say what it's doing and what happened.
I use the Toast Stack from the same free set. It's already in the layout from section 4 (<Toaster />), so you can call toast() from any client component:
// app/invoice-actions.tsx
"use client";
import { toast } from "qodesign-free/toast-stack";
// stand-ins for your server actions or API calls
const exportCsv = () => new Promise<number>((r) => setTimeout(() => r(214), 1400));
const archive = (_ids: string[]) => new Promise<void>((r) => setTimeout(r, 300));
const restore = (_ids: string[]) => new Promise<void>((r) => setTimeout(r, 300));
export function InvoiceActions({ selected }: { selected: string[] }) {
return (
<div className="flex gap-2">
<button
className="rounded-lg border border-zinc-200 px-3 py-1.5 text-sm dark:border-zinc-800"
onClick={() =>
toast.promise(exportCsv(), {
loading: { title: "Exporting invoices..." },
success: (rows) => ({
title: "Export ready",
description: `${rows} rows in invoices.csv`,
}),
error: () => ({ title: "Export failed", description: "Nothing was downloaded." }),
})
}
>
Export CSV
</button>
<button
className="rounded-lg bg-zinc-900 px-3 py-1.5 text-sm text-white disabled:opacity-40 dark:bg-white dark:text-zinc-900"
disabled={selected.length === 0}
onClick={async () => {
await archive(selected);
toast.success(`${selected.length} invoices archived`, {
action: {
label: "Undo",
onClick: () => {
void restore(selected).then(() => toast.info("Restored"));
},
},
});
}}
>
Archive
</button>
</div>
);
}
toast.promise shows a spinner, then turns into a check with the row count when the export resolves, or an error if it fails. It keeps the spinner up for at least 600 ms, so a fast request doesn't flash. Archive does the work first, then offers Undo, which is friendlier than an "Are you sure?" dialog for anything you can reverse.
And the page that puts it all together:
// app/page.tsx
import { Suspense } from "react";
import { Kpis, KpisSkeleton } from "./kpis";
import { ThemeToggle } from "./theme-toggle";
import { InvoiceActions } from "./invoice-actions";
import { InvoicesEmpty } from "./invoices-empty";
export default function Dashboard() {
return (
<main className="mx-auto max-w-5xl space-y-8 p-6">
<header className="flex items-center justify-between">
<h1 className="text-lg font-semibold">Overview</h1>
<ThemeToggle />
</header>
<Suspense fallback={<KpisSkeleton />}>
<Kpis />
</Suspense>
<section className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="font-medium">Invoices</h2>
<InvoiceActions selected={["inv_1", "inv_2", "inv_3"]} />
</div>
<InvoicesEmpty />
</section>
</main>
);
}
A prompt if you'd rather let the AI do it
Here are sections 1 to 3 as a prompt for Cursor, v0 or Lovable. Paste it with your dashboard page open, then read the diff:
Rework this dashboard page (Next.js App Router, Tailwind v4). Keep the data and copy.
1. Make the first metric the hero: text-5xl, its delta with "vs <period>" next to it. Move the other metrics into one <dl> row, text-xl. Add tabular-nums to every number.
2. Create lib/format.ts with Intl.NumberFormat helpers (currency without cents, compact, percent with signDisplay "exceptZero") and use them for every number.
3. Replace any spinner with a Suspense boundary whose fallback is a skeleton with the same layout as the loaded content.
4. Give every empty list a message saying why it's empty and one link to the next step.
5. Don't add dependencies and don't touch the chart.
I'd still do the dark mode by hand. The no-flash script and the server snapshot are small, and a generated edit can easily leave one of them out.
About me, and the links. I'm Teodor, and I make animated UI components at qodesign, so the toggle and the toast stack in this post are mine. The Day/Night Toggle is free on the site, and the MIT repo qodesign-free on GitHub holds it too, along with the Toast Stack, Pricing Switch, Upload Dropzone and the Orbit and Ledger dashboards. The paid catalogue is animated components, each with a React + Tailwind version and a PROMPT.md for AI coding tools. The dashboard one is at qodesign.dev. You can grab the free toggle at qodesign.dev/free.
If you run this on your own dashboard, I'd like to see the before and after.
I wrote this post with help from an AI assistant. Every snippet above was built and type-checked in a fresh Next.js 16 app (tsc and next build passing), and I clicked through the theme switch and the toasts in a browser before publishing.
Top comments (0)