Next.js App Router で SaaS ダッシュボードをサイドバー型に移行した記録
- engineering
- nextjs
- app-router
- claude-code
TL;DR
- 課題: 1ページ詰め込み構造で「どこに何があるか分からない」「Settings 追加できない」の限界
- 解決: Supabase/Vercel/Linear が採用するサイドバー+マルチページに移行(5ページ分割、所要2時間)
- テクニック: Next.js App Router でディレクトリ追加だけ、sticky 多段 (
top-14+h-[calc(100vh-3.5rem)])、usePathnameで active 判定、<div> + aria-disabledで Coming Soon、LP と dashboard で i18n 辞書を SSOT に- トレードオフ: モバイル時のサイドバー収納設計、状態管理の分散、新規実装時の初期コスト増(後述)
なぜ SaaS は皆サイドバー+マルチページなのか
Supabase / Vercel / Linear / Notion / Stripe Dashboard — どの主要 SaaS も左サイドバー+マルチページを採用しています。これは「ナビゲーションコスト最小化」と「機能追加への構造的余白」の両立解です。
逆に「1ページ詰め込み」が許される期間は短く、機能が3つを超えると構造的限界が来ます。HeatMapX もまさにそこに到達しました。
背景:1ページ詰め込みからの限界
HeatMapX のダッシュボードは長らく /dashboard 1ページ完結でした:
- お知らせバナー
- プラン使用量カード (PlanCard)
- 登録サイトのリスト
- CLI インストール手順 (CliOnboarding)
- Getting Started ガイド
- 各種モーダル (OnboardingModal, UpgradeDialog, AddSiteDialog)
これらが縦に積まれている状態でした。早期は問題ありませんでしたが、機能が増えるたびに「どこに何があるか分かりにくい」「Settings 追加できない」という構造的限界が出てきました。
そこで、Supabase / Vercel / Linear などが採用している 左サイドバー + マルチページ構成 に移行しました。
最終構成
| ルート | 内容 |
|---|---|
/dashboard |
ホーム (オーバービュー: 統計 + 使用量 + クイックアクション) |
/dashboard/sites |
ヒートマップ一覧 (サイトリスト) |
/dashboard/cli |
CLI & Skills (CLI セットアップ + Claude Code Skill 案内) |
/dashboard/billing |
プラン & 請求 (現在使用量 + 全プラン比較表) |
/dashboard/settings |
設定 (言語選択など) |
Next.js App Router のファイルベースルーティングが効く
App Router では、ディレクトリ構造がそのまま URL になります。新ページの追加は フォルダ + page.tsx ファイル を作るだけ:
src/app/dashboard/
├── layout.tsx ← 全 dashboard/* で共有 (sidebar + header)
├── page.tsx ← /dashboard (ホーム)
├── sites/
│ └── page.tsx ← /dashboard/sites
├── cli/
│ └── page.tsx ← /dashboard/cli
├── billing/
│ └── page.tsx ← /dashboard/billing
├── settings/
│ └── page.tsx ← /dashboard/settings
└── components/
├── Sidebar.tsx
├── PricingPlans.tsx
├── LanguagePicker.tsx
└── SkillOnboarding.tsx
5 ページ追加で 新規 5 ファイル + 共通 layout.tsx 更新。サーバーサイド対応 (Server Components)、認証チェック (requireUser)、i18n (useLocale) などはすべて自然に動きます。
Sticky Header + Sticky Sidebar の共存
レイアウト構造
// dashboard/layout.tsx
<div className="flex min-h-screen flex-col">
<header className="sticky top-0 z-30 h-14 ...">
{/* logo + theme + locale */}
</header>
<div className="flex flex-1">
<Sidebar />
<main className="flex-1">
<div className="mx-auto max-w-6xl px-8 py-8">
{children}
</div>
</main>
</div>
</div>
サイドバーの sticky を header と共存させる
Sidebar の sticky top-0 だと header の真下に隠れます。header の高さ分オフセット させる:
// dashboard/components/Sidebar.tsx
<aside className="sticky top-14 hidden h-[calc(100vh-3.5rem)] w-60 shrink-0 flex-col border-r ... sm:flex">
<nav className="flex-1 overflow-y-auto">
{/* menu items */}
</nav>
<div className="border-t">
{/* logout */}
</div>
</aside>
ポイントは2つ:
top-14= 3.5rem = 56px(ヘッダー高さに合わせる)h-[calc(100vh-3.5rem)]でビューポート高さからヘッダー分を引く(さもないと下にはみ出す)
flex-col + flex-1 overflow-y-auto + 下部 border-t でログアウトを底に固定するパターンも添えて。
💡 学び1: sticky 要素を多段で重ねる時は top の数値を正確に計算する。Tailwind の
top-14(56px) とh-[calc(100vh-3.5rem)]はセットで使う。header の高さを後で変更する時はこの2箇所を同期させること。
サイドバーのアクティブ状態判定
usePathname を使って現在のページに対応するメニュー項目をハイライト:
'use client'
import { usePathname } from 'next/navigation'
const menuItems = [
{ href: '/dashboard', en: 'Home', ja: 'ホーム' },
{ href: '/dashboard/sites', en: 'Heatmaps', ja: 'ヒートマップ一覧' },
// ...
]
export function Sidebar() {
const pathname = usePathname() ?? '/dashboard'
return (
<aside>
{menuItems.map(item => {
// /dashboard だけは厳密一致、それ以外は前方一致
const isActive = item.href === '/dashboard'
? pathname === '/dashboard'
: pathname.startsWith(item.href)
return (
<Link href={item.href} className={
isActive
? 'bg-slate-100 font-medium text-slate-900'
: 'text-slate-600 hover:bg-slate-50'
}>
...
</Link>
)
})}
</aside>
)
}
/dashboard だけは厳密一致 (===)、他は startsWith で前方一致。/dashboard/sites/abc123 のような詳細ページでも「ヒートマップ一覧」がアクティブのままになります。
Coming Soon パターン: 未実装機能の予告
A/B Testing と Dynamic UI を「今後実装予定」として可視化したいけど、クリックできないようにしたい。Link じゃなく <div> + aria-disabled で実装:
const comingSoonItems = [
{ en: 'A/B Testing', ja: 'A/B テスト' },
{ en: 'Dynamic UI', ja: 'ダイナミック UI' },
]
<p className="text-xs font-semibold uppercase tracking-wider text-slate-400">
Coming soon
</p>
{comingSoonItems.map(item => (
<div
aria-disabled="true"
className="flex cursor-not-allowed items-center gap-2.5 rounded-md px-3 py-2 text-sm text-slate-400"
>
<Icon className="text-slate-300" />
<Bi en={item.en} ja={item.ja} />
<span className="ml-auto rounded bg-slate-100 px-1.5 py-0.5 text-[10px] uppercase text-slate-500">
Soon
</span>
</div>
))}
実装時は comingSoonItems から menuItems 配列に移すだけで、レイアウト・スタイルは流用できます。
💡 学び2: 未実装機能は「予告する」と「期待値を作る」。サイドバー下部に Coming Soon ラベルで2項目を配置するだけで、ユーザーは「このプロダクトは今後 A/B テストや Dynamic UI が来る」と知れる。ロードマップを書くより、UI 内で予告するほうが見られる。
LP と Dashboard でデータ共有: i18n 辞書を SSOT に
料金プラン情報は LP の /en/pricing ページと、dashboard の /dashboard/billing の両方で表示します。重複定義すると価格改定時に2箇所更新が必要で事故りやすい。
解決策: i18n 辞書を Single Source of Truth に。LP も dashboard も同じ t(locale).pricing.plans を読む:
// LP: src/components/marketing/Pricing.tsx
const d = t(locale).pricing
{d.plans.map(plan => (
<article className="dark-theme-styles">...</article>
))}
// Dashboard: src/app/dashboard/components/PricingPlans.tsx
const d = t(dashLocale).pricing
{d.plans.map(plan => (
<article className="light-theme-styles">...</article>
))}
データ構造は同じで、スタイル(dark/light)と動作(LP はログイン誘導、dashboard は直接 checkout)だけ差し替え。料金改定はディクショナリ1ファイル変更で完結します。
ホーム画面の Server Component で並列データ取得
ホーム画面では複数の集計データを取得する必要があります:
- ユーザーのプラン
- サイト数
- 月間 PV
- 月間 AI 解析使用量
- 今日のイベント数 (全サイト合算)
Server Component で順次 await すると遅い。Promise.all で並列化:
export default async function DashboardHome() {
const user = await requireUser()
const [plan, siteCount, monthlyPv, aiUsage] = await Promise.all([
getUserPlan(user.id),
getSiteCount(user.id),
getMonthlyPageViews(user.id),
getMonthlyUsage(user.id),
])
// ...
}
1.5s → 600ms 程度に短縮されます。Next.js は Server Component のレンダリング前に全 await を解決するので、async 関数のサイクルを意識する設計が効きます。
使用量バーの色変化で「危険」を伝える
進捗バーの色を使用率で動的に切り替え:
function UsageCard({ used, max }) {
const pct = Math.min(100, (used / max) * 100)
const isOver = pct >= 100
const isHigh = pct >= 80
return (
<div className="h-1.5 w-full rounded-full bg-slate-200">
<div
className={
isOver ? 'bg-red-500' :
isHigh ? 'bg-amber-500' :
'bg-orange-500'
}
style={{ width: `${pct}%` }}
/>
</div>
)
}
クォータ近い段階で色が変わるので、ユーザーは「そろそろアップグレード必要かも」と気づけます。orange-500 を「正常時」に使うことで、HeatMapX のブランドカラーが常に視界に入る効果も。
ログアウトを「ヘッダー → サイドバー下部」に移動
初期実装ではヘッダー右端にログアウトリンクを置いていましたが、サイドバー化に伴い 下部に移動 しました。理由:
- サイドバー下部は「重要だが頻度の低い」要素の標準位置 (Slack, Discord, Notion 同様)
- ヘッダーは「グローバル設定 (テーマ・言語) + ブランド」だけにしてシンプル化
- ログアウトの誤クリックリスクが下がる (画面右上はホットスポット)
Claude Code Skill としての導入案内
HeatMapX は Claude Code Plugin として公開済みで、CLI と並列に「Skill」案内が必要でした。/dashboard/cli ページを2列レイアウトに:
// dashboard/cli/page.tsx
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<CliOnboarding /> {/* npm install -g heatmapx */}
<SkillOnboarding /> {/* /plugin install heatmapx-skill@... */}
</div>
SkillOnboarding には日英2例のサンプルコマンド:
// 英語例
"Analyze /pricing with HeatMapX and give me CRO ideas."
// 日本語例
"HeatMapX で /pricing を分析して、改善案を提案して。"
これで「CLI 派」「Claude Code Skill 派」両方のユーザーが、同じページから自分の導入経路を選べます。
移行作業のステップ
- Sidebar コンポーネント新規作成 (5項目 + Coming Soon 2項目、inline SVG icon)
- layout.tsx を flex 構成に (header + sidebar + main)
- 既存サイト一覧を /dashboard/sites に切り出し (page.tsx 内容を sites/page.tsx へ移動)
- /dashboard を新ホーム画面に書き直し (統計カード + 使用量バー + クイックアクション)
- billing/cli/settings のページ追加 (新規 page.tsx 3つ)
- PricingPlans コンポーネント新規 (LP と data 共有、style だけ書き分け)
- SkillOnboarding コンポーネント新規 (Claude Code Skill 案内)
- LanguagePicker コンポーネント新規 (設定ページ用ラジオ式)
合計 4 つの新規コンポーネント + 5 つの新規 page.tsx + layout.tsx + page.tsx 改修 = 11ファイル。Claude Code で 1セッション・2時間程度で完成しました。
トレードオフ:サイドバー型のデメリット
移行記事の信頼性のため、デメリットも明記します。
- モバイル時の取り扱い:
sm:ブレークポイントで サイドバーをhiddenに倒し、ヘッダーにメニューボタンを置く必要がある。本記事執筆時点では未対応で、現状はモバイル時にサイドバーが消える簡易実装 - 状態管理の分散: 5ページに分かれた結果、共通の「サイト数」や「プラン」表示は各ページで個別に取得することになる(Server Component なら遅延小だが、Client State なら工夫必要)
- 初期実装コストの上昇: 1ページなら 1ファイルで済んだ。5ページ+共通 layout で 11ファイル必要 — 機能数が少ない段階では明らかにオーバーキル
- 「ホームに何を載せるか」問題: オーバービュー設計を別途やる必要がある。1ページ完結時は「全部出す」で済んだが、ホーム画面の取捨選択は地味に難しい設計判断
- 後方互換 URL の管理: 既存
/dashboardブックマークは新ホーム画面で受けられる設計にしたが、将来サブパス(例:/dashboard/cli)を再構成するときは 301 リダイレクトの整備が必要
これらは「機能が3つを超えた段階では受け入れる価値があるコスト」だと考えています。逆にプロダクトが初期で機能が少ない時は、1ページ詰め込みのほうがむしろ正しい。
まとめ
Next.js App Router は SaaS のマルチページ移行を ディレクトリ作成だけ で実現できる強力な構造を持っています。Sticky サイドバーは top-14 + h-[calc(100vh-3.5rem)] でヘッダーと共存させ、usePathname でアクティブ状態を判定、Coming Soon は <div> + aria-disabled で予告ラベル化。LP と dashboard で同じ i18n 辞書を SSOT として使うことで価格改定の事故を防ぐ──。
これらの組み合わせで、「1ページ詰め込み」から「Supabase 風のスケーラブルな多階層 SaaS UI」への移行が、新規実装の障壁をほぼゼロにして実現できました。ただし上記のトレードオフを踏まえると、機能が3つを超えるタイミングが移行の最適時期と言えそうです。