用 Next.js App Router 将 SaaS 仪表板迁移到侧边栏多页面布局
- engineering
- nextjs
- app-router
- claude-code
TL;DR
- 问题:单页布局「什么都找不到」且「没地方加设置页」,已触及结构上限
- 解决方案:迁移到 Supabase、Vercel、Linear 都在用的侧边栏 + 多页面结构(5 个页面,约 2 小时工作量)
- 技术要点:新页面 = 在 Next.js App Router 中新建目录;多层固定侧边栏(
top-14+h-[calc(100vh-3.5rem)]);用usePathname检测激活状态;用<div> + aria-disabled实现即将上线项;一个在 LP 和仪表板间共享的 i18n 字典作为单一数据源- 权衡:移动端侧边栏抽屉设计、分布式状态管理、新页面更高的前期实现成本(详见下文)
为什么每家 SaaS 都用侧边栏 + 多页面布局
Supabase、Vercel、Linear、Notion、Stripe Dashboard——每个主流 SaaS 产品都用左侧边栏 + 多页面布局。这是解决两个相互竞争需求的标准方案:最小化导航成本,同时为未来功能留出结构空间。
「把所有东西堆在一页」的方式只能维持一段时间。一旦超过三个独立功能,结构性裂缝就开始显现。HeatMapX 撞上了这堵墙。
背景:单页布局的局限
很长时间里,HeatMapX 仪表板完全住在一个 /dashboard 页面上:
- 公告横幅
- 套餐使用卡片(PlanCard)
- 已注册站点列表
- CLI 安装指南(CliOnboarding)
- 入门指南
- 各种弹窗(OnboardingModal、UpgradeDialog、AddSiteDialog)
所有内容垂直堆叠。起步时还好,但随着功能累积,两个问题变得无法忽视:「不知道东西在哪里」,以及「没有合适的地方加设置页」。单页结构变成了一条死路。
解决方案:迁移到 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/* 共享(侧边栏 + 头部)
├── 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)和国际化(useLocale)都开箱即用。
固定头部 + 固定侧边栏:让两者共存
布局结构
// dashboard/layout.tsx
<div className="flex min-h-screen flex-col">
<header className="sticky top-0 z-30 h-14 ...">
{/* logo + 主题 + 语言 */}
</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 top-0 的侧边栏会滑到头部下面。解决方案:偏移头部的高度:
// 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(与头部高度一致)h-[calc(100vh-3.5rem)]从视口高度中减去头部——不加这个,侧边栏会溢出底部
flex-col + flex-1 overflow-y-auto + 底部 border-t 的组合将退出登录按钮固定在侧边栏底部。
💡 收获 1:叠加多个固定元素时,精确计算
top值。Tailwind 的top-14(56px)和h-[calc(100vh-3.5rem)]是配对使用的。如果之后修改了头部高度,两个值都要同步更新。
侧边栏中的激活状态检测
用 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 这样的详情页,侧边栏中的「热图」仍然保持高亮。
即将上线模式:展示尚未构建的功能
我们希望将 A/B 测试和动态 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:展示尚未构建的功能可以设定预期并建立期待感。侧边栏底部的两个即将上线项告诉用户「A/B 测试和动态 UI 即将到来」——无需维护任何额外内容。这个信号比路线图页面触达的人更多,因为每次打开应用都会看到它。
在 LP 和仪表板之间共享定价数据:以 i18n 字典作为单一数据源
定价套餐信息出现在两个地方:/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 引导登录;仪表板直接跳转结账)。更新定价只需改一个文件。
首页上的 Server Components 并行数据请求
首页需要获取多个聚合数值:
- 用户当前套餐
- 已注册站点数量
- 每月页面浏览量
- 每月 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,从设计之初就考虑并行化会立竿见影。
用颜色变化的进度条传递「危险」信号
进度条颜色根据使用百分比动态变化:
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 插件发布,所以 /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 用户都能从同一个页面找到各自的引导流程。
迁移步骤
- 创建 Sidebar 组件(5 个导航项 + 2 个即将上线项,内联 SVG 图标)
- 将 layout.tsx 重构为 flex 布局(头部 + 侧边栏 + 主内容区)
- 将站点列表提取到 /dashboard/sites(将 page.tsx 内容移至 sites/page.tsx)
- 将 /dashboard 重写为新首页(统计卡片 + 使用量进度条 + 快速操作)
- 添加 billing / cli / settings 页面(3 个新 page.tsx 文件)
- 创建 PricingPlans 组件(与 LP 共享数据,样式不同)
- 创建 SkillOnboarding 组件(Claude Code Skill 指南)
- 创建 LanguagePicker 组件(设置页的单选样式选择器)
合计:4 个新组件 + 5 个新 page.tsx 文件 + 更新 layout.tsx 和根 page.tsx = 11 个文件。在单次 Claude Code 会话中完成,约 2 小时。
权衡:侧边栏布局的缺点
为了让这篇迁移记录更有可信度,以下是真实的缺点。
- 移动端处理:在
sm:断点以下,侧边栏会隐藏,需要在头部加汉堡菜单。写作本文时这部分尚未完成——在移动端侧边栏直接消失,是个粗糙的临时状态。 - 分布式状态管理:数据分散在 5 个页面,像「站点数量」或「当前套餐」这样的共享值会在每个页面独立获取。使用 Server Components 时延迟成本很小,但如果依赖客户端状态,就需要更系统的方案。
- 更高的前期实现成本:以前一个页面对应一个文件。五个页面加上共享布局意味着 11 个文件。功能数量少时,这显然是过度设计。
- 「首页放什么」的问题:单页布局完全回避了这个问题——把所有东西都展示出来就行了。决定概览页上放什么,是一个比听起来更微妙的设计判断。
- 向后兼容的 URL 管理:我们将
/dashboard设计为仍然作为首页工作,所以现有书签不会失效。但如果未来子路径如/dashboard/cli需要重组,就需要正确设置 301 重定向。
我们的判断是:一旦拥有超过三个独立功能,这些成本就值得付出。反过来也同样成立——如果产品还处于早期、功能较少,单页布局可能才是正确选择。
总结
Next.js App Router 为多页面迁移提供了强大的结构——新页面就是新目录。通过 top-14 + h-[calc(100vh-3.5rem)],固定侧边栏和固定头部可以和谐共存。激活状态是一个 usePathname 检查。即将上线项用 <div> + aria-disabled 实现。用单个 i18n 字典作为 LP 和仪表板之间的单一数据源,可以防止定价更新事故。
这些模式合在一起,让从「一页堆到底」变成「Supabase 风格可扩展多层 SaaS UI」几乎没有新页面的准入门槛。话虽如此,上述权衡是真实存在的——这意味着迁移的正确时机是超过三个独立功能时,而不是更早。