HeatMapXHeatMapX
价格登录

用 Next.js App Router 将 SaaS 仪表板迁移到侧边栏多页面布局

HeatMapX Engineering Team22 min read
  • 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>

两个关键点:

  1. top-14 = 3.5rem = 56px(与头部高度一致)
  2. 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 用户都能从同一个页面找到各自的引导流程。

迁移步骤

  1. 创建 Sidebar 组件(5 个导航项 + 2 个即将上线项,内联 SVG 图标)
  2. 将 layout.tsx 重构为 flex 布局(头部 + 侧边栏 + 主内容区)
  3. 将站点列表提取到 /dashboard/sites(将 page.tsx 内容移至 sites/page.tsx)
  4. 将 /dashboard 重写为新首页(统计卡片 + 使用量进度条 + 快速操作)
  5. 添加 billing / cli / settings 页面(3 个新 page.tsx 文件)
  6. 创建 PricingPlans 组件(与 LP 共享数据,样式不同)
  7. 创建 SkillOnboarding 组件(Claude Code Skill 指南)
  8. 创建 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」几乎没有新页面的准入门槛。话虽如此,上述权衡是真实存在的——这意味着迁移的正确时机是超过三个独立功能时,而不是更早。

从 Claude Code 运行的热力图,免费开始。

粘贴一行追踪标签,从 CLI 获取分析和改进建议。