個人開発で作る管理ダッシュボード Supabase + Astroで1日で完成
この記事の要点
個人開発のメディアサイトに管理ダッシュボードを追加する。アクセスランキング・問い合わせ一覧・環境フラグ管理の3機能をSupabase + Astroで実装する設計と実際のコードを解説する。
結論
個人開発の管理ダッシュボードに必要な機能は3つに絞れる。「アクセスランキング(何が読まれているか)」「問い合わせ一覧(誰から連絡が来ているか)」「環境フラグ(自分のアクセスを計測から除外するトグル)」だ。Supabaseからデータを取得してHTMLの表として表示する実装で1日あれば完成する。
ダッシュボードの設計
管理ダッシュボードは/admin/以下に複数のページで構成する。
src/pages/admin/
├── index.astro ← ダッシュボードのトップ(サマリー)
├── analytics.astro ← アクセスランキング
├── inquiries.astro ← 問い合わせ一覧
└── settings.astro ← 環境フラグ・設定
各ページは認証チェックを入れて、未認証ならログインページにリダイレクトする。
ページ1:ダッシュボードサマリー
---
import { supabase } from '../../lib/supabase';
const today = new Date().toISOString().split('T')[0];
// 今日のPV
const { count: todayPV } = await supabase
.from('access_logs')
.select('*', { count: 'exact', head: true })
.gte('created_at', `${today}T00:00:00`);
// 総PV
const { data: totalData } = await supabase
.from('page_views')
.select('count');
const totalPV = totalData?.reduce((sum, row) => sum + row.count, 0) ?? 0;
// 未読の問い合わせ数
const { count: unreadCount } = await supabase
.from('inquiries')
.select('*', { count: 'exact', head: true })
.eq('read', false);
// 人気記事トップ5
const { data: topPages } = await supabase
.from('page_views')
.select('slug, count')
.order('count', { ascending: false })
.limit(5);
---
<html lang="ja">
<body>
<h1>管理ダッシュボード</h1>
<div class="stats">
<div class="stat-card">
<h3>今日のPV</h3>
<p class="number">{todayPV?.toLocaleString()}</p>
</div>
<div class="stat-card">
<h3>累計PV</h3>
<p class="number">{totalPV.toLocaleString()}</p>
</div>
<div class="stat-card">
<h3>未読の問い合わせ</h3>
<p class="number">{unreadCount ?? 0}</p>
</div>
</div>
<h2>人気記事トップ5</h2>
<ol>
{topPages?.map(p => (
<li>
<a href={`/articles/${p.slug}/`} target="_blank">{p.slug}</a>
<span>{p.count.toLocaleString()} PV</span>
</li>
))}
</ol>
<nav>
<a href="/admin/analytics/">アクセス詳細 →</a>
<a href="/admin/inquiries/">問い合わせ一覧 →</a>
<a href="/admin/settings/">設定 →</a>
</nav>
</body>
</html>
ページ2:アクセスランキング詳細
---
import { supabase } from '../../lib/supabase';
// 記事別PVランキング(全件)
const { data: allPages } = await supabase
.from('page_views')
.select('slug, count, updated_at')
.order('count', { ascending: false });
// 直近7日間の流入元
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
const { data: referrers } = await supabase
.from('access_logs')
.select('referrer')
.not('referrer', 'is', null)
.gte('created_at', sevenDaysAgo);
// 流入元を集計
const referrerCounts = referrers?.reduce((acc, row) => {
acc[row.referrer] = (acc[row.referrer] || 0) + 1;
return acc;
}, {});
const sortedReferrers = Object.entries(referrerCounts || {})
.sort((a, b) => b[1] - a[1])
.slice(0, 10);
---
<h1>アクセス詳細</h1>
<h2>記事別PVランキング</h2>
<table>
<thead>
<tr><th>記事スラッグ</th><th>累計PV</th><th>最終アクセス</th></tr>
</thead>
<tbody>
{allPages?.map(p => (
<tr>
<td><a href={`/articles/${p.slug}/`} target="_blank">{p.slug}</a></td>
<td>{p.count.toLocaleString()}</td>
<td>{new Date(p.updated_at).toLocaleDateString('ja-JP')}</td>
</tr>
))}
</tbody>
</table>
<h2>流入元(直近7日)</h2>
<table>
<thead><tr><th>流入元</th><th>件数</th></tr></thead>
<tbody>
{sortedReferrers.map(([referrer, count]) => (
<tr>
<td>{referrer}</td>
<td>{count}</td>
</tr>
))}
</tbody>
</table>
ページ3:問い合わせ一覧
inquiriesテーブルにreadカラムを追加しておく:
ALTER TABLE inquiries ADD COLUMN read boolean DEFAULT false;
---
import { supabase } from '../../lib/supabase';
const { data: inquiries } = await supabase
.from('inquiries')
.select('id, name, email, message, read, created_at')
.order('created_at', { ascending: false });
---
<h1>問い合わせ一覧</h1>
{inquiries?.map(inq => (
<div class={`inquiry ${inq.read ? 'read' : 'unread'}`}>
<div class="header">
<span class="name">{inq.name}</span>
<span class="email">{inq.email}</span>
<span class="date">{new Date(inq.created_at).toLocaleString('ja-JP')}</span>
{!inq.read && <span class="badge">未読</span>}
</div>
<div class="message">{inq.message}</div>
{!inq.read && (
<button data-id={inq.id} class="mark-read-btn">既読にする</button>
)}
</div>
))}
<script>
import { supabase } from '../../lib/supabase';
document.querySelectorAll('.mark-read-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset.id;
await supabase.from('inquiries').update({ read: true }).eq('id', id);
btn.closest('.inquiry').classList.add('read');
btn.remove();
});
});
</script>
ページ4:環境フラグ設定
自分のアクセスをカウントから除外するためのフラグを切り替えるページ。
<h1>設定</h1>
<h2>自分のアクセスを除外する</h2>
<p>管理者フラグがオンの場合、このブラウザからのアクセスはカウントされません。</p>
<button id="toggle-owner-flag"></button>
<script>
const btn = document.getElementById('toggle-owner-flag');
const updateBtn = () => {
const isOwner = localStorage.getItem('owner') === 'true';
btn.textContent = isOwner ? 'オフにする(計測を再開)' : 'オンにする(自分を除外)';
btn.style.background = isOwner ? '#ef4444' : '#22c55e';
};
updateBtn();
btn.addEventListener('click', () => {
const isOwner = localStorage.getItem('owner') === 'true';
if (isOwner) {
localStorage.removeItem('owner');
} else {
localStorage.setItem('owner', 'true');
}
updateBtn();
});
</script>
Supabase Authでの認証保護はSupabase Authでメール認証ログインを実装するを、アクセス計測の設計は静的サイトにアクセス解析を自作するで解説している。
まとめ
個人開発の管理ダッシュボードは、サマリー・アクセスランキング・問い合わせ一覧・設定の4ページで構成できる。Supabaseからデータを取得してHTMLで表示するシンプルな実装で、1日あれば基本形が完成する。情報を見やすく表示することより、「毎日2〜3分確認する習慣」が持続できる作りにすることが、個人開発の管理画面では最も重要だ。
よくある質問
個人開発の管理ダッシュボードを作るのにどのくらい時間がかかりますか
Supabase + Astroの基本構成が整っていれば、アクセスランキング・問い合わせ一覧・環境フラグ管理の3機能を1日で実装できます。Supabase Authによる認証保護の追加は2〜3時間の追加作業です。
管理ダッシュボードは外部から見られる状態ですか
設計によります。静的生成したページはURLを知っている人なら誰でもアクセスできます。機密情報を表示する管理画面は、Supabase Authによる認証保護か、パスワード保護の設定が必要です。
Google Analyticsの代わりに自前の管理ダッシュボードで十分ですか
目的によります。「どの記事が読まれているか」「問い合わせが来ているか」の確認なら自前で十分です。ユーザーの行動分析・セッション計測・コンバージョン計測が必要なら、Plausible・UmamiなどのGA代替か、GAとの併用を検討します。
管理画面のUIフレームワークは何を使えばいいですか
個人開発の管理画面はTailwind CSS + シンプルなHTMLで十分です。Vue・Reactなどのフレームワークを使わなくても、表示のみの管理画面はAstroとバニラJSで作れます。UI自体をきれいに作ることより、必要な情報を素早く確認できることを優先してください。