独立型ダッシュボードを作って壊した日 — 1ドメイン統合型への方針転換
朝、管理画面をどこに置くかでClaude Codeと30分ほど議論を重ねた。同じアプリ内に埋め込むか、別リポジトリに切り出すか。「先生が複数アプリの学習状況を一画面で見たい」というユースケースを口に出した瞬間、答えが決まった。独立アプリにする。eurekapu-nuxt4/eurekapu-dashboard/ に配置して、tax-lpと同じパターンでモノレポの一部として管理することにした。
設計議論: SaaS時代の終焉とローカルファースト
議論の途中で話が広がり、「そもそもSaaS月額モデルはこの時代に合うのか」という問いが出てきた。Claude Codeのようなツールで対話しながらアプリを組み立てられる今、テンプレートを渡して各自がローカルで立ち上げる方が理にかなう場面がある。結果として、テンプレート提供(B2B軸)とコンテンツ提供(B2C軸)の2軸に整理した。具体的な販売戦略はまだ固まっていないが、頭の中のモヤモヤが構造として見える形になった。
技術構成
- Nuxt 4 + Cloudflare Pages(SSR)
- D1直接バインディング: API層を挟まず、問題集アプリのD1データベースを直接参照する。管理画面なのでトラフィックは少なく、抽象層を省いてシンプルに保つ判断
- Better Auth: 認証レイヤー。管理者ホワイトリスト方式でアクセス制御
実装: 6フェーズ・全20ファイル
実装は6フェーズに分けて進めた。
- プロジェクト初期化 --
package.json、nuxt.config.ts、wrangler.tomlのスケルトン作成 - 認証レイヤー -- Better Auth導入、
admin-guardミドルウェアで管理者以外をブロック - レイアウトとログイン画面 -- サイドバー付きレイアウト、Googleログイン画面
- ダッシュボードAPI -- ユーザー一覧・ユーザー詳細・統計の3エンドポイント。D1に直接SQLを投げる
- ダッシュボードページ -- 概要ページ、ユーザー一覧、ユーザー詳細の3画面
- デプロイスクリプト -- PowerShellスクリプトでローカルビルド→Cloudflare Pagesデプロイ
全20ファイルを作成し、ビルドが通った。ここまでは順調だった。
本番デプロイで踏んだ3つの罠
ビルド成功からデプロイまでの間に、3件のトラブルが待っていた。
1. Better Authのセッションテーブルにtokenカラムが無い
デプロイ後にログインしようとすると、D1側でカラム不足エラーが出た。Better Authのマイグレーションが生成したスキーマにtokenカラムが含まれていなかった。D1コンソールから手動でALTER TABLE session ADD COLUMN token TEXTを実行して解決。マイグレーション生成を過信せず、実テーブルとスキーマの差分を目視で確認する癖をつける必要がある。
2. SSR時のuseFetchがCookieを転送しない
サーバーサイドレンダリング時、useFetchでAPIを叩くと認証Cookieが付与されず、常に未ログイン扱いになる問題に遭遇した。Nuxt 3/4ではSSR時にブラウザのCookieが自動転送されない。useRequestFetch()に切り替えることで、リクエストヘッダーからCookieを引き継げるようになった。
// NG: SSR時にCookieが飛ばない
const { data } = await useFetch('/api/dashboard/stats')
// OK: リクエストヘッダーを引き継ぐ
const requestFetch = useRequestFetch()
const { data } = await useFetch('/api/dashboard/stats', { $fetch: requestFetch })
この1行の差でログイン後の画面が真っ白になるかデータが表示されるかが分かれる。SSR + 認証の組み合わせでは必ず意識すべきポイントだった。
3. 管理者ホワイトリストにメールアドレスが未登録
ログインは成功するが管理画面に入れない。admin-guardミドルウェアが弾いていた。環境変数のADMIN_EMAILSに自分のメールアドレスを追加して解決。単純なミスだが、エラーメッセージが「Unauthorized」だけだったので、認証の問題かガードの問題かの切り分けに少し時間を使った。
サーバーレス vs トラディショナルバックエンドの整理
ダッシュボード実装を通じて、Cloudflare Workersのサーバーレスモデルとトラディショナルなバックエンド(Express/Railsなど常駐サーバー)の違いを改めて整理した。
- Workers: CPU実行時間30秒制限、リクエスト単位の課金、コールドスタートがほぼゼロ
- トラディショナル: 実行時間制限なし、常時起動コスト、長時間バッチ処理に向く
管理ダッシュボードのように「少数ユーザーが軽いクエリを投げる」用途にはWorkersが合う。逆に、全ユーザーの学習データを集計するバッチ処理を入れたくなったら、30秒制限にぶつかる可能性がある。その場合はCronトリガーで事前集計テーブルに書き出すアプローチが現実的。
マネタイゼーション計画の改訂
サブスクリプションモデルではなく、3段階の買い切りモデルに整理した。
- フリー: 一部コンテンツを無料公開
- ログイン済み: 学習進捗の保存が可能に
- 買い切り: 全コンテンツへのアクセス権を一括購入
月額課金は管理コストが高く、ユーザーにとっても「いつ解約するか」を気にさせてしまう。買い切りなら一度払えば終わり。このシンプルさが、個人開発のサポート負荷にも合っている。
動画ホスティング調査
コンテンツに動画を含める場合のホスティング先を比較調査した。
| サービス | 特徴 | 懸念 |
|---|---|---|
| Cloudflare Stream | Cloudflareエコシステム内で完結 | 従量課金が読みにくい |
| Bunny.net | コスト効率が高い、CDN品質も良い | Cloudflareとの二重管理 |
| Mux | 開発者体験が良い、APIが充実 | 価格が高め |
結論は出していないが、まずは動画なしでテキスト+画像ベースのコンテンツを優先し、動画は需要が見えてから導入する方針にした。
今日の学び
useRequestFetch()はSSR + 認証の組み合わせで必須。これを知らずにuseFetchだけで組むと、サーバーサイドでの認証状態が常に未ログインになり、原因特定に時間を持っていかれる- マイグレーション生成ツールの出力を鵜呑みにしない。本番のD1テーブル定義を
PRAGMA table_info(session)で直接確認する手順を踏んでいれば、tokenカラム不足はデプロイ前に気づけた - 6フェーズ・20ファイルを一気に作り切るとビルドは通るが、本番環境固有の問題は踏むまでわからない。次回は「2ファイル作ってデプロイ」を繰り返す方がトラブルの切り分けが楽になる
方針転換: 独立型ダッシュボード → 1ドメイン統合型
ここまで作り上げたダッシュボードだが、同日中にマネタイズモデルとユーザー体験を掘り下げた結果、独立型をやめて1ドメイン統合型に転換する判断をした。
なぜ壊したか
サブドメイン分離を前提に設計を進めていたが、実際のユーザー導線を考えたときに3つの問題が浮かび上がった。
- セッションの分断 — サブドメイン間でCookieが共有されず、サイトを移動するたびに再ログインが必要になる
- 購入状態の把握困難 — 「自分は何を買ったか」を確認する場所がない
- 課金導線の分断 — ある科目を終えて次に進みたいとき、別サイトに飛ばされて再度購入フローを踏む
1ドメイン・1アプリならこれらはすべて消える。
新しい構成
eurekapu.com(1ドメイン・1アプリ)
├── / ← 無料(トップ、紹介ページ)
├── /boki3/ ← 購入済み or サブスク会員のみ
├── /boki2/ ← 同上
├── /admin/ ← 管理者のみ(メールホワイトリスト)
└── /login ← 認証
管理画面はアプリ内の /admin/ ルートとして構築する。DBも1つのD1に集約。独立ダッシュボードで得た技術知見(Better Auth認証、D1直接バインディング、SSR時のCookie転送)はそのまま /admin/ 実装に流用できる。
廃止対象
eurekapu-dashboard/ディレクトリeurekapu-dashboard-db(D1)- ダッシュボード用の Cloudflare Pages プロジェクト
無駄だったか?
作ったコードは捨てるが、SSR + 認証のCookie転送問題、D1スキーマの手動確認の重要性、useRequestFetch() の必要性など、本番デプロイで踏んだ3つの罠から得た知見は /admin/ 構築時にそのまま活きる。プロトタイプとして意味のある1日だった。
次のステップ
- eurekapu-dashboard ディレクトリおよび関連リソースの削除
- eurekapu-nuxt4 に
/admin/ルートを構築(ダッシュボードの機能を移植) - 購入管理テーブル(purchase, subscription)の追加
- ルートベースのアクセス制御ミドルウェアの実装
- 決済連携の検討(Stripe Checkout)