Supabase + Next.js で作るマーケダッシュボード
自前ダッシュボードを作るべき条件を絞ったうえで、データ取得・集計・チャート・フィルタ・リアルタイム・認証までを動くコードで通します。基盤構築の手前(Supabase + dbt + Metabase)は別記事に分けています。
by Shin
SupabaseとNext.jsで自前のマーケダッシュボードは、作るべきか。 結論から言うと、大半のチームは作らないほうがいいでしょう。BIツール(Metabase など)で足りるからです。自前に踏み込む価値があるのは「クライアントに納品する」「自社プロダクトに埋め込む」「BIでは出せない対話操作が必要」の3条件のどれかを満たすときだけで、それ以外は隠れた保守コストを買うだけになります。 この記事では、その判断を最初に潰したうえで、要件定義 → Supabaseからのデータ取得と集計 → 実装(チャート・フィルタ・リアルタイム)→ 認証とアクセス制御の順に、動くコードと運用所感を出します。データ基盤そのものの構築(取り込み・dbt変換・スキーマ設計)は扱いません。それは別棟(グロースのためのデータ基盤構築)の仕事で、本記事は「集計済みの mart を読んで見せる」フロント側に限定します。
まず潰す:本当に自前で作るのか
正直に書きます。BIツールで集計済みのテーブルを繋げば、ダッシュボードは半日でできます。権限管理もリンク共有もメンテも、ツール側が面倒を見てくれます。自前で同じものを作ると、認証・アクセス制御・落ちたときの責任が全部こちらに来ます。
自前に踏み込む価値があるのは、次のどれかに当てはまるときだけです。
- クライアントに納品する — 自分のブランドのUIで渡したい、BIツールのログイン画面を見せたくない、納品物として所有権ごと渡したい。
- 自社プロダクトに埋め込む — 既存のNext.jsアプリの一画面として、同じ認証・同じデザインで統合したい。
- BIでは出せない対話操作が要る — 反実仮想のスライダー、シナリオ比較、独自のドリルダウンなど、汎用BIのチャートでは表現できないもの。
私の手元の一次所感を一つ。ある BtoB SaaS(匿名)の社内ダッシュボードは、最初の1年はBIツールで十分回っていました。自前化したのは「クライアントに同じ画面を渡す」必要が出た瞬間です。そのときの追加コストは、BIツール運用比で 初期 +20〜40時間/月次保守 +2〜4時間(環境依存・レンジ)。この上振れを払う理由が上の3条件で、理由がないなら払うべきではありません。これが本記事の前提になります。
要件定義:見るべき指標とロール
自前で作ると決めたら、最初にやることはチャートを選ぶことではありません。「行動が変わる数字」だけに絞ることと、誰がどこまで見られるか(ロール)を先に決めることです。
指標は欲張ると破綻します。最初の版で並べる数字は、それを見て翌週の打ち手が変わるものに限定します。具体的には次の3層に分けて、各層を別画面ではなく1スクロールに収めます。
- 獲得:チャネル別の流入・CV・CPA(日次・週次)
- 効率:ROAS、チャネル横断の配分(どこを止め、どこに寄せるか)
- 異常:前週比で閾値を超えた指標のアラート(増減どちらも)
ロールは認証(後述)の設計を先に縛るので、ここで決める。最小構成は3つで足りることが多い。
admin:全データ+設定変更member:全データ閲覧のみclient_viewer:自分が紐づくクライアントの行だけ閲覧
client_viewer の「自分の行だけ」が、後段のRow Level Security(RLS)の主役になります。ここを曖昧にすると、納品先に他社の数字が見える事故につながります。
そして本記事の境界をもう一度引いておきます。ダッシュボードが読むのは、基盤側ですでに集計された mart(例:mart_daily_metrics という日次×チャネル×指標のビュー/テーブル)だとします。この mart をどう作るか(ELT・dbtの変換・テスト)は基盤構築の記事の領分で、ここでは「あらかじめ取り決めの済んでいる読み取り先」として扱います。フロントで未加工のログを集計し始めた時点で、設計を間違えています。
Supabaseからのデータ取得と集計
原則は一つ。集計はDB側(Postgres)に寄せ、フロントは結果を受け取るだけにする。フロントで数万行を reduce し始めると、初期表示が伸び、Core Web Vitals が崩れます。実際、フロント集計をやめてDB側のビュー+RPCに寄せたとき、初期表示は 2〜3秒台 → 0.3秒台(所感・環境依存)に変わりました。
この原則には、いま特有の敵がいます。AI にダッシュボードを書かせると、ほぼ必ずこの原則が破られる。Claude Code を含む LLM は、実装を丸ごと任せると「テーブルから行を全件取得して JS の reduce で集計する」コードを既定で選びます(複数の実装で繰り返し観察した一次所感です)。この記事の読者はまさに AI に書かせる層のはずなので、対処を先に書いておきます——プロンプトに「集計は SQL/RPC 側で行い、フロントに未加工の行を持ち込まない」と明示するか、生成されたコードを「フロントで reduce していないか」の一点でレビューします。どちらか一段を挟むだけで、この歪みはほぼ消えます。
もう一つ、この原則を支える Supabase 固有の事実があります。Data API には返却行数の上限(Max Rows・既定1000行)があり、超過してもエラーにならず、先頭から黙って切り詰められる。.limit(100000) を付けてもこの上限は超えられません。だからフロントで生行を集計する設計は、行数が上限を超えた日から「合計が UI と合わない」「グラフの右端が欠ける」という形で静かに間違い始める——例外が出ないので、症状から原因に辿り着きにくい正確性バグの源です。集計を DB 側に寄せれば返る行数は日×チャネル程度まで小さくなり、この罠自体を構造的に踏まなくなります。上限は Dashboard の Data API 設定で変更できますが、上げて凌ぐより集計を寄せるのが筋です。
まず、期間を引数で受け取る集計関数(RPC)を用意します。ビューを直に読むより、引数で期間や粒度を渡せるRPCのほうがフロントが薄くなります。
-- 期間とクライアントを受け取り、日次のチャネル別サマリを返す
create or replace function public.fn_channel_daily(
p_from date,
p_to date,
p_client_id uuid default null
)
returns table (
day date,
channel text,
sessions bigint,
conversions bigint,
cost numeric,
cpa numeric
)
language sql
stable
security invoker -- 呼び出し元の権限で実行(RLSを効かせる。後述)
set search_path = public
as $$
select
m.day,
m.channel,
sum(m.sessions) as sessions,
sum(m.conversions) as conversions,
sum(m.cost) as cost,
case when sum(m.conversions) > 0
then round(sum(m.cost) / sum(m.conversions), 0)
else null end as cpa
from public.mart_daily_metrics m
where m.day between p_from and p_to
and (p_client_id is null or m.client_id = p_client_id)
group by m.day, m.channel
order by m.day, m.channel
$$;Next.js(App Router)側は Server Component から読みます。@supabase/ssr のサーバークライアントを使い、Cookieベースのセッションをそのまま引き継ぎます。クライアント生成のユーティリティは次のとおりです(これは現行の推奨パターン。@supabase/auth-helpers は非推奨になっています)。
// utils/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createClient() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!, // 公開鍵(anonキーの値も移行期は可)
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// Server Component から呼ばれた場合は middleware 側で更新するので無視
}
},
},
}
)
}Server Component から RPC を呼びます。searchParams を期間フィルタの正にしている点が後段の伏線になります。
// app/dashboard/page.tsx (Server Component)
import { createClient } from '@/utils/supabase/server'
import ChannelChart from './ChannelChart'
export default async function DashboardPage({
searchParams,
}: {
searchParams: Promise<{ from?: string; to?: string; client?: string }>
}) {
const { from, to, client } = await searchParams
const supabase = await createClient()
const { data, error } = await supabase.rpc('fn_channel_daily', {
p_from: from ?? defaultFrom(),
p_to: to ?? today(),
p_client_id: client ?? null,
})
if (error) {
return <p>データを取得できませんでした。時間をおいて再読み込みしてください。</p>
}
return <ChannelChart rows={data ?? []} />
}行数が増えてきたとき(目安として mart が 数十万行を超えるあたり・所感)は、RPC のなかで都度 group by するのをやめ、マテリアライズドビューに事前集計を退避させます。ただしこの判断は基盤側の話で、退避の設計は基盤構築の記事に寄せています。フロント側の鉄則は「重い集計を持ち込まない」一点だけです。
実装:チャート・フィルタ・リアルタイム
ここが自前ダッシュボードの本体ですが、派手にする必要はありません。むしろ依存を増やさず、抑制したデザインで読みやすくすることが、表示速度と信頼の両方に効きます。
チャート
チャートライブラリは軽いものを一つに絞ります。色は抑え、グラデーションや影に頼りません。下は Recharts の最小例で、受け取った行をそのまま描く Client Component です。
// app/dashboard/ChannelChart.tsx
'use client'
import { useEffect, useState } from 'react'
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts'
type Row = { day: string; channel: string; conversions: number }
export default function ChannelChart({ rows }: { rows: Row[] }) {
// ① マウントガード:初回描画はクライアント確定後に
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
// 日付ごとに横持ちへ整形(DB側で整形済みなら省略可)
const byDay = new Map<string, Record<string, number | string>>()
for (const r of rows) {
const d = byDay.get(r.day) ?? { day: r.day }
d[r.channel] = r.conversions
byDay.set(r.day, d)
}
const data = [...byDay.values()]
const channels = [...new Set(rows.map((r) => r.channel))]
return (
// ② 外側で高さを予約:チャートの出現でレイアウトを動かさない
<div style={{ height: 320 }}>
{mounted && (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 8, right: 16, bottom: 8, left: 0 }}>
<XAxis dataKey="day" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} width={40} />
<Tooltip />
{channels.map((ch) => (
<Line
key={ch}
type="monotone"
dataKey={ch}
dot={false}
strokeWidth={1.5}
isAnimationActive={false} // ③ 描画アニメーション無効
/>
))}
</LineChart>
</ResponsiveContainer>
)}
</div>
)
}コード中の①〜③は装飾ではなく、体感速度のための定石です。App Router では recharts の ResponsiveContainer がサーバー描画とクライアント実寸のあいだで暴れやすく、①のマウントガード(クライアント確定後に描く)と②の外側での高さ予約を組み合わせると、チャートの出現でレイアウトが跳ねる(CLS)のを防げます。③の isAnimationActive={false} は、線が伸びる演出を消す代わりに描画コストと操作の引っかかり(INP)を抑えます。ダッシュボードで毎回線が伸びるアニメーションは、3日で誰も見なくなる割にコストだけ残ります。
フィルタ
フィルタの状態は、Client側の state ではなく URLの searchParams を正にする。理由は3つあり、いずれも自前ならではの利点です。第一に、フィルタした画面をそのままURLで共有できます(納品先に「この期間のこの数字」を渡せます)。第二に、Server Componentで再フェッチされるので、フロントに重いデータが乗りません。第三に、ブラウザの戻る/進むがそのまま効きます。
// app/dashboard/DateRangeFilter.tsx
'use client'
import { useRouter, useSearchParams, usePathname } from 'next/navigation'
export default function DateRangeFilter() {
const router = useRouter()
const pathname = usePathname()
const params = useSearchParams()
function setRange(from: string, to: string) {
const next = new URLSearchParams(params)
next.set('from', from)
next.set('to', to)
router.push(`${pathname}?${next.toString()}`)
}
return (
<div>
<button onClick={() => setRange(last7d(), today())}>過去7日</button>
<button onClick={() => setRange(last28d(), today())}>過去28日</button>
</div>
)
}フィルタまわりで、実害から学んだ注意を二つ足しておきます。
第一に、フィルタの値は「UI の state → フック/関数の引数 → API のクエリパラメータ」まで一本で通す。この鎖のどこか1箇所でも固定値が挟まると(データ取得フックのデフォルト引数に仮置きした期間など)、UI は反応するのにデータが変わらない——「固定表示バグ」に見える症状になり、原因の特定に時間を食います。本記事の構成なら searchParams から RPC の p_from / p_to まで、期間を受け取らない層を作らないこと。
第二に、date 文字列を作る瞬間のタイムゾーン。new Date().toISOString().split('T')[0] は UTC の日付を返すので、JST の 0:00〜8:59 に開くと「今日」が1日ずれ、前日までのデータしか出ません。
// JST を明示して YYYY-MM-DD を作る(sv-SE ロケールは ISO 形式で返る)
const todayJst = new Intl.DateTimeFormat('sv-SE', {
timeZone: 'Asia/Tokyo',
}).format(new Date())あわせて、月次の比較を「過去30日 vs その前30日」のような同じ長さの窓でやると、月の長さや週の並びの違いで意図とずれます。前月比較は暦月(1日〜末日)で切るのが安全です。
リアルタイム
ここで一度立ち止まります。マーケダッシュボードに常時リアルタイムは、ほとんどの場合いりません。 日次や週次で意思決定する数字を秒間更新しても、意思決定は速くなりません。むしろ接続を張り続けるぶんコストとバグ(再接続漏れ)を抱えます。
リアルタイムが効くのは限定的な場面だけです。たとえば「大型キャンペーンの配信日に、CVの立ち上がりを1画面だけ見張る」ようなケース。私の運用でも、リアルタイムは全画面ではなく配信モニタ用の1コンポーネントに限定して入れました。
その限定用途のコードがこれ。postgres_changes で対象テーブルのINSERTを購読し、アンマウント時に必ずクリーンアップします。
// app/dashboard/LiveCounter.tsx
'use client'
import { useEffect, useState } from 'react'
import { createClient } from '@/utils/supabase/client'
export default function LiveCounter({ campaignId }: { campaignId: string }) {
const [count, setCount] = useState(0)
const supabase = createClient()
useEffect(() => {
const channel = supabase
.channel(`cv-${campaignId}`)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'conversions',
filter: `campaign_id=eq.${campaignId}`,
},
() => setCount((c) => c + 1)
)
.subscribe()
return () => {
supabase.removeChannel(channel) // 購読の後始末は必須
}
}, [campaignId, supabase])
return <p>本日のCV(ライブ):{count}</p>
}認証とアクセス制御
自前ダッシュボードでいちばん事故が起きるのがここです。原則をはっきりさせます。フロントでの出し分けは「装飾」にすぎず、本物の境界はDB側のRLSにある。 ボタンを隠しても、APIを直接叩かれれば見えてしまいます。だから「client_viewer は自分のクライアントの行だけ」を、フロントではなくRLSで担保します。
「装飾にすぎない」を具体的に言い換えると、こうなります。公開鍵(従来の anon キー)は、ブラウザに渡された時点で公開情報になります。アプリのコードで秘匿列を隠して(redact して)いても、鍵と URL さえあれば REST を直接叩けるので、アプリ層のフィルタは丸ごとバイパスされます。実際、あるダッシュボード実装で「アプリ側で redact 済みのつもり」だった秘匿値が、REST 直叩きで数百件規模、ローデータのまま取れたことがあります。守るなら DB 層——秘匿列は別テーブルに分離するか専用ビューに逃がし、行は RLS で絞ります。アプリ層のフィルタは防御に数えない。
納品前に一度、自分のダッシュボードを攻撃者視点で叩いておくとよいでしょう。
# ブラウザの DevTools を開けば公開鍵と URL は誰でも拾える。つまり誰でもこれが打てる
curl "https://<project>.supabase.co/rest/v1/mart_daily_metrics?select=*" \
-H "apikey: <公開鍵>" \
-H "Authorization: Bearer <公開鍵>"
# RLS が正しく効いていれば 0 行(または権限エラー)。秘匿列がローデータのまま返ってきたら設計が漏れているまずロールを profiles に持たせ、mart に対してRLSを張ります。
-- ロール保持
create table if not exists public.profiles (
id uuid primary key references auth.users(id) on delete cascade,
role text not null default 'member', -- admin / member / client_viewer
client_id uuid -- client_viewer のとき紐づくクライアント
);
alter table public.mart_daily_metrics enable row level security;
-- 全員(admin / member)は閲覧可、client_viewer は自分の client_id の行だけ
create policy "read scoped metrics"
on public.mart_daily_metrics
for select
to authenticated
using (
exists (
select 1 from public.profiles p
where p.id = auth.uid()
and (
p.role in ('admin', 'member')
or (p.role = 'client_viewer' and p.client_id = mart_daily_metrics.client_id)
)
)
);セッションの更新は middleware に寄せます。Server Component はCookieを書けないため、期限切れトークンの更新は middleware が担います。
// middleware.ts (要点のみ)
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function middleware(request: NextRequest) {
let response = NextResponse.next({ request })
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{
cookies: {
getAll: () => request.cookies.getAll(),
setAll: (cookiesToSet) => {
cookiesToSet.forEach(({ name, value, options }) =>
response.cookies.set(name, value, options)
)
},
},
}
)
// セッションを更新しつつ、未ログインは弾く
const { data } = await supabase.auth.getUser()
if (!data.user && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url))
}
return response
}
export const config = { matcher: ['/dashboard/:path*'] }最後にクライアント納品時の鉄則を3つ。第一に、秘密鍵(secret / service_role 相当)は何があってもブラウザ側に出しません。これはRLSを丸ごと迂回するので、漏れた瞬間に全クライアントの数字が見えます。第二に、client_viewer の実アカウントで一度ログインし、URLの client パラメータを他社のIDに書き換えても他社の行が出ないことを必ず確認します(RLSが効いていれば0行になります)。第三に、ダッシュボードのアクセスログを残します。納品物は「誰が何をいつ見たか」を後から説明できる状態にしておきます。
ここまでが、自前で作ると決めた場合のフロント実装の最短ルートです。繰り返しますが、入口の3条件に当てはまらないなら、この保守コストは買わないほうがいいでしょう。「そもそも小さな組織はどこまでデータ基盤に投資すべきか」という手前の判断は、ビジネス側の記事(「データが見られない」を解く:小さな組織のためのデータ基盤の考え方)で扱っています。本記事のダッシュボードが読む mart の作り方は、基盤構築の記事へ。