The first screen of every SaaS dashboard is the same: four or six stat cards, then a table. And yet I keep seeing two failure modes in client codebases. Either the frontend fires five separate requests to five separate endpoints and the cards pop in one by one, or there's one god-endpoint returning a nested blob that only one screen understands and every redesign breaks. There's a middle path, and it's boring in the best way: one endpoint, exactly the numbers the cards need, nothing else.
The endpoint returns raw metrics, not presentation
public function stats(): JsonResponse
{
return response()->json([
'total_users' => User::count(),
'new_users_this_week' => User::where('created_at', '>=', now()->subWeek())->count(),
'admins_count' => User::where('role', 'admin')->count(),
'active_tokens' => PersonalAccessToken::count(),
]);
}
Four keys. Each is a cheap aggregate query. No joins, no N+1, no serialisation of model graphs — the response is a flat object of numbers, computed server-side in milliseconds. The route sits behind auth:sanctum (every logged-in user sees the dashboard), but not behind the admin middleware: the per-user list shown underneath is fetched from the admin-gated /users endpoint separately, and only when user.role === 'admin'.
The design principle: the API returns facts, the frontend decides presentation. The key is total_users, not "Total Users" with a colour and an icon. If a product owner renames a card or swaps two cards' positions next month, no backend change is needed. The moment an API starts returning label, colour, and icon_name fields, the backend owns the UI, and every design tweak becomes a deployment.
The frontend fetches everything in parallel, once
The Dashboard page loads its data with a single Promise.all:
const [statsRes, usersRes] = await Promise.all([
client.get('/dashboard/stats'),
isAdmin ? client.get('/users') : Promise.resolve(null),
]);
Two details worth copying:
-
The non-admin path resolves to
null, not a skipped promise. The destructuring shape stays identical either way, so the rendering logic below doesn't branch on "did we ask". -
There's a
cancelledflag on unmount (let cancelled = false; ... return () => { cancelled = true; }), checked before everysetState. Dashboards re-render and unmount while requests are in flight more often than you'd think; without the guard you get the classic "state update on unmounted component" warning, which is React politely telling you there's a memory leak.
StatCard is deliberately dumb
export default function StatCard({ label, value, icon, accent = 'indigo' }) {
const accentBg = {
indigo: 'bg-indigo-100 text-indigo-600',
emerald: 'bg-emerald-100 text-emerald-600',
amber: 'bg-amber-100 text-amber-600',
rose: 'bg-rose-100 text-rose-600',
}[accent];
// ...renders label, value, and an icon tile
}
It takes a label, a value, an icon, and an accent colour. That's the entire API surface. The page maps total_users → "Total Users" with the indigo accent, new_users_this_week → emerald, and so on. The component knows nothing about users, tokens, or weeks.
This is the part I used to get wrong. My old dashboard components took a statKey prop and did the mapping internally, which meant adding a fifth card required editing the shared component — the one file every screen imports. Now the shared component is a pure function of four props and the page owns all the meaning. New card? One new line in the page, zero risk to anything else.
Why the tokens count is there
active_tokens counts rows in Sanctum's personal_access_tokens table. It's not a vanity metric — it answers "how many live sessions/API clients exist right now", which is the first thing you check when something looks off with authentication. Putting it on the dashboard means the answer is one glance away instead of a database query away. The cheapest monitoring is a number you already look at.
The lesson
Dashboard metrics have a shape: flat, server-computed, presentation-free. One endpoint per screen, parallel fetches with an unmount guard, and dumb card components that take label, value, icon, accent and nothing else. I've rebuilt dashboards three different ways over the years; this is the version that survived contact with real redesigns.
This stats endpoint, the StatCard component, and the parallel-fetching Dashboard page are all from my Laravel + React SaaS Starter Kit — the same Sanctum-authenticated, role-gated codebase as the articles in this series: https://kamranofficial.gumroad.com/l/mhekoig ($19, one-time). The pattern transfers to any stack, though. Your API should count things; your components should display things. Keep those jobs separate.
Top comments (0)