用 Next.js App Router 將 SaaS 儀表板遷移至側邊欄佈局
- 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>
兩個需要注意的地方:
top-14= 3.5rem = 56px(與 header 高度相符)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 路徑。
遷移步驟
- 建立 Sidebar 元件(5 個導航項目 + 2 個 Coming Soon 項目,使用行內 SVG 圖示)
- 重構 layout.tsx 為 flex 佈局(header + sidebar + main)
- 將網站列表提取至 /dashboard/sites(將 page.tsx 內容移至 sites/page.tsx)
- 將 /dashboard 改寫為新的 Home 畫面(統計卡片 + 用量進度條 + 快速操作)
- 新增 billing / cli / settings 頁面(3 個新的 page.tsx 檔案)
- 建立 PricingPlans 元件(與 LP 共享數據,不同樣式)
- 建立 SkillOnboarding 元件(Claude Code Skill 指南)
- 建立 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」。話雖如此,上述的取捨都是真實存在的——這意味著正確的遷移時機是當你有超過三個不同的功能時,而不是更早。