Construir un dashboard SaaS confiable con Claude Code
Guía práctica para definir KPIs, SQL, UI Next.js, accesibilidad, permisos y revisión de dashboards SaaS.
Un dashboard SaaS debe ser confiable antes de verse bien
Un dashboard SaaS no es una página con gráficos bonitos. Es la superficie donde dirección, finanzas, customer success, ventas y producto deciden qué corregir primero. Si el MRR no muestra moneda, la tasa de activación usa un denominador ambiguo o el SLA ignora la zona horaria, el equipo toma decisiones con datos frágiles.
Claude Code puede acelerar el layout, pero necesita una especificación clara. En este artículo, KPI significa indicador clave de rendimiento, API contract es el acuerdo de datos entre backend y frontend, y stale data son datos demasiado antiguos para decidir. Veremos definición de KPIs, contrato API, agregación SQL, UI con React/Next.js, accesibilidad de gráficos, filtros, estados de carga y error, límites por rol y un ciclo de revisión.
flowchart LR
A["Definición KPI"] --> B["API contract"]
B --> C["Agregación SQL"]
C --> D["Next.js API"]
D --> E["React UI"]
E --> F["Permisos y revisión"]
F --> A
Define primero las condiciones de confianza
Antes de pedir tarjetas y gráficos, define qué hace que el número sea confiable: unidad, rango de fechas, zona horaria, alcance de permisos y frescura.
| Área | Dashboard débil | Dashboard confiable |
|---|---|---|
| Unidad | Muestra 123,456 | Muestra JPY, USD, users o % |
| Fechas | Dice “este mes” | Muestra 2026-05-01 a 2026-05-31 |
| Zona horaria | Usa el valor por defecto de la DB | Muestra Asia/Tokyo o UTC en API y UI |
| Permisos | Oculta tarjetas en React | Valida tenant y rol en API y SQL |
| Frescura | Los datos viejos parecen normales | Muestra generatedAt y estado stale |
| Explicación | Solo hay gráfico | Incluye fórmula, periodo comparado y exclusiones |
Los primeros casos de uso suelen ser estos: KPIs ejecutivos como MRR, ARR, churn, ARPA y mezcla de planes; KPIs de producto como activación, conversión de prueba a pago y adopción de funciones; KPIs de customer success como tickets abiertos, SLA incumplido y cuentas en riesgo; y KPIs financieros como facturas pagadas, reembolsos, deuda vencida e ingresos por plan.
API contract listo para copiar
Fija la respuesta de /api/dashboard/summary antes de construir la UI. Este JSON sirve como mock, fixture o prueba de 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 }
]
}
La clave está en meta. La UI no debe adivinar periodo, zona horaria, permisos ni frescura. Para rutas y componentes en App Router, usa la documentación oficial de Next.js App Router. Para componentes, renderizado condicional y estado, vuelve a React Learn.
SQL con límites explícitos
Este ejemplo PostgreSQL asume una tabla invoices con tenant_id, status, amount_cents, paid_at y currency. El final del periodo es exclusivo para evitar errores de milisegundos y zona horaria.
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;
Los fallos típicos son usar 23:59:59 como cierre, promediar porcentajes diarios en lugar de recalcular numerador y denominador, olvidar el filtro de tenant y mezclar reembolsos con ingresos pagados. Pide a Claude Code que revise estos puntos como errores de negocio, no como estilo.
Tipos TypeScript para proteger el contrato
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 con carga, error, vacío y stale
Este client component cubre carga, 403, errores de API, datos vacíos, stale data, tarjetas KPI y un gráfico con tabla fallback. Revisa accesibilidad con MDN Web Accessibility y la wiki de accesibilidad de Recharts.
"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("es-ES");
}
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: "No tienes permiso para ver este 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">Cargando dashboard...</section>;
if (state.status === "empty") return <section className="p-6">No hay KPIs para este filtro.</section>;
if (state.status === "error") {
return (
<section role="alert" className="p-6">
<h2 className="text-lg font-semibold">No se pudo cargar el dashboard</h2>
<p>{state.message}</p>
<button className="mt-4 rounded bg-slate-900 px-4 py-2 text-white" onClick={() => location.reload()}>
Reintentar
</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} a {data.meta.dateRange.to}, zona horaria {data.meta.timezone}.
Generado en {data.meta.generatedAt}.
</p>
{stale && (
<p role="status" className="rounded border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900">
Estos datos están vencidos. Recalcula antes de decidir.
</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>
);
}
No transmitas significado solo con color. Las subidas y bajadas necesitan etiquetas, números, periodo comparado y una tabla accesible. Si la UI filtra por plan, región, owner o fecha, el URL, la API y SQL deben usar los mismos parámetros.
Prompt de revisión para Claude Code
El flujo recomendado en Claude Code common workflows funciona mejor si investigas, editas, pruebas y revisas en ciclos pequeños.
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.
Para ampliar el tema, lee visualización de datos con Claude Code y la guía de RBAC. Si quieres convertir este proceso en una práctica de equipo, ClaudeCodeLab ofrece plantillas de Claude Code y formación y consultoría para diccionarios KPI, prompts de revisión y reglas específicas del repositorio.
Al probar este flujo con datos SaaS de ejemplo de ClaudeCodeLab, el mayor beneficio fue no empezar por la pantalla. Cuando el contrato incluyó zona horaria, hora de generación, estado stale y permisos, la revisión de Claude Code se volvió concreta. La UI mejora más rápido cuando las condiciones de confianza están fijadas desde el principio.
PDF gratis: cheatsheet de Claude Code
Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.
Cuidamos tus datos y no enviamos spam.
Sobre el autor
Masa
Ingeniero enfocado en workflows prácticos con Claude Code.
Artículos relacionados
Workflow de Obsidian a CLAUDE.md con Claude Code
Convierte notas de trabajo de Obsidian en notas operativas de CLAUDE.md para no repetir contexto.
Claude Code Revenue CTA Routing: de artículos a PDF, Gumroad y consulta
Un flujo con Claude Code para dirigir lectores a PDF gratis, Gumroad o consulta según intención.
Reglas de handoff para equipos con Claude Code: evidencia, permisos, rollback e ingresos
Formato práctico para entregar trabajo de Claude Code con pruebas, permisos, rollback, PDF gratis, Gumroad y consulta.