Next.js App Router で SaaS ダッシュボードをサイドバー型に移行した記録

ヒートマップエックス エンジニアチーム24分で読了
  • 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つ:

  1. top-14 = 3.5rem = 56px(ヘッダー高さに合わせる)
  2. 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 派」両方のユーザーが、同じページから自分の導入経路を選べます。

移行作業のステップ

  1. Sidebar コンポーネント新規作成 (5項目 + Coming Soon 2項目、inline SVG icon)
  2. layout.tsx を flex 構成に (header + sidebar + main)
  3. 既存サイト一覧を /dashboard/sites に切り出し (page.tsx 内容を sites/page.tsx へ移動)
  4. /dashboard を新ホーム画面に書き直し (統計カード + 使用量バー + クイックアクション)
  5. billing/cli/settings のページ追加 (新規 page.tsx 3つ)
  6. PricingPlans コンポーネント新規 (LP と data 共有、style だけ書き分け)
  7. SkillOnboarding コンポーネント新規 (Claude Code Skill 案内)
  8. 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つを超えるタイミングが移行の最適時期と言えそうです。

Claude Codeから動かすヒートマップを、まずは無料で。

計測タグを1行貼って、ブラウザ操作なしで分析・改善提案までCLIから受け取れます。クレカ不要・30秒でセットアップ。