Chuyển Dashboard SaaS Sang Sidebar Layout với Next.js App Router

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

TL;DR

  • Vấn đề: Layout một trang với "không tìm thấy gì được" và "không có chỗ để thêm Settings" đã chạm tới giới hạn cấu trúc
  • Giải pháp: Chuyển sang cấu trúc sidebar + nhiều trang như Supabase, Vercel, và Linear sử dụng (5 trang, ~2 tiếng làm việc)
  • Kỹ thuật: Trang mới = chỉ cần tạo thư mục trong Next.js App Router; sticky sidebar nhiều lớp (top-14 + h-[calc(100vh-3.5rem)]); phát hiện trạng thái active với usePathname; các mục Coming Soon dùng <div> + aria-disabled; một i18n dictionary dùng chung làm SSOT (Single Source of Truth) cho cả LP và dashboard
  • Đánh đổi: Thiết kế sidebar drawer cho mobile, quản lý state phân tán, chi phí triển khai ban đầu cao hơn cho trang mới (thảo luận bên dưới)

Tại Sao Mọi SaaS Đều Dùng Sidebar + Layout Nhiều Trang

Supabase, Vercel, Linear, Notion, Stripe Dashboard — mọi sản phẩm SaaS lớn đều dùng layout sidebar trái + nhiều trang. Đây là giải pháp tiêu chuẩn cho hai nhu cầu cạnh tranh nhau: tối thiểu hóa chi phí điều hướng và để lại không gian cấu trúc cho các tính năng trong tương lai.

Cách tiếp cận một trang "đổ tất cả vào đây" chỉ hoạt động được một thời gian. Khi bạn vượt qua ba tính năng riêng biệt, các vết nứt cấu trúc bắt đầu xuất hiện. HeatMapX đã chạm đến giới hạn đó.

Bối cảnh: Giới hạn của Layout Một Trang

Trong một thời gian dài, dashboard HeatMapX nằm hoàn toàn trên một trang /dashboard:

  • Banner thông báo
  • Card sử dụng gói (PlanCard)
  • Danh sách site đã đăng ký
  • Hướng dẫn cài đặt CLI (CliOnboarding)
  • Hướng dẫn Getting Started
  • Các modal khác nhau (OnboardingModal, UpgradeDialog, AddSiteDialog)

Mọi thứ xếp chồng theo chiều dọc. Ổn khi bắt đầu, nhưng khi tính năng tích lũy, hai vấn đề trở nên không thể bỏ qua: "Tôi không biết gì ở đâu cả," và "không có chỗ gọn gàng để thêm Settings." Cấu trúc một trang đã trở thành ngõ cụt cấu trúc.

Cách khắc phục: chuyển sang layout sidebar trái + nhiều trang mà Supabase, Vercel, và Linear đều sử dụng.

Cấu trúc Route Cuối cùng

Route Nội dung
/dashboard Home (tổng quan: thống kê + sử dụng + quick actions)
/dashboard/sites Danh sách Heatmaps (danh sách site)
/dashboard/cli CLI & Skills (cài đặt CLI + hướng dẫn Claude Code Skill)
/dashboard/billing Plan & Billing (tóm tắt sử dụng hiện tại + bảng so sánh gói đầy đủ)
/dashboard/settings Settings (chọn ngôn ngữ, v.v.)

Tại Sao Next.js App Router Làm Điều Này Dễ Dàng

Với App Router, cấu trúc thư mục của bạn chính là cấu trúc URL của bạn. Thêm trang mới chỉ cần tạo một thư mục và file page.tsx. Vậy thôi:

src/app/dashboard/
├── layout.tsx              ← dùng chung cho tất cả dashboard/* (sidebar + 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 trang mới = 5 file mới + cập nhật layout.tsx dùng chung. Server-side rendering (Server Components), kiểm tra auth (requireUser), và i18n (useLocale) đều hoạt động ngay.

Sticky Header + Sticky Sidebar: Làm Chúng Cùng Tồn Tại

Cấu trúc layout

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

Làm sidebar cố định bên dưới header

Sidebar với sticky top-0 sẽ trượt xuống dưới header. Cách khắc phục: dịch chuyển nó bằng chiều cao của 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>

Hai điều cần làm đúng:

  1. top-14 = 3.5rem = 56px (khớp với chiều cao header)
  2. h-[calc(100vh-3.5rem)] trừ header khỏi chiều cao viewport — không có điều này, sidebar sẽ tràn xuống dưới đáy

Pattern flex-col + flex-1 overflow-y-auto + border-t ở dưới cùng ghim nút logout vào phần footer của sidebar.

💡 Bài học 1: Khi xếp chồng nhiều phần tử sticky, tính toán các giá trị top chính xác. top-14 (56px) và h-[calc(100vh-3.5rem)] của Tailwind là một cặp ăn khớp. Nếu bạn thay đổi chiều cao header sau này, cập nhật cả hai giá trị đồng thời.

Phát Hiện Trạng Thái Active Trong Sidebar

Dùng usePathname để highlight mục menu tương ứng với trang hiện tại:

'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 => {
        // khớp chính xác cho /dashboard, khớp prefix cho mọi thứ khác
        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 dùng so sánh chính xác (===); mọi thứ khác dùng startsWith. Điều này có nghĩa là trang chi tiết như /dashboard/sites/abc123 vẫn giữ "Heatmaps" được highlight trong sidebar.

Pattern Coming Soon: Thông Báo Tính Năng Chưa Xây Dựng

Chúng tôi muốn hiển thị A/B Testing và Dynamic UI như "coming soon" — thấy được, nhưng không click được. Giải pháp: dùng <div> với aria-disabled thay vì <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>
))}

Khi tính năng ra mắt, chuyển mục đó từ comingSoonItems sang menuItems. Layout và style tái sử dụng tự động.

💡 Bài học 2: Thông báo tính năng chưa xây dựng đặt ra kỳ vọng và tạo ra sự mong đợi. Hai mục Coming Soon ở cuối sidebar cho người dùng biết "A/B Testing và Dynamic UI đang được phát triển" — mà không cần duy trì nội dung bổ sung. Tín hiệu đó tiếp cận nhiều người hơn bất kỳ trang roadmap nào, vì nó hiển thị mỗi lần ai đó mở ứng dụng.

Chia Sẻ Dữ Liệu Giá Giữa LP và Dashboard: i18n Dictionary Là SSOT

Thông tin gói giá xuất hiện ở hai nơi: LP tại /en/pricing và dashboard tại /dashboard/billing. Định nghĩa dữ liệu ở hai nơi có nghĩa là thay đổi giá đòi hỏi hai lần cập nhật — và sớm hay muộn, một trong số đó sẽ bị bỏ sót.

Cách khắc phục: biến i18n dictionary thành Single Source of Truth. Cả LP và dashboard đều đọc từ cùng 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>
))}

Cấu trúc dữ liệu hoàn toàn giống nhau. Chỉ có style (dark vs. light) và hành vi CTA khác nhau (LP nhắc đăng nhập; dashboard đi thẳng đến checkout). Cập nhật giá chỉ là thay đổi một file.

Fetch Dữ Liệu Song Song với Server Components Trên Trang Home

Trang home cần fetch nhiều giá trị tổng hợp:

  • Gói hiện tại của người dùng
  • Số site đã đăng ký
  • Lượt xem trang hàng tháng
  • Sử dụng phân tích AI hàng tháng
  • Số sự kiện hôm nay (trên tất cả các site)

Await các giá trị này tuần tự trong Server Component là chậm. Dùng Promise.all để song song hóa:

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),
  ])
  // ...
}

Điều này giảm thời gian tải từ khoảng 1.5s xuống ~600ms. Vì Next.js giải quyết tất cả các await trước khi render Server Component, việc thiết kế với song song hóa trong đầu mang lại hiệu quả ngay lập tức.

Thanh Tiến Độ Màu Sắc Để Báo Hiệu "Nguy Hiểm"

Màu thanh tiến độ thay đổi động dựa trên phần trăm sử dụng:

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>
  )
}

Khi người dùng tiếp cận hạn ngạch, sự thay đổi màu sắc thúc đẩy họ nâng cấp. Dùng orange-500 làm màu "bình thường" cũng giữ màu thương hiệu HeatMapX luôn hiện diện.

Di Chuyển Logout Từ Header Sang Footer Sidebar

Triển khai ban đầu có link logout ở góc trên bên phải của header. Với sidebar, nó chuyển sang phần dưới cùng của sidebar. Lý do:

  • Footer sidebar là vị trí tiêu chuẩn cho các hành động "quan trọng nhưng ít dùng" — Slack, Discord, và Notion đều làm vậy
  • Header tập trung vào các điều khiển toàn cục (theme, ngôn ngữ) và branding
  • Giảm nguy cơ vô tình đăng xuất (góc trên bên phải là vùng click nhiều)

Onboarding cho Claude Code Skill

HeatMapX được phát hành như một plugin Claude Code, vì vậy trang /dashboard/cli cần hiển thị cả CLI và Skill cạnh nhau. Layout hai cột trên màn hình lớn:

// 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 hiển thị các lệnh mẫu bằng cả tiếng Anh và tiếng Nhật:

// English example
"Analyze /pricing with HeatMapX and give me CRO ideas."

// Japanese example
"HeatMapX で /pricing を分析して、改善案を提案して。"

Theo cách này, người dùng CLI-first và người dùng Claude Code Skill đều tìm thấy đường onboarding của mình từ cùng một trang.

Các Bước Di Chuyển

  1. Tạo component Sidebar (5 mục nav + 2 mục Coming Soon, icon SVG inline)
  2. Tái cấu trúc layout.tsx sang flex layout (header + sidebar + main)
  3. Tách danh sách site sang /dashboard/sites (chuyển nội dung page.tsx sang sites/page.tsx)
  4. Viết lại /dashboard như màn hình home mới (stats card + thanh sử dụng + quick action)
  5. Thêm trang billing / cli / settings (3 file page.tsx mới)
  6. Tạo component PricingPlans (dữ liệu dùng chung với LP, style khác)
  7. Tạo component SkillOnboarding (hướng dẫn Claude Code Skill)
  8. Tạo component LanguagePicker (bộ chọn dạng radio cho trang settings)

Tổng cộng: 4 component mới + 5 file page.tsx mới + cập nhật layout.tsx và root page.tsx = 11 file. Hoàn thành trong một phiên Claude Code, khoảng 2 tiếng.

Đánh Đổi: Nhược Điểm của Sidebar Layout

Để bài viết về việc di chuyển này có sự tin cậy, đây là những hạn chế thực sự.

  • Xử lý mobile: Dưới breakpoint sm:, sidebar bị ẩn và bạn cần menu hamburger trong header. Tại thời điểm viết bài này, điều này chưa hoàn thành — trên mobile, sidebar đơn giản là biến mất, đây là trạng thái tạm thời khá thô.
  • Quản lý state phân tán: Với dữ liệu phân tán qua 5 trang, các giá trị dùng chung như "số site" hay "gói hiện tại" được fetch độc lập trên mỗi trang. Với Server Components, chi phí độ trễ nhỏ, nhưng nếu bạn dựa vào client state, bạn sẽ cần cách tiếp cận có chủ đích hơn.
  • Chi phí triển khai ban đầu cao hơn: Một trang trước đây chỉ cần một file. Năm trang cộng với shared layout có nghĩa là 11 file. Với số lượng tính năng thấp, điều này rõ ràng là quá mức cần thiết.
  • Vấn đề "cái gì thuộc về màn hình home?": Layout một trang bỏ qua điều này hoàn toàn — bạn chỉ cần hiển thị mọi thứ. Quyết định cái gì thuộc về màn hình tổng quan là quyết định thiết kế tinh tế hơn nhiều so với vẻ ngoài của nó.
  • Quản lý URL tương thích ngược: Chúng tôi thiết kế /dashboard vẫn hoạt động như màn hình home, để các bookmark hiện có không bị hỏng. Nhưng nếu các sub-path như /dashboard/cli được tổ chức lại trong tương lai, sẽ cần thiết lập redirect 301 đúng cách.

Quan điểm của chúng tôi: đây là những chi phí đáng trả một khi bạn có hơn ba tính năng riêng biệt. Và điều ngược lại cũng đúng — nếu sản phẩm của bạn vẫn còn sớm và ít tính năng, layout một trang có lẽ là lựa chọn đúng.

Kết luận

Next.js App Router cung cấp cấu trúc mạnh mẽ cho việc di chuyển nhiều trang — trang mới chỉ là thư mục mới. Sticky sidebar cùng tồn tại với sticky header qua top-14 + h-[calc(100vh-3.5rem)]. Trạng thái active là một kiểm tra usePathname. Mục Coming Soon là <div> + aria-disabled. Và chia sẻ một i18n dictionary làm SSOT giữa LP và dashboard ngăn chặn các sự cố cập nhật giá.

Cùng nhau, các pattern này làm cho việc đi từ "bãi chứa một trang" sang "UI SaaS đa tầng có thể mở rộng kiểu Supabase" gần như không có rào cản nào khi thêm trang mới. Tuy nhiên, các đánh đổi ở trên là thực tế — nghĩa là thời điểm đúng để di chuyển là khi bạn vượt qua ba tính năng riêng biệt, không phải trước đó.

Heatmap chạy từ Claude Code — bắt đầu miễn phí.

Dán một tag tracker, nhận phân tích và đề xuất CRO từ CLI.