What you will build
A /dashboard route with a title and a Week / Month switch, then four KPI cards (one column on phones, two on tablets, four on desktop), a bar chart next to a Top sources list, and a Recent signups table. The range lives in the URL as ?range=month, so it survives a reload and you can share the link. Every file is a server component, so the numbers can come straight from your database.
It needs Next.js 15 with the App Router and Tailwind v4. No packages.
The data in one function
Keep every number behind one async function that takes the range. The page only knows the shape, so moving from sample numbers to your database or analytics API is a change in this one file.
export type Range = "week" | "month";
export type Kpi = { label: string; value: number; format: "number" | "percent"; delta: number };
export type Point = { label: string; value: number };
export type Source = { name: string; share: number };
export type Signup = { id: string; name: string; email: string; plan: string; when: string };
// Swap the numbers for your database or analytics API. Keep the shape.
export async function getDashboard(range: Range) {
const days = range === "week" ? 7 : 30;
const series: Point[] = Array.from({ length: days }, (_, i) => ({
label: range === "week" ? ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"][i] : String(i + 1),
value: Math.round(420 + 160 * Math.sin(i / 1.7) + i * 9),
}));
const visitors = series.reduce((sum, p) => sum + p.value, 0);
const signups = Math.round(visitors * 0.034);
const kpis: Kpi[] = [
{ label: "Visitors", value: visitors, format: "number", delta: 12.4 },
{ label: "Signups", value: signups, format: "number", delta: 8.1 },
{ label: "Conversion", value: (signups / visitors) * 100, format: "percent", delta: -0.3 },
{ label: "Active users", value: Math.round(signups * 0.62), format: "number", delta: 4.7 },
];
const sources: Source[] = [
{ name: "Search", share: 41 },
{ name: "Direct", share: 27 },
{ name: "Social", share: 19 },
{ name: "Referral", share: 13 },
];
const recent: Signup[] = [
{ id: "u1", name: "Maya Chen", email: "[email protected]", plan: "Pro", when: "2 min ago" },
{ id: "u2", name: "Leo Hart", email: "[email protected]", plan: "Free", when: "18 min ago" },
{ id: "u3", name: "Ana Pop", email: "[email protected]", plan: "Team", when: "1 h ago" },
{ id: "u4", name: "Sam Ortiz", email: "[email protected]", plan: "Free", when: "3 h ago" },
];
return { kpis, series, sources, recent };
}KPI cards
Each card shows the value, then the change against the last period. The arrow is decoration, so it is hidden from screen readers and a visually hidden Up or Down takes its place.
import type { Kpi, Range } from "./data";
const format = (k: Kpi) =>
k.format === "percent" ? `${k.value.toFixed(1)}%` : new Intl.NumberFormat("en-US").format(k.value);
export function KpiCard({ kpi, range }: { kpi: Kpi; range: Range }) {
const up = kpi.delta >= 0;
return (
<div className="rounded-2xl border border-zinc-800 bg-zinc-900/60 p-5">
<p className="text-sm text-zinc-400">{kpi.label}</p>
<p className="mt-2 text-3xl font-semibold tabular-nums">{format(kpi)}</p>
<p className={`mt-1 text-sm ${up ? "text-emerald-400" : "text-red-400"}`}>
<span aria-hidden="true">{up ? "▲" : "▼"} </span>
<span className="sr-only">{up ? "Up" : "Down"} </span>
{Math.abs(kpi.delta).toFixed(1)}%<span className="text-zinc-500"> vs last {range}</span>
</p>
</div>
);
}tabular-nums gives every digit the same width, so the numbers do not jump around when the range changes. To make them count up, see the number counter post under Read next.
A bar chart without a chart library
One SVG with a viewBox of 100 by 50 and preserveAspectRatio="none" stretches to any width. Each bar is a rect, and its height is its share of the largest value. The labels sit in a normal row under the SVG so the text is not stretched with it.
import type { Point } from "./data";
export function BarChart({ title, data }: { title: string; data: Point[] }) {
const max = Math.max(...data.map((d) => d.value));
const slot = 100 / data.length;
const every = data.length > 12 ? 5 : 1; // label every 5th day on the month view
return (
<figure className="rounded-2xl border border-zinc-800 bg-zinc-900/60 p-5">
<figcaption className="text-sm text-zinc-400">{title}</figcaption>
<svg viewBox="0 0 100 50" preserveAspectRatio="none" aria-hidden="true" className="mt-4 h-48 w-full">
{data.map((d, i) => {
const h = (d.value / max) * 48;
return (
<rect
key={`${data.length}-${d.label}`}
x={i * slot + slot * 0.15}
y={50 - h}
width={slot * 0.7}
height={h}
className="origin-bottom fill-lime-300/80 [transform-box:fill-box] hover:fill-lime-300 motion-safe:animate-[grow_.6s_cubic-bezier(.2,.8,.2,1)_both]"
style={{ animationDelay: `${i * 25}ms` }}
>
<title>{`${d.label}: ${d.value}`}</title>
</rect>
);
})}
</svg>
<div className="mt-2 flex text-[11px] text-zinc-500" aria-hidden="true">
{data.map((d, i) => (
<span key={d.label} className="flex-1 text-center">{i % every === 0 ? d.label : ""}</span>
))}
</div>
{/* the same numbers as a table for screen readers */}
<table className="sr-only">
<caption>{title}</caption>
<tbody>
{data.map((d) => (
<tr key={d.label}>
<th scope="row">{d.label}</th>
<td>{d.value}</td>
</tr>
))}
</tbody>
</table>
</figure>
);
}The grow animation scales each bar from 0 to full height. [transform-box:fill-box] with origin-bottom makes every bar grow from its own bottom edge, and the 25 ms delay per bar gives the stagger. The key includes the number of bars, so switching from week to month mounts new bars and the animation plays again. Screen readers skip the drawing and read the same numbers from the hidden table. Add the keyframe to your global CSS:
@keyframes grow {
from { transform: scaleY(0); }
to { transform: scaleY(1); }
}Top sources and recent signups
Two plain blocks. The source bars are divs with a percentage width.
import type { Source } from "./data";
export function Sources({ sources }: { sources: Source[] }) {
return (
<section className="rounded-2xl border border-zinc-800 bg-zinc-900/60 p-5">
<h2 className="text-sm text-zinc-400">Top sources</h2>
<ul className="mt-4 space-y-4">
{sources.map((s) => (
<li key={s.name}>
<div className="flex justify-between text-sm">
<span>{s.name}</span>
<span className="tabular-nums text-zinc-400">{s.share}%</span>
</div>
<div className="mt-1.5 h-2 rounded-full bg-zinc-800">
<div className="h-full rounded-full bg-lime-300/80" style={{ width: `${s.share}%` }} />
</div>
</li>
))}
</ul>
</section>
);
}The table keeps a 520px minimum width inside an overflow-x-auto box, so on a phone it scrolls sideways instead of squashing the columns or making the whole page wider than the screen.
import type { Signup } from "./data";
export function RecentSignups({ rows }: { rows: Signup[] }) {
return (
<section className="rounded-2xl border border-zinc-800 bg-zinc-900/60 p-5">
<h2 className="text-sm text-zinc-400">Recent signups</h2>
<div className="mt-3 overflow-x-auto">
<table className="w-full min-w-[520px] text-left text-sm">
<thead className="text-zinc-500">
<tr>
<th scope="col" className="py-2 font-normal">Name</th>
<th scope="col" className="py-2 font-normal">Email</th>
<th scope="col" className="py-2 font-normal">Plan</th>
<th scope="col" className="py-2 text-right font-normal">When</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800">
{rows.map((r) => (
<tr key={r.id}>
<td className="py-3 font-medium">{r.name}</td>
<td className="py-3 text-zinc-400">{r.email}</td>
<td className="py-3">
<span className="rounded-full bg-zinc-800 px-2 py-0.5 text-xs">{r.plan}</span>
</td>
<td className="py-3 text-right text-zinc-400">{r.when}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
);
}The range switch and the page
The switch is two links, not state. Next.js renders the server page again with the new searchParams, and scroll={false} keeps you where you were on the page.
import Link from "next/link";
import type { Range } from "./data";
export function RangeSwitch({ range }: { range: Range }) {
return (
<nav aria-label="Date range" className="inline-flex rounded-full border border-zinc-800 bg-zinc-900 p-1 text-sm">
{(["week", "month"] as const).map((r) => (
<Link
key={r}
href={`/dashboard?range=${r}`}
scroll={false}
aria-current={r === range ? "page" : undefined}
className={`rounded-full px-4 py-1.5 capitalize transition-colors ${
r === range ? "bg-zinc-100 text-zinc-950" : "text-zinc-400 hover:text-zinc-100"
}`}
>
{r}
</Link>
))}
</nav>
);
}In Next.js 15 searchParams is a Promise, so the page awaits it. Anything other than month falls back to week.
import type { Metadata } from "next";
import { BarChart } from "./BarChart";
import { KpiCard } from "./KpiCard";
import { RangeSwitch } from "./RangeSwitch";
import { RecentSignups } from "./RecentSignups";
import { Sources } from "./Sources";
import { getDashboard, type Range } from "./data";
export const metadata: Metadata = { title: "Dashboard" };
export default async function DashboardPage({ searchParams }: { searchParams: Promise<{ range?: string }> }) {
const { range: q } = await searchParams;
const range: Range = q === "month" ? "month" : "week";
const { kpis, series, sources, recent } = await getDashboard(range);
return (
<div className="mx-auto max-w-6xl space-y-4 px-5 py-8">
<header className="flex flex-wrap items-center justify-between gap-4 pb-2">
<h1 className="text-2xl font-semibold">Overview</h1>
<RangeSwitch range={range} />
</header>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{kpis.map((k) => (
<KpiCard key={k.label} kpi={k} range={range} />
))}
</div>
<div className="grid gap-4 lg:grid-cols-3">
<div className="lg:col-span-2">
<BarChart title={range === "week" ? "Visitors this week" : "Visitors, last 30 days"} data={series} />
</div>
<Sources sources={sources} />
</div>
<RecentSignups rows={recent} />
</div>
);
}loading.tsx shows a skeleton with the same layout while the data loads, so nothing jumps when it arrives.
export default function Loading() {
return (
<div className="mx-auto max-w-6xl space-y-4 px-5 py-8" aria-busy="true" aria-label="Loading dashboard">
<div className="h-9 w-40 animate-pulse rounded-lg bg-zinc-800" />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }, (_, i) => (
<div key={i} className="h-32 animate-pulse rounded-2xl bg-zinc-900" />
))}
</div>
<div className="h-72 animate-pulse rounded-2xl bg-zinc-900" />
</div>
);
}What we checked
In Chrome at 390px and 1440px, in a Next.js 15.5 app with React 19 and Tailwind v4: the cards sat in 1 column at 390px and 4 at 1440px, and the page never got wider than the screen (at 390px the table scrolled inside its box, 520px of content in 308px). The week view drew 7 bars that were still growing when we first read them and at full height a second later. Month drew 30 bars labelled 1, 6, 11, 16, 21 and 26, the switch moved aria-current to Month and the cards changed to the month numbers. The hidden table held all 30 values. With reduced motion on, the bars did not animate.
Using Cursor, v0 or Lovable
Cursor: paste the folder and ask it to rewrite getDashboard against your database. Keep the shape of what it returns and the components need no change.
v0: v0 dashboards usually pull in a chart package such as Recharts. That works, but if you want no chart package, paste BarChart.tsx and ask v0 to use it for the bar chart.
Lovable: React with Vite has no server components, so keep the components, call getDashboard from a useEffect or React Query, and read the range with useSearchParams from React Router.
Or use the ready-made piece
If you want it to feel alive, Analytics Dashboard V1 is a dark analytics dashboard where the KPI numbers count up with easing, the bar chart springs in with a stagger and shows tooltips on hover, a goal ring fills, and a Week/Month switch with a sliding pill animates everything to the new data.
Everything above is free to copy. The piece is for when you want the finished, animated version with its source and a PROMPT.md for Cursor, v0 and Lovable.
Questions
- How do I make a dashboard in Next.js with Tailwind?
- Fetch the numbers in a server component page, lay out KPI cards with a responsive grid like grid sm:grid-cols-2 lg:grid-cols-4, draw the charts in SVG or with a chart library, and keep filters such as the date range in the URL.
- Do I need a chart library for a bar chart in React?
- Not for a simple one. An SVG with one rect per value and a viewBox that stretches covers it. Add a CSS scaleY animation for the grow-in and a hidden table for screen readers.
- How do I keep a dashboard filter after a reload in Next.js?
- Put it in the URL as a search param and link to it. The server page reads searchParams, so a reload or a shared link shows the same view.
- How do I stop a dashboard table from breaking the layout on mobile?
- Wrap the table in a div with overflow-x-auto and give the table a min-width. It scrolls inside its box and the page keeps the screen width.