0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

dbtのマートを「そのまま」見せる:Next.jsのサーバーコンポーネントでECダッシュボードを作る

0
Last updated at Posted at 2026-10-31

はじめに

以前の記事で、dbt・BigQuery・Terraform で「数字が合うEC分析基盤」を作りました。今回はその続きで、できあがったテーブル(マート)を見るためのダッシュボードを作りました。

以前から、BigQuery のデータを Looker Studio でダッシュボードにするときの構築の手間が大きいと感じていました。そこで今回は、ダッシュボードをコードで作ってみました。

ダッシュボードを作るときに気をつけたのは、次の2つです。

  • せっかく揃えた数字を、画面側で崩さないこと
  • 認証情報やSQLをブラウザに出さず、BigQuery の費用も増やさないこと

Next.js(App Router)・React・TypeScript で作り、データは BigQuery か、リポジトリに同梱した架空のサンプルから読みます。サンプルを使えば、クラウドのアカウントなしで手元で動きます。

現場の悩みと設計判断

現場でよくある悩み このリポジトリでの判断
BIツールでダッシュボードを1つ作るたびに、構築の手間が大きい ダッシュボードをコードで書き、小さな部品に分ける。変更の依頼が小さな差分になり、AIが下書きして人がレビューできる
部署ごとに売上の数字が合わない 指標の定義は dbt の1か所。画面はマートの行を足し上げるだけ
それらしく見えて実は間違っている比率(日ごとの平均単価の平均など) 比率は合計から出し、率の増減は pt で見せる
認証情報やSQLがブラウザに出てしまう データの取得はサーバーコンポーネントだけ、server-only のモジュールで行う
画面を開くたびに BigQuery の費用が増える 結果を10分キャッシュし、RFMは25行に集計してから読む
データが変わっていないのに数字が毎日変わる 期間はデータの最終日から数える

作り方:AIとの分担

AIコーディングアシスタント(Claude)と一緒に、作り始めてからCIが通るまで1時間以内で作りました。何を作るか、誰のどんな悩みに応えるか、守る条件(架空データのみ・秘密情報を扱わない)を決め、動いた画面を確かめたのは私です。設計の提案、コード、テスト、CI、README はAIが担当しました。

できること

画面 内容
売上画面(URL:/) 純売上・注文数・平均単価(AOV)・粗利率・キャンセル/返品率を前の期間と比較。チャネル別の売上推移と、チャネルごとの表
顧客画面(URL:/customers) RFMの5セグメントごとの人数・売上比率・1人あたり売上と、セグメントに合う施策。R×F のマトリクス

期間(30 / 90 / 365日)とチャネルで絞り込めます。絞り込みはただのリンクなので、どの表示にもURLがあり、そのまま共有できます。

全体像

読むマート 粒度 使い道
mart_daily_channel_sales 日 × チャネル KPI、推移グラフ、チャネル表
mart_customer_rfm R スコア × F スコアで集計 セグメントのカード、R×F マトリクス

1. 指標はダッシュボードで作り直さない

「売上は完了した注文だけを数える」という定義は、dbt の fct_orders で1回だけ決めています。ダッシュボードは、マートの行を足し上げて、比率を出すだけにしました。

src/lib/metrics.ts(抜粋)
const ratio = (a: number, b: number): number | null => (b === 0 ? null : a / b);

export function summarize(rows: readonly DailyChannelSales[]): Summary {
  const t = rows.reduce(
    (acc, r) => {
      acc.orders += r.orders;
      acc.completedOrders += r.completedOrders;
      acc.cancelledOrders += r.cancelledOrders;
      acc.returnedOrders += r.returnedOrders;
      acc.netRevenue += r.netRevenue;
      acc.grossMargin += r.grossMargin;
      return acc;
    },
    { orders: 0, completedOrders: 0, cancelledOrders: 0, returnedOrders: 0, netRevenue: 0, grossMargin: 0 },
  );
  return {
    ...t,
    averageOrderValue: ratio(t.netRevenue, t.completedOrders),
    marginRate: ratio(t.grossMargin, t.netRevenue),
    lossRate: ratio(t.cancelledOrders + t.returnedOrders, t.orders),
  };
}

ポイントは、比率は合計から出し、日ごとの比率を平均しないことです。日ごとの平均単価を平均すると、注文の少ない日も多い日も同じ重さになり、数字がずれます。マートには average_order_value の列もありますが、期間で集計し直すときは使わず、売上と件数から計算し直しています。

0で割る場合は null を返し、画面では「–」と表示します。

2. データの取り口をインターフェースで1つにする

画面は、次の小さなインターフェースだけに依存しています。

src/lib/types.ts(抜粋)
export interface DataSource {
  readonly name: "sample" | "bigquery";
  dailyChannelSales(): Promise<DailyChannelSales[]>;
  rfmCells(): Promise<RfmCell[]>;
}

実装は2つで、環境変数 DATA_SOURCE で切り替えます。BigQuery のクライアントは、使うときだけ読み込みます。

src/lib/data/index.ts(抜粋)
import "server-only";

async function create(): Promise<DataSource> {
  const kind = process.env.DATA_SOURCE ?? "sample";
  if (kind === "sample") return createSampleSource();
  if (kind === "bigquery") {
    const projectId = process.env.BQ_PROJECT_ID;
    if (!projectId) throw new Error("DATA_SOURCE=bigquery needs BQ_PROJECT_ID");
    const [{ BigQuery }, { createBigQuerySource }] = await Promise.all([
      import("@google-cloud/bigquery"),
      import("./bigquery"),
    ]);
    const location = process.env.BQ_LOCATION;
    const source = createBigQuerySource(
      { projectId, dataset: process.env.BQ_DATASET ?? "marts", location },
      new BigQuery({ projectId, location }),
    );
    return withCache(source, CACHE_TTL_MS);
  }
  throw new Error(`Unknown DATA_SOURCE: ${kind} (use "sample" or "bigquery")`);
}

サンプルは、ec-analytics-platform を DuckDB でビルドした結果を、スクリプトでJSONに書き出したものです。テストでは「サンプルの合計が、マートの合計と一致すること」を確かめています。

3. データの取得はサーバーだけで行う

ページは React のサーバーコンポーネントで、サーバー上でデータを読みます。ブラウザに届くのは描画済みのHTMLだけなので、認証情報もSQLもブラウザに出ません。データを読むモジュールには import "server-only" を付け、間違ってクライアント側から読み込むとビルドエラーになるようにしています。

src/app/page.tsx(抜粋)
export const dynamic = "force-dynamic";

export default async function SalesPage({ searchParams }: { searchParams: SearchParams }) {
  const params = await searchParams;
  const period = parsePeriod(params.period);
  const source = await getDataSource();
  const rows = await source.dailyChannelSales();
  // ...
}

期間やチャネルの絞り込みも、クライアントの状態ではなくURLのクエリで持っています。useState を使わないので、JavaScriptが動かない環境でも絞り込めます。想定外の値(?period=7d など)は既定値に戻します。

4. SQLは値をパラメータで渡し、テーブル名はチェックする

BigQuery では、値はクエリパラメータで渡せますが、データセット名やテーブル名はパラメータにできません。そこで、名前は文字の種類をチェックしてから SQL に入れています。

src/lib/data/bigquery.ts(抜粋)
const IDENTIFIER = /^[A-Za-z0-9_-]{1,1024}$/;

export function tableRef(projectId: string, dataset: string, table: string): string {
  for (const [name, value] of [["project", projectId], ["dataset", dataset], ["table", table]] as const) {
    if (!IDENTIFIER.test(value)) throw new Error(`Invalid BigQuery ${name} name: ${value}`);
  }
  return `\`${projectId}.${dataset}.${table}\``;
}

export function dailySql(config: BigQueryConfig): string {
  const table = tableRef(config.projectId, config.dataset, "mart_daily_channel_sales");
  return `
    select
      format_date('%Y-%m-%d', ordered_on) as ordered_on,
      channel, orders, completed_orders, cancelled_orders, returned_orders,
      net_revenue, gross_margin
    from ${table}
    where ordered_on > date_sub((select max(ordered_on) from ${table}), interval @lookback_days day)
    order by ordered_on, channel`;
}

テストでは、marts`; drop table x; -- のようなデータセット名が拒否されることを確かめています。BigQuery のクライアントは query だけを持つ型にしているので、テストでは偽物のクライアントを渡せます。

5. BigQuery を毎回読まない

マートはバッチで作り直すものなので、ページを開くたびに BigQuery を読んでも、費用が増えるだけです。結果をメモリに10分間持つようにしました。失敗した結果は持たず、次のアクセスでやり直します。

src/lib/data/cache.ts(抜粋)
export function withCache(source: DataSource, ttlMs: number, now: () => number = Date.now): DataSource {
  const memo = <T>(load: () => Promise<T>) => {
    let value: { at: number; data: Promise<T> } | null = null;
    return () => {
      if (value === null || now() - value.at >= ttlMs) {
        const data = load();
        const entry = { at: now(), data };
        value = entry;
        data.catch(() => {
          if (value === entry) value = null; // 失敗は持たない
        });
      }
      return value.data;
    };
  };
  // ...
}

読む量も減らしています。顧客のRFMは1人1行で読むと顧客数ぶんの行になりますが、画面に必要なのはセグメントの集計だけなので、BigQuery 側で R スコア × F スコアの25行に集計してから読みます。

6. 期間は「データの最終日」から数える

「直近90日」を今日から数えると、データの更新が止まっている間に数字が毎日変わってしまいます。ec-analytics-platform の RFM と同じく、データの最終日を基準にしました。比較するのは、その直前の同じ日数の期間です。

src/lib/period.ts(抜粋)
export function resolvePeriod(latestDate: string, period: PeriodKey): PeriodRanges {
  const days = PERIODS[period].days;
  const end = latestDate;
  const start = addDays(end, -(days - 1));
  const prevEnd = addDays(start, -1);
  const prevStart = addDays(prevEnd, -(days - 1));
  return { current: { start, end }, previous: { start: prevStart, end: prevEnd }, days };
}

日付の計算はすべて UTC の日付文字列で行い、サーバーのタイムゾーンで日付がずれないようにしています。

前期間との比較は、金額や件数は「+12.3%」のような増減率で、粗利率やキャンセル率のような率は「+1.2 pt」のような差で表示します。率をさらに増減率で表すと(47.4% → 47.5% が「+0.2%」になる)、読み違えやすいためです。キャンセル率は下がるほうが良いので、色の向きも逆にしています。

7. グラフは自作のSVG

グラフは、折れ線とマトリクスの2種類だけなので、ライブラリを使わずSVGとHTMLで書きました。バンドルが小さくなり、ライト/ダークの配色もCSSの変数1か所で揃えられます。軸の最大値は、1・2・5 × 10のn乗に切り上げています。

src/components/LineChart.tsx(抜粋)
export function niceMax(value: number): number {
  if (value <= 0) return 1;
  const exp = 10 ** Math.floor(Math.log10(value));
  const f = value / exp;
  const nice = f <= 1 ? 1 : f <= 2 ? 2 : f <= 5 ? 5 : 10;
  return nice * exp;
}

週ごとの集計は期間の終わりから7日ずつ数え、最後の週が必ず丸1週間になるようにしています。

テストとCI

Vitest で22件のテストを書きました。

  • 集計:比率を合計から出すこと、0で割らないこと、週の区切りで行が漏れたり二重に数えられたりしないこと
  • 期間:うるう年や月末をまたぐ日付計算、想定外の値を既定値に戻すこと
  • サンプル:同梱データの合計が、マートの合計と一致すること
  • BigQuery:テーブル名のチェック、パラメータの渡し方、数値でない値をエラーにすること、キャッシュ

GitHub Actions では、lint・型チェック・テスト・本番ビルドのあとに、サーバーを起動して各ページが200を返すかも確かめています。

動かす

Node.js 20.9 以上が必要です。

git clone https://github.com/yahayuta/ec-dashboard.git
cd ec-dashboard
npm install
npm run dev      # http://localhost:3000(同梱のサンプルで動きます)
npm run check    # lint・型チェック・テスト・ビルド

BigQuery を使う場合は、ec-analytics-platform でマートを作ってから、環境変数を設定します。

gcloud auth application-default login
DATA_SOURCE=bigquery BQ_PROJECT_ID=your-gcp-project BQ_DATASET=marts BQ_LOCATION=asia-northeast1 npm run dev

読むだけなので、権限はマートのデータセットへの roles/bigquery.dataViewer と、クエリを実行する roles/bigquery.jobUser で足ります。Cloud Run にもそのままデプロイできます(README に手順があります)。

まとめ

  • 指標の定義は dbt に1か所。画面は足し上げて比率を出すだけにする
  • 比率は合計から出し、率の比較は「pt」で見せる
  • データの取得はサーバーコンポーネントだけで行い、認証情報もSQLもブラウザに出さない
  • SQLは値をパラメータで渡し、テーブル名は文字の種類をチェックする
  • BigQuery の結果はキャッシュし、読む前に集計して行数を減らす
  • 期間はデータの最終日から数え、同じデータなら同じ数字を出す

ダッシュボードをコードで書いておけば、AIに頼むだけで画面を直したり項目を足したりできます。いずれは、非エンジニアのメンバーが自分で運用できる形に移していけると良いと考えています。

リポジトリ:https://github.com/yahayuta/ec-dashboard

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?