Build a Trustworthy SaaS Dashboard with Claude Code
A practical Claude Code workflow for SaaS KPIs, SQL, Next.js UI, accessibility, permissions, and review.
A SaaS Dashboard Must Be Trusted Before It Looks Good
A SaaS dashboard is not just a page with attractive charts. It is the workspace where founders, finance, customer success, sales, and product teams decide what to fix next. If MRR has the wrong currency, activation rate uses the wrong denominator, or support SLA numbers ignore timezone, the dashboard becomes a source of bad decisions.
Claude Code can produce the layout quickly, but the prompt must define what makes the numbers trustworthy. In this guide, KPI means a key performance indicator, API contract means the promise between backend and frontend, and stale data means data that may be too old for decision making. We will cover KPI definition, API contract, SQL aggregation, React and Next.js UI, accessible charts, filters, loading and error states, role boundaries, and a repeatable review loop.
flowchart LR
A["KPI definition"] --> B["API contract"]
B --> C["SQL aggregation"]
C --> D["Next.js API"]
D --> E["React UI"]
E --> F["Permissions and review"]
F --> A
Define the KPI Contract First
Before asking Claude Code to create cards and charts, define what a reliable dashboard must reveal. Units, date range, timezone, permission scope, and freshness are part of the feature, not metadata to add later.
| Area | Thin dashboard | Trustworthy dashboard |
|---|---|---|
| Units | Shows 123,456 | Shows JPY, USD, users, or % |
| Date range | Says “this month” | Shows 2026-05-01 to 2026-05-31 |
| Timezone | Uses database default | Shows Asia/Tokyo or UTC in API and UI |
| Permissions | Hides cards in React | Enforces tenant and role checks in API and SQL |
| Freshness | Old data looks normal | Shows generatedAt and stale status |
| Explanation | Chart only | Includes formula, comparison period, and exclusions |
Three practical use cases should shape the first version:
- Executive KPIs: MRR, ARR, churn rate, ARPA, and plan mix. Currency and monthly allocation rules must be explicit.
- Product KPIs: activation rate, trial conversion, feature adoption, and cohort movement. The denominator must be written down.
- Customer success KPIs: open tickets, SLA breaches, account risk, and owner queues. Role boundaries must prevent one team from seeing another team’s restricted accounts.
- Finance KPIs: paid invoices, refunds, overdue invoices, and revenue by plan. The dashboard should explain why it may differ from accounting exports.
This is the first Claude Code task: generate a KPI dictionary before generating UI. Ask it to define the formula, unit, source table, timezone, role visibility, and edge cases for every metric.
Copy-Paste API Contract
Use a contract like this for /api/dashboard/summary. It can feed a mock API, a fixture, or a frontend test.
{
"meta": {
"tenantId": "tenant_123",
"dateRange": {
"from": "2026-05-01",
"to": "2026-05-31"
},
"timezone": "Asia/Tokyo",
"generatedAt": "2026-06-01T09:00:00+09:00",
"staleAfterMinutes": 60,
"permissions": ["dashboard:read", "finance:read"]
},
"metrics": [
{
"id": "mrr",
"label": "MRR",
"unit": "JPY",
"current": 4820000,
"previous": 4510000,
"deltaPct": 6.87,
"formula": "paid monthly subscription revenue excluding tax and refunds"
},
{
"id": "activation_rate",
"label": "Activation rate",
"unit": "percent",
"current": 42.3,
"previous": 39.8,
"deltaPct": 2.5,
"formula": "activated accounts divided by new trial accounts"
}
],
"series": [
{ "date": "2026-05-01", "mrr": 4380000, "activationRate": 37.5 },
{ "date": "2026-05-08", "mrr": 4510000, "activationRate": 39.1 },
{ "date": "2026-05-15", "mrr": 4620000, "activationRate": 40.4 },
{ "date": "2026-05-22", "mrr": 4740000, "activationRate": 41.8 },
{ "date": "2026-05-31", "mrr": 4820000, "activationRate": 42.3 }
]
}
The important part is meta. The UI should not guess date range, timezone, freshness, or permission scope. When building the route with the Next.js App Router, check the official Next.js App Router docs. For component basics and conditional rendering, the official React Learn guide is still the best starting point.
SQL Aggregation With Explicit Boundaries
This PostgreSQL query assumes an invoices table with tenant_id, status, amount_cents, paid_at, and currency. It uses an exclusive end date so the last day is not lost through timezone or millisecond bugs.
WITH params AS (
SELECT
'tenant_123'::text AS tenant_id,
'2026-05-01'::date AS from_date,
'2026-06-01'::date AS exclusive_to_date,
'Asia/Tokyo'::text AS report_timezone
),
paid_invoices AS (
SELECT
date_trunc('day', paid_at AT TIME ZONE params.report_timezone)::date AS paid_day,
amount_cents,
currency
FROM invoices
CROSS JOIN params
WHERE invoices.tenant_id = params.tenant_id
AND invoices.status = 'paid'
AND invoices.paid_at >= params.from_date AT TIME ZONE params.report_timezone
AND invoices.paid_at < params.exclusive_to_date AT TIME ZONE params.report_timezone
),
daily AS (
SELECT
paid_day,
currency,
SUM(amount_cents) / 100.0 AS revenue,
COUNT(*) AS paid_invoice_count
FROM paid_invoices
GROUP BY paid_day, currency
)
SELECT
paid_day,
currency,
revenue,
paid_invoice_count,
SUM(revenue) OVER (PARTITION BY currency ORDER BY paid_day) AS cumulative_revenue
FROM daily
ORDER BY paid_day, currency;
Common pitfalls are concrete: using 23:59:59 for the end of the month, averaging percentages instead of recalculating numerator and denominator, dropping tenant filters in a chart query, and showing refunded revenue as paid revenue. Make Claude Code review these as business correctness issues, not style issues.
TypeScript Types for the Contract
Types keep Claude Code from inventing fields or losing units while it edits the UI.
export type MetricUnit = "JPY" | "USD" | "users" | "percent" | "count";
export type DashboardMetric = {
id: "mrr" | "activation_rate" | "trial_conversion" | "support_sla";
label: string;
unit: MetricUnit;
current: number;
previous: number;
deltaPct: number;
formula: string;
};
export type DashboardPoint = {
date: string;
mrr: number;
activationRate: number;
};
export type DashboardPayload = {
meta: {
tenantId: string;
dateRange: {
from: string;
to: string;
};
timezone: string;
generatedAt: string;
staleAfterMinutes: number;
permissions: string[];
};
metrics: DashboardMetric[];
series: DashboardPoint[];
};
React Dashboard With Loading, Error, Empty, and Stale States
This client component is intentionally complete enough to paste into a Next.js app. It handles loading, API errors, empty data, permission errors, stale data, KPI cards, and a chart with a table fallback. For accessibility, use MDN Web Accessibility and the Recharts accessibility wiki as references.
"use client";
import { useEffect, useMemo, useState } from "react";
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis
} from "recharts";
import type { DashboardPayload, DashboardMetric } from "./dashboard-types";
type LoadState =
| { status: "loading" }
| { status: "error"; message: string }
| { status: "empty" }
| { status: "ready"; data: DashboardPayload };
const money = new Intl.NumberFormat("ja-JP", {
style: "currency",
currency: "JPY",
maximumFractionDigits: 0
});
function formatMetric(metric: DashboardMetric) {
if (metric.unit === "JPY") return money.format(metric.current);
if (metric.unit === "percent") return `${metric.current.toFixed(1)}%`;
return metric.current.toLocaleString("en-US");
}
function isStale(data: DashboardPayload) {
const generated = new Date(data.meta.generatedAt).getTime();
const limit = data.meta.staleAfterMinutes * 60 * 1000;
return Date.now() - generated > limit;
}
export default function DashboardPage() {
const [state, setState] = useState<LoadState>({ status: "loading" });
useEffect(() => {
const controller = new AbortController();
async function loadDashboard() {
try {
setState({ status: "loading" });
const response = await fetch(
"/api/dashboard/summary?from=2026-05-01&to=2026-05-31&timezone=Asia/Tokyo",
{ signal: controller.signal }
);
if (response.status === 403) {
setState({ status: "error", message: "You do not have permission to view this dashboard." });
return;
}
if (!response.ok) {
throw new Error(`Dashboard API failed: ${response.status}`);
}
const data = (await response.json()) as DashboardPayload;
setState(data.metrics.length === 0 ? { status: "empty" } : { status: "ready", data });
} catch (error) {
if (!controller.signal.aborted) {
setState({
status: "error",
message: error instanceof Error ? error.message : "Unknown dashboard error."
});
}
}
}
loadDashboard();
return () => controller.abort();
}, []);
if (state.status === "loading") {
return <section aria-busy="true" className="p-6">Loading dashboard...</section>;
}
if (state.status === "error") {
return (
<section role="alert" className="p-6">
<h2 className="text-lg font-semibold">Dashboard failed to load</h2>
<p>{state.message}</p>
<button className="mt-4 rounded bg-slate-900 px-4 py-2 text-white" onClick={() => location.reload()}>
Retry
</button>
</section>
);
}
if (state.status === "empty") {
return <section className="p-6">No KPI data is available for this filter.</section>;
}
return <DashboardContent data={state.data} />;
}
function DashboardContent({ data }: { data: DashboardPayload }) {
const stale = useMemo(() => isStale(data), [data]);
return (
<main className="space-y-6 p-6">
<header className="space-y-2">
<h1 className="text-2xl font-bold">SaaS KPI Dashboard</h1>
<p className="text-sm text-slate-600">
{data.meta.dateRange.from} to {data.meta.dateRange.to}, timezone {data.meta.timezone}.
Generated at {data.meta.generatedAt}.
</p>
{stale && (
<p role="status" className="rounded border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900">
This data is stale. Refresh the aggregation before making decisions.
</p>
)}
</header>
<section className="grid gap-4 md:grid-cols-2 lg:grid-cols-4" aria-label="Key metrics">
{data.metrics.map((metric) => (
<article key={metric.id} className="rounded border bg-white p-4">
<p className="text-sm text-slate-500">{metric.label}</p>
<p className="mt-2 text-2xl font-bold">{formatMetric(metric)}</p>
<p className="text-sm text-slate-600">
Previous period {metric.deltaPct >= 0 ? "+" : ""}{metric.deltaPct.toFixed(2)}%
</p>
<p className="mt-2 text-xs text-slate-500">Formula: {metric.formula}</p>
</article>
))}
</section>
<section className="rounded border bg-white p-4">
<h2 className="text-lg font-semibold">MRR trend</h2>
<ResponsiveContainer width="100%" height={320}>
<LineChart data={data.series} accessibilityLayer aria-label="MRR trend chart">
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis tickFormatter={(value) => money.format(Number(value))} />
<Tooltip formatter={(value) => money.format(Number(value))} />
<Line type="monotone" dataKey="mrr" stroke="#2563eb" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
<table className="sr-only">
<caption>MRR trend table</caption>
<thead>
<tr><th>Date</th><th>MRR</th><th>Activation rate</th></tr>
</thead>
<tbody>
{data.series.map((point) => (
<tr key={point.date}>
<td>{point.date}</td>
<td>{point.mrr}</td>
<td>{point.activationRate}</td>
</tr>
))}
</tbody>
</table>
</section>
</main>
);
}
Do not rely on color alone. A green line and a red line are not enough; show labels, values, comparison periods, and a data table fallback. Filters must also be consistent: if the UI lets users filter by plan, region, owner, or date range, the URL, API, and SQL must all use the same inputs.
Review Prompt for Claude Code
Use Claude Code as a reviewer after it implements the feature. The official Claude Code common workflows are built around small investigate, edit, test, and review loops. This prompt is ready to paste:
You are reviewing a SaaS KPI dashboard implementation.
Check these files:
- app/api/dashboard/summary/route.ts
- app/dashboard/page.tsx
- lib/dashboard-types.ts
- sql/dashboard-summary.sql
Review priorities:
1. KPI correctness: units, formulas, date range, timezone, and previous-period comparison.
2. Trust signals: generatedAt, staleAfterMinutes, empty state, loading state, error state.
3. Security: tenant isolation, role boundaries, finance-only metrics, API-side authorization.
4. Accessibility: chart labels, non-color-only meaning, keyboard navigation, table fallback.
5. Maintainability: duplicated formatter logic, unsafe casts, missing tests.
Return findings as P0, P1, or P2. Include file paths, exact code references, and a suggested fix.
Do not rewrite the whole dashboard unless a finding requires it.
For related ClaudeCodeLab guides, read data visualization with Claude Code and the role-based access control guide. If you want this workflow packaged for your team, ClaudeCodeLab offers Claude Code templates and Claude Code training and consultation for KPI dictionaries, review prompts, and repository-specific dashboard rules.
After trying this flow with ClaudeCodeLab sample SaaS data, the biggest improvement came from not starting with the screen. Once the contract included timezone, generated time, stale status, and permissions, Claude Code’s review became specific and useful. A dashboard becomes faster to build when the trust conditions are fixed before the visual polish.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Obsidian to CLAUDE.md Workflow: Stop Re-explaining Context
Turn Obsidian working notes into concise CLAUDE.md operating notes that make Claude Code sessions easier to resume.
Claude Code Revenue CTA Routing: Send Articles to PDF, Gumroad, and Consultation
A Claude Code workflow for routing article readers to the free PDF, Gumroad products, or consultation by intent.
Claude Code Team Handoff Rules: Review Evidence, Permissions, Rollback, and Revenue Paths
A practical Claude Code handoff format for team review, proof, permission rules, rollback, free PDF, Gumroad, and consultation paths.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.