Use Cases (Updated: 6/1/2026)

Build a Trustworthy SaaS Dashboard with Claude Code

A practical Claude Code workflow for SaaS KPIs, SQL, Next.js UI, accessibility, permissions, and review.

Build a Trustworthy SaaS Dashboard with Claude Code

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.

AreaThin dashboardTrustworthy dashboard
UnitsShows 123,456Shows JPY, USD, users, or %
Date rangeSays “this month”Shows 2026-05-01 to 2026-05-31
TimezoneUses database defaultShows Asia/Tokyo or UTC in API and UI
PermissionsHides cards in ReactEnforces tenant and role checks in API and SQL
FreshnessOld data looks normalShows generatedAt and stale status
ExplanationChart onlyIncludes formula, comparison period, and exclusions

Three practical use cases should shape the first version:

  1. Executive KPIs: MRR, ARR, churn rate, ARPA, and plan mix. Currency and monthly allocation rules must be explicit.
  2. Product KPIs: activation rate, trial conversion, feature adoption, and cohort movement. The denominator must be written down.
  3. 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.
  4. 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.

#Claude Code #SaaS dashboard #React #Next.js #data visualization #Recharts
Free

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.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.