DEV Community

Cover image for The v0 dashboard look: 5 things I change first in Next.js + Tailwind
Teodor Avadani
Teodor Avadani

Posted on

The v0 dashboard look: 5 things I change first in Next.js + Tailwind

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>
  );
}
Enter fullscreen mode Exit fullscreen mode
// 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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,
  };
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 *));
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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");
      }}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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)