HeatMapXHeatMapX
價格登入

用 Next.js App Router 將 SaaS 儀表板遷移至側邊欄佈局

HeatMapX Engineering Team23 min read
  • engineering
  • nextjs
  • app-router
  • claude-code

TL;DR

  • 問題:「什麼都找不到」且「沒有地方放 Settings」的單頁佈局已觸及結構極限
  • 解決方案:遷移至 Supabase、Vercel 和 Linear 採用的側邊欄 + 多頁架構(5 個頁面,約 2 小時工作量)
  • 技術手法:新頁面 = 只需在 Next.js App Router 中新增目錄;多層 sticky 側邊欄(top-14 + h-[calc(100vh-3.5rem)]);用 usePathname 偵測當前頁面;用 <div> + aria-disabled 實作 Coming Soon 項目;LP 與儀表板共用 i18n 字典作為單一真實來源(SSOT)
  • 取捨:行動版側邊欄抽屜設計、分散式狀態管理、新頁面較高的初期實作成本(詳見下文)

為何每個 SaaS 都使用側邊欄 + 多頁佈局

Supabase、Vercel、Linear、Notion、Stripe 儀表板——每個主要 SaaS 產品都使用左側邊欄 + 多頁佈局。這是解決兩個相互競爭需求的標準方案:降低導航成本,並為未來功能保留結構空間。

「把所有東西倒進來」的單頁做法只能維持一段時間。一旦超過三個不同的功能,結構裂痕就會開始顯現。HeatMapX 撞上了這道牆。

背景:單頁佈局的極限

很長一段時間,HeatMapX 儀表板完全住在單一的 /dashboard 頁面:

  • 公告橫幅
  • 方案用量卡片(PlanCard)
  • 已新增網站列表
  • CLI 安裝指南(CliOnboarding)
  • 入門指南
  • 各種模態視窗(OnboardingModal、UpgradeDialog、AddSiteDialog)

所有東西垂直堆疊。一開始沒問題,但隨著功能累積,兩個問題變得無法忽視:「找不到東西在哪裡」,以及「沒有乾淨的地方放 Settings」。單頁結構已成為結構性的死路。

解決方式:遷移至 Supabase、Vercel 和 Linear 都採用的左側邊欄 + 多頁佈局

最終路由結構

路由 內容
/dashboard Home(概覽:統計 + 用量 + 快速操作)
/dashboard/sites Heatmaps 列表(網站列表)
/dashboard/cli CLI & Skills(CLI 設定 + Claude Code Skill 指南)
/dashboard/billing Plan & Billing(當前用量 + 完整方案比較表)
/dashboard/settings Settings(語言選擇等)

為何 Next.js App Router 讓這件事輕而易舉

使用 App Router,你的目錄結構就是你的 URL 結構。新增頁面意味著建立資料夾和 page.tsx 檔案。就這樣:

src/app/dashboard/
├── layout.tsx              ← 所有 dashboard/* 共用(側邊欄 + header)
├── page.tsx                ← /dashboard(Home)
├── 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 側邊欄:讓它們共存

佈局結構

// 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>

讓側邊欄固定在 header 下方

帶有 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">
    {/* 選單項目 */}
  </nav>
  <div className="border-t">
    {/* 登出 */}
  </div>
</aside>

兩個需要注意的地方:

  1. top-14 = 3.5rem = 56px(與 header 高度相符)
  2. h-[calc(100vh-3.5rem)] 從視窗高度中減去 header——若沒有這個,側邊欄會超出底部

flex-col + flex-1 overflow-y-auto + 底部的 border-t 模式將登出按鈕固定在側邊欄底部。

💡 學習 1:堆疊多個 sticky 元素時,精確計算 top 值。Tailwind 的 top-14(56px)和 h-[calc(100vh-3.5rem)] 是一對。若之後更改 header 高度,請同步更新這兩個值。

側邊欄中的當前頁面偵測

使用 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 這樣的詳情頁面,側邊欄中的「Heatmaps」仍會保持高亮。

Coming Soon 模式:預告尚未開發的功能

我們想將 A/B Testing 和 Dynamic UI 呈現為「即將推出」——可見,但無法點擊。解決方式:使用帶有 aria-disabled<div> 而非 <Link>

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 項目告訴用戶「A/B Testing 和 Dynamic UI 即將到來」——完全不需要維護額外內容。這個訊號比任何路線圖頁面觸及的人都多,因為每次有人打開應用程式它都會出現。

在 LP 和儀表板之間共享定價數據:以 i18n 字典作為 SSOT

定價方案資訊出現在兩個地方:/en/pricing 的 LP 和 /dashboard/billing 的儀表板。在兩個地方定義數據意味著價格變更需要兩次更新——遲早其中一個會被遺漏。

解決方式:讓 i18n 字典成為單一真實來源。LP 和儀表板都從同一個 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>
))}

// 儀表板: src/app/dashboard/components/PricingPlans.tsx
const d = t(dashLocale).pricing
{d.plans.map(plan => (
  <article className="light-theme-styles">...</article>
))}

數據結構完全相同。只有樣式(深色 vs. 淺色)和 CTA 行為不同(LP 提示登入;儀表板直接前往結帳)。更新定價只需修改一個檔案。

在 Home 頁面使用 Server Components 並行獲取數據

Home 頁面需要獲取多個彙總值:

  • 用戶當前方案
  • 已新增網站數量
  • 每月頁面瀏覽量
  • 每月 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.5 秒縮短到約 600ms。由於 Next.js 在渲染 Server Component 前會解析所有 await,從一開始就以並行化為設計原則是立竿見影的。

以顏色編碼的用量進度條來示警「危險」

進度條顏色根據用量百分比動態變化:

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 的品牌色隨時可見。

將登出從 Header 移至側邊欄底部

初始實作將登出連結放在 header 右上角。有了側邊欄後,它移到了側邊欄的底部區域。原因:

  • 側邊欄底部是「重要但不常用」操作的標準位置——Slack、Discord 和 Notion 都這樣做
  • Header 聚焦於全域控制(主題、語言)和品牌
  • 降低誤觸登出的風險(右上角是高頻點擊區域)

Claude Code Skill 的 Onboarding

HeatMapX 以 Claude Code 插件形式發布,因此 /dashboard/cli 頁面需要並排呈現 CLI 和 Skill 兩種使用方式。在較大螢幕上採用雙欄佈局:

// 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 以英文和日文同時顯示範例指令:

// 英文範例
"Analyze /pricing with HeatMapX and give me CRO ideas."

// 日文範例
"HeatMapX で /pricing を分析して、改善案を提案して。"

這樣,以 CLI 為主的用戶和使用 Claude Code Skill 的用戶都能從同一頁面找到自己的 onboarding 路徑。

遷移步驟

  1. 建立 Sidebar 元件(5 個導航項目 + 2 個 Coming Soon 項目,使用行內 SVG 圖示)
  2. 重構 layout.tsx 為 flex 佈局(header + sidebar + main)
  3. 將網站列表提取至 /dashboard/sites(將 page.tsx 內容移至 sites/page.tsx)
  4. 將 /dashboard 改寫為新的 Home 畫面(統計卡片 + 用量進度條 + 快速操作)
  5. 新增 billing / cli / settings 頁面(3 個新的 page.tsx 檔案)
  6. 建立 PricingPlans 元件(與 LP 共享數據,不同樣式)
  7. 建立 SkillOnboarding 元件(Claude Code Skill 指南)
  8. 建立 LanguagePicker 元件(Settings 頁面的單選樣式選擇器)

合計:4 個新元件 + 5 個新的 page.tsx 檔案 + 更新 layout.tsx 和根目錄 page.tsx = 11 個檔案。在單一 Claude Code 工作階段中完成,約 2 小時。

取捨:側邊欄佈局的缺點

為了讓這篇遷移紀錄更有可信度,以下是誠實的缺點。

  • 行動版處理:在 sm: 斷點以下,側邊欄會被隱藏,你需要在 header 中放一個漢堡選單。撰寫本文時這部分尚未完成——在行動版上側邊欄直接消失,這是一個粗糙的過渡狀態。
  • 分散式狀態管理:數據分散在 5 個頁面,「網站數量」或「當前方案」等共用值會在每個頁面獨立獲取。使用 Server Components 時延遲成本很小,但如果依賴客戶端狀態,你需要更審慎的做法。
  • 較高的初期實作成本:以前一個頁面就是一個檔案。五個頁面加上共用 layout 意味著 11 個檔案。在功能數量少的時候,這顯然是過度工程。
  • 「Home 畫面要放什麼?」的問題:單頁佈局完全迴避了這個問題——只需展示所有東西。決定概覽畫面要放什麼,是一個比聽起來更微妙的設計決策。
  • 向後相容的 URL 管理:我們將 /dashboard 設計為仍作為 Home 畫面,所以現有書籤不會失效。但如果未來 /dashboard/cli 等子路徑被重組,就需要正確設定 301 重定向。

我們的看法:一旦你有超過三個不同的功能,這些成本是值得付出的。反過來同樣成立——如果你的產品還在早期且功能不多,單頁佈局可能才是正確的選擇

結論

Next.js App Router 為多頁遷移提供了強大的結構——新頁面就是新目錄。Sticky 側邊欄透過 top-14 + h-[calc(100vh-3.5rem)] 與 sticky header 共存。當前頁面狀態是一個 usePathname 檢查。Coming Soon 項目是 <div> + aria-disabled。而將單一 i18n 字典作為 LP 和儀表板之間的 SSOT,可以防止定價更新意外。

這些模式合在一起,讓你幾乎沒有任何障礙地從「單頁傾倒式」遷移到「Supabase 風格的可擴展多層 SaaS UI」。話雖如此,上述的取捨都是真實存在的——這意味著正確的遷移時機是當你有超過三個不同的功能時,而不是更早。

從 Claude Code 執行的熱圖,免費開始。

貼上一行追蹤標籤,從 CLI 取得分析與改善建議。