Use Cases (更新: 2026/6/1)

Claude CodeでSaaSダッシュボードを作る実践ガイド

KPI定義、SQL集計、Next.js UI、権限とレビューまで、信頼できるSaaSダッシュボードを作る実践手順。

Claude CodeでSaaSダッシュボードを作る実践ガイド

Claude Codeで作るSaaSダッシュボードは「信頼性」が最優先

SaaSの管理ダッシュボードは、きれいなグラフを並べる画面ではありません。経営、CS、営業、プロダクトチームが「今どこに問題があるか」を判断するための作業台です。MRR、解約率、アクティブユーザー、サポートSLAの数字が1つでも曖昧だと、施策の優先順位がずれます。

Claude Codeに「ダッシュボードを作って」とだけ頼むと、見た目は整っていても、単位、集計期間、タイムゾーン、権限、古いデータの表示が抜けがちです。この記事では、初心者でも実務に近い形で進められるように、KPI定義、API contract、SQL集計、React/Next.jsのUI、チャートのアクセシビリティ、フィルター、ローディングとエラー状態、ロール境界、レビューの回し方までを一気通貫で整理します。

ここでいうKPIは「重要業績評価指標」、つまり事業の健康状態を判断する数字です。API contractは「フロントエンドとバックエンドの約束」です。stale dataは「更新が止まって古くなったデータ」です。最初にこの3つを明文化すると、Claude Codeの出力をそのまま信じるのではなく、実務で検証できる成果物にできます。

flowchart LR
  A["KPI定義"] --> B["API contract"]
  B --> C["SQL集計"]
  C --> D["Next.js API"]
  D --> E["React UI"]
  E --> F["権限とレビュー"]
  F --> A

先に決めるKPIと信頼条件

最初に「何を表示するか」ではなく「何なら信頼できるか」を決めます。Claude Codeへの依頼も、UI部品からではなく、この表を渡すほうが精度が上がります。

観点薄いダッシュボード信頼できるダッシュボード
単位ただ 123,456 と出すJPY, USD, users, % まで表示する
期間「今月」とだけ書く2026-05-01 から 2026-05-31 までと明記する
タイムゾーンDBの初期設定に任せるAsia/TokyoUTC を画面とAPIに出す
権限画面だけ隠すAPIとSQLでもテナント、ロール、権限を絞る
更新鮮度古い数字でも同じ表示最終生成時刻と stale 判定を出す
説明グラフだけ算出式、前期間比較、除外条件を添える

実務でよく使うユースケースは、少なくとも次の3つです。

  1. 経営向けのMRR、ARR、解約率、ARPA確認。売上単位と請求通貨を間違えると月次報告が崩れます。
  2. プロダクト向けのアクティベーション、無料トライアルから有料化への転換率、主要機能利用率。分母の定義が曖昧だと施策の効果を誤解します。
  3. CS向けのサポート未対応件数、SLA超過、ハイリスク顧客リスト。権限境界を誤ると、担当外の顧客データを見せてしまいます。
  4. 財務向けの請求額、返金、未回収、プラン別売上。ダッシュボードと会計システムの数字が合わないと信頼が落ちます。

この段階でClaude Codeには、画面を作らせる前に「KPI辞書」を作らせます。たとえば「MRRは支払い済みの月額サブスクリプションだけを含める。税、返金、年額契約の月割り方針を明記する」といった粒度です。MasaがClaudeCodeLabの検証用SaaSデータで試したときも、最初にKPI辞書を作るだけで、後工程の手戻りがかなり減りました。

API contractをJSONで固定する

次はAPI contractです。フロントエンドが欲しい形を先に固定すると、SQL、API、UIを分担しても崩れません。以下は /api/dashboard/summary が返す想定のJSONです。コピーしてモックサーバーやテストデータに使えます。

{
  "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 }
  ]
}

ポイントは、meta に日付範囲、タイムゾーン、生成時刻、stale判定、権限を入れることです。これがないと、画面側で「今日の数字なのか」「誰に見せてよい数字なのか」を判断できません。Next.jsのApp RouterでAPIを作る場合は、公式の Next.js App Router docs を確認しながら、API route、server component、client componentの境界を決めます。Reactの状態管理や条件分岐は React Learn が基礎確認に向いています。

SQL集計は境界条件をコードに書く

SQLは「だいたい合っている」だけでは危険です。期間の開始は含める、終了は翌日の手前までにする、支払い済みだけを含める、テナントIDで必ず絞る、といった条件をクエリに直接書きます。次の例はPostgreSQL想定で、invoices テーブルに tenant_id, status, amount_cents, paid_at, currency がある前提です。

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;

落とし穴は、月末の to を含めようとして 23:59:59 を使うことです。ミリ秒やタイムゾーンで漏れます。上のように終了日を排他的、つまり「この日時より前」として扱うと安定します。もう1つの落とし穴は、平均値の平均を出すことです。日別の転換率を足して割るのではなく、期間全体の分子と分母を再集計してください。

TypeScriptで画面の契約を守る

JSON contractを決めたら、TypeScriptの型に落とします。型は見た目のためではなく、Claude Codeが余計なフィールドを作ったり、単位を落としたりしたときに検出するための安全装置です。

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/Next.jsのUIは状態を全部見せる

ダッシュボードUIでは、成功状態だけ作るのが一番危険です。ローディング、エラー、空データ、権限不足、stale dataを見える形にします。以下はNext.jsのclient componentとして貼れる例です。Rechartsを使いますが、チャートだけに依存せず、スクリーンリーダー用の表も置いています。アクセシビリティの考え方は MDN Web AccessibilityRecharts and 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("ja-JP");
}

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: "このダッシュボードを見る権限がありません。" });
          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 : "不明なエラーが発生しました。"
          });
        }
      }
    }

    loadDashboard();
    return () => controller.abort();
  }, []);

  if (state.status === "loading") {
    return <section aria-busy="true" className="p-6">ダッシュボードを読み込んでいます...</section>;
  }

  if (state.status === "error") {
    return (
      <section role="alert" className="p-6">
        <h2 className="text-lg font-semibold">読み込みに失敗しました</h2>
        <p>{state.message}</p>
        <button className="mt-4 rounded bg-slate-900 px-4 py-2 text-white" onClick={() => location.reload()}>
          再読み込み
        </button>
      </section>
    );
  }

  if (state.status === "empty") {
    return <section className="p-6">指定した期間に表示できるKPIがありません。</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} まで、タイムゾーン {data.meta.timezone}。
          最終生成 {data.meta.generatedAt}。
        </p>
        {stale && (
          <p role="status" className="rounded border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900">
            このデータは更新期限を過ぎています。意思決定の前に再集計してください。
          </p>
        )}
      </header>

      <section className="grid gap-4 md:grid-cols-2 lg:grid-cols-4" aria-label="主要KPI">
        {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">
              前期間比 {metric.deltaPct >= 0 ? "+" : ""}{metric.deltaPct.toFixed(2)}%
            </p>
            <p className="mt-2 text-xs text-slate-500">算出式: {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の時系列グラフ">
            <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>
  );
}

チャートは色だけで意味を伝えないことも重要です。上昇は緑、下降は赤、だけでは色覚特性のあるユーザーに伝わりません。ラベル、数値、前期間比、表を併用します。フィルターも同じで、プラン、地域、担当者、期間を画面で変えられるなら、URLクエリ、API、SQLのすべてで同じ条件を使う必要があります。

Claude Codeへのレビュー依頼プロンプト

最後に、Claude Codeを実装者としてだけでなくレビュアーとして使います。公式の Claude Code common workflows にあるように、調査、編集、テスト、レビューを小さく回すほうが安定します。次のプロンプトは、そのまま貼って使えるレビュー依頼です。

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.

レビューで特に見落としやすいのは、ロール境界です。管理者、CS担当、財務担当、閲覧専用ユーザーで見えるKPIが違うなら、React側の表示分岐だけでは不十分です。API routeで権限を確認し、SQLでもテナントIDや担当範囲を条件に入れます。RBAC、つまりロールベースアクセス制御の実装は、関連記事の Claude CodeでRBACを実装するガイド も参考になります。グラフ設計は Claude Codeでデータ可視化を実装する と合わせて読むと、UIの判断軸をそろえやすくなります。

実装を失敗させないレビューサイクル

現場では、次の順番でClaude Codeに小さく依頼すると安定します。

  1. KPI辞書を作る。専門用語は「重要業績評価指標」「前期間比較」「排他的終了日」のように日本語で説明させる。
  2. API contractをJSONで固定する。画面に必要なメタ情報を必ず含める。
  3. SQLを1本ずつ作る。サンプルデータで境界日、返金、未払い、タイムゾーンを確認する。
  4. TypeScript型を作る。any で逃げた箇所をレビューで検出する。
  5. UIを作る。成功、ローディング、エラー、空データ、stale data、権限不足をすべて表示する。
  6. Claude Codeにレビューさせ、人間がKPIの意味と権限を確認する。

ClaudeCodeLabでは、この流れをチームで再利用できるように、CLAUDE.md、レビュー用プロンプト、ダッシュボード設計テンプレートをまとめた Claude Codeテンプレート集 と、実務リポジトリに合わせた Claude Code研修・個別相談 を用意しています。SaaSのKPI定義、SQLレビュー、ダッシュボードUIの設計をチーム標準にしたい場合は、テンプレートから始めるのが早いです。

この記事で紹介した内容を実際に試した結果、最も効果が大きかったのは「画面を先に作らない」ことでした。Masaが検証用のSaaSデータでMRRとアクティベーション率のダッシュボードを作ったところ、API contractにタイムゾーン、生成時刻、stale判定、権限を入れただけで、Claude Codeの実装レビューが具体的になりました。見た目の修正よりも、数字を信頼できる条件を先に固定することが、SaaSダッシュボード開発の近道です。

#Claude Code #SaaSダッシュボード #React #Next.js #データ可視化 #Recharts
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。