Créer un dashboard SaaS fiable avec Claude Code
Guide pratique pour KPIs, SQL, UI Next.js, accessibilité, permissions et revue d'un dashboard SaaS.
Un dashboard SaaS doit d’abord être fiable
Un dashboard SaaS n’est pas une simple page de graphiques. C’est l’espace où direction, finance, customer success, vente et produit décident quoi corriger en priorité. Si le MRR n’affiche pas sa devise, si le taux d’activation a un dénominateur flou ou si le SLA cache le fuseau horaire, l’interface pousse l’équipe vers de mauvaises décisions.
Claude Code peut accélérer la mise en page, mais il faut lui donner les conditions de confiance. Ici, KPI signifie indicateur clé de performance, API contract désigne le contrat de données entre backend et frontend, et stale data désigne une donnée trop ancienne pour décider. Nous allons couvrir la définition des KPIs, le contrat API, l’agrégation SQL, l’UI React/Next.js, l’accessibilité des graphiques, les filtres, les états de chargement et d’erreur, les limites de rôle et la boucle de revue.
flowchart LR
A["Définition KPI"] --> B["API contract"]
B --> C["Agrégation SQL"]
C --> D["API Next.js"]
D --> E["UI React"]
E --> F["Permissions et revue"]
F --> A
Définir les conditions de confiance
Avant de demander à Claude Code de générer des cartes et des courbes, définissez l’unité, la période, le fuseau horaire, le périmètre de permission et la fraîcheur des données.
| Sujet | Dashboard fragile | Dashboard fiable |
|---|---|---|
| Unité | Affiche 123,456 | Affiche JPY, USD, users ou % |
| Période | Dit “ce mois-ci” | Affiche 2026-05-01 à 2026-05-31 |
| Fuseau horaire | Utilise le défaut de la DB | Affiche Asia/Tokyo ou UTC dans l’API et l’UI |
| Permissions | Cache des cartes côté React | Vérifie tenant et rôle dans l’API et SQL |
| Fraîcheur | Les vieilles données semblent normales | Affiche generatedAt et l’état stale |
| Explication | Graphique seul | Donne formule, période comparée et exclusions |
Les cas d’usage pratiques sont au moins trois. Les dirigeants suivent MRR, ARR, churn, ARPA et mix de plans avec devise et règles de prorata. Le produit suit activation, conversion d’essai, adoption de fonctionnalités et cohorts avec un dénominateur explicite. Le customer success suit tickets ouverts, dépassements de SLA, comptes à risque et files par owner avec des frontières de rôle strictes. La finance ajoute factures payées, remboursements, impayés et revenu par plan.
Contrat API prêt à copier
Fixez la réponse de /api/dashboard/summary avant l’UI. Ce JSON peut servir de mock, fixture ou test frontend.
{
"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 }
]
}
Le bloc meta est indispensable. L’UI ne doit pas deviner la période, le fuseau, les droits ou la fraîcheur. Pour les routes et composants App Router, appuyez-vous sur la documentation officielle Next.js App Router. Pour les composants, le rendu conditionnel et l’état, consultez React Learn.
SQL avec frontières explicites
Cet exemple PostgreSQL suppose une table invoices avec tenant_id, status, amount_cents, paid_at et currency. La date de fin est exclusive pour éviter les bugs de fin de mois et de fuseau horaire.
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;
Les pièges concrets sont récurrents: utiliser 23:59:59 en fin de période, moyenner des pourcentages quotidiens, oublier le filtre tenant, ou compter des remboursements comme revenu payé. Demandez à Claude Code de traiter ces points comme des risques métier, pas comme des détails de style.
Types TypeScript du contrat
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[];
};
UI React avec chargement, erreur, vide et stale
Ce client component gère chargement, 403, erreur API, absence de données, stale data, cartes KPI et graphique avec tableau de secours. Pour l’accessibilité, utilisez MDN Web Accessibility et la wiki Recharts accessibility.
"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("fr-FR");
}
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: "Vous n'avez pas le droit de voir ce 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">Chargement du dashboard...</section>;
if (state.status === "empty") return <section className="p-6">Aucun KPI disponible pour ce filtre.</section>;
if (state.status === "error") {
return (
<section role="alert" className="p-6">
<h2 className="text-lg font-semibold">Le dashboard n'a pas chargé</h2>
<p>{state.message}</p>
<button className="mt-4 rounded bg-slate-900 px-4 py-2 text-white" onClick={() => location.reload()}>
Réessayer
</button>
</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} à {data.meta.dateRange.to}, fuseau {data.meta.timezone}.
Généré à {data.meta.generatedAt}.
</p>
{stale && (
<p role="status" className="rounded border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900">
Ces données sont périmées. Relancez l'agrégation avant de décider.
</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>
);
}
Ne transmettez pas le sens par la couleur seule. Les variations doivent aussi être visibles par les libellés, les valeurs, la période comparée et un tableau. Si l’UI filtre par plan, région, owner ou date, l’URL, l’API et SQL doivent utiliser les mêmes paramètres.
Prompt de revue pour Claude Code
Les Claude Code common workflows recommandent de boucler par petites étapes: investigation, édition, test, revue.
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.
Pour compléter, lisez la visualisation de données avec Claude Code et le guide RBAC. Pour transformer ce flux en standard d’équipe, ClaudeCodeLab propose des templates Claude Code et de la formation et consultation Claude Code pour dictionnaires KPI, prompts de revue et règles de dépôt.
Après avoir essayé ce flux avec des données SaaS d’exemple ClaudeCodeLab, le gain principal a été de ne pas commencer par l’écran. Dès que le contrat contenait fuseau horaire, heure de génération, état stale et permissions, la revue Claude Code devenait précise. Une UI se construit plus vite quand les conditions de confiance sont fixées avant le polissage visuel.
PDF gratuit: cheatsheet Claude Code
Saisissez votre email et téléchargez une page avec commandes, habitudes de review et workflow sûr.
Nous protégeons vos données et n'envoyons pas de spam.
À propos de l'auteur
Masa
Ingénieur spécialisé dans les workflows pratiques avec Claude Code.
Articles liés
Workflow Obsidian vers CLAUDE.md avec Claude Code
Transformer des notes Obsidian en notes CLAUDE.md concises pour reprendre les sessions sans réexpliquer.
Claude Code Revenue CTA Routing : relier articles, PDF, Gumroad et consultation
Un workflow Claude Code pour orienter les lecteurs vers PDF gratuit, Gumroad ou consultation selon l'intention.
Règles de handoff Claude Code en équipe: preuves, permissions, rollback et revenus
Un format concret pour transmettre un travail Claude Code avec preuves, permissions, rollback, PDF gratuit, Gumroad et consultation.