ย้าย Dashboard SaaS ไปใช้ Sidebar Layout ด้วย Next.js App Router
- engineering
- nextjs
- app-router
- claude-code
TL;DR
- ปัญหา: Layout หน้าเดียวที่ "หาอะไรไม่เจอ" และ "ไม่มีที่ใส่ Settings" ถึงขีดจำกัดเชิงโครงสร้างแล้ว
- ทางออก: ย้ายมาใช้โครงสร้าง sidebar + multi-page แบบที่ Supabase, Vercel และ Linear ใช้ (5 หน้า ใช้เวลาประมาณ 2 ชั่วโมง)
- เทคนิค: หน้าใหม่ = แค่สร้าง directory ใน Next.js App Router; sticky sidebar หลายชั้น (
top-14+h-[calc(100vh-3.5rem)]); ตรวจจับ active state ด้วยusePathname; Coming Soon item ใช้<div> + aria-disabled; i18n dictionary เดียวเป็น SSOT ข้ามทั้ง LP และ dashboard- Tradeoff: การออกแบบ mobile sidebar drawer, การจัดการ distributed state, ต้นทุนเริ่มต้นที่สูงกว่าสำหรับหน้าใหม่ (อธิบายด้านล่าง)
ทำไม SaaS ทุกเจ้าถึงใช้ Sidebar + Multi-Page Layout
Supabase, Vercel, Linear, Notion, Stripe Dashboard — ทุก SaaS รายใหญ่ใช้ left sidebar + multi-page layout นี่คือวิธีแก้มาตรฐานสำหรับความต้องการที่ขัดแย้งกันสองอย่าง: ลดต้นทุน navigation และเปิดพื้นที่เชิงโครงสร้างสำหรับฟีเจอร์ในอนาคต
แนวทาง "ยัดทุกอย่างในหน้าเดียว" ทำงานได้แค่ชั่วระยะหนึ่ง พอฟีเจอร์ที่แตกต่างกันมีมากกว่า 3 อย่าง รอยร้าวเชิงโครงสร้างก็เริ่มปรากฏ HeatMapX ชนกำแพงนั้นแล้ว
บริบท: ข้อจำกัดของ Single-Page Layout
นานมาแล้ว HeatMapX dashboard อาศัยอยู่ทั้งหมดบนหน้า /dashboard เดียว:
- แบนเนอร์ประกาศ
- Plan usage card (PlanCard)
- รายการเว็บที่ลงทะเบียน
- คู่มือติดตั้ง CLI (CliOnboarding)
- คู่มือ Getting Started
- Modal ต่าง ๆ (OnboardingModal, UpgradeDialog, AddSiteDialog)
ทุกอย่างซ้อนแนวตั้ง ใช้ได้ในช่วงแรก แต่เมื่อฟีเจอร์เพิ่มขึ้น ปัญหาสองอย่างกลายเป็นสิ่งที่เพิกเฉยไม่ได้: "หาอะไรไม่เจอเลย" และ "ไม่มีที่ใส่ Settings อย่างเป็นระเบียบ" โครงสร้างหน้าเดียวกลายเป็นทางตันเชิงโครงสร้าง
ทางออก: ย้ายมาใช้ left sidebar + multi-page layout แบบที่ Supabase, Vercel และ Linear ใช้
โครงสร้าง Route สุดท้าย
| Route | เนื้อหา |
|---|---|
/dashboard |
Home (ภาพรวม: stats + usage + quick actions) |
/dashboard/sites |
Heatmaps list (รายการเว็บ) |
/dashboard/cli |
CLI & Skills (ตั้งค่า CLI + คู่มือ Claude Code Skill) |
/dashboard/billing |
Plan & Billing (สรุป usage ปัจจุบัน + ตารางเปรียบเทียบ plan เต็มรูปแบบ) |
/dashboard/settings |
Settings (เลือกภาษา ฯลฯ) |
ทำไม Next.js App Router ถึงทำให้เรื่องนี้ง่ายมาก
กับ App Router โครงสร้าง directory คือโครงสร้าง URL การเพิ่มหน้าใหม่หมายถึง สร้าง folder และไฟล์ page.tsx แค่นั้น:
src/app/dashboard/
├── layout.tsx ← ใช้ร่วมกันทั้งหมดใน 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 หน้าใหม่ = 5 ไฟล์ใหม่ + อัปเดต layout.tsx ที่ใช้ร่วมกัน Server-side rendering (Server Components), การตรวจสอบ auth (requireUser) และ i18n (useLocale) ทำงานได้เลย
Sticky Header + Sticky Sidebar: ทำให้อยู่ร่วมกันได้
โครงสร้าง 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>
ทำให้ sidebar ติดอยู่ใต้ header
sidebar ที่มี sticky top-0 จะเลื่อนลอดใต้ header วิธีแก้: offset มันด้วยความสูงของ 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>
สองสิ่งที่ต้องทำให้ถูกต้อง:
top-14= 3.5rem = 56px (ตรงกับความสูง header)h-[calc(100vh-3.5rem)]หัก header ออกจากความสูง viewport — ถ้าไม่มีสิ่งนี้ sidebar จะล้นออกด้านล่าง
Pattern flex-col + flex-1 overflow-y-auto + border-t ด้านล่างล็อกปุ่ม logout ไว้ที่ footer ของ sidebar
💡 บทเรียนที่ 1: เมื่อซ้อน sticky element หลายชั้น คำนวณค่า
topให้แม่นยำtop-14(56px) และh-[calc(100vh-3.5rem)]ของ Tailwind เป็นคู่ที่จับคู่กัน ถ้าเปลี่ยนความสูง header ในภายหลัง ต้องอัปเดตทั้งสองค่าพร้อมกัน
การตรวจจับ Active State ใน Sidebar
ใช้ usePathname เพื่อ highlight รายการ menu ที่ตรงกับหน้าปัจจุบัน:
'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 => {
// exact match สำหรับ /dashboard, prefix match สำหรับที่เหลือ
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 ใช้ strict equality (===); ที่เหลือใช้ startsWith หมายความว่าหน้า detail เช่น /dashboard/sites/abc123 จะยังคง highlight "Heatmaps" ใน sidebar
Coming Soon Pattern: ประกาศฟีเจอร์ที่ยังสร้างไม่เสร็จ
เราต้องการแสดง A/B Testing และ Dynamic UI เป็น "coming soon" — มองเห็นได้แต่คลิกไม่ได้ วิธีแก้: ใช้ <div> กับ aria-disabled แทน <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>
))}
เมื่อฟีเจอร์ ship แล้ว ย้าย entry นั้นจาก comingSoonItems ไปยัง menuItems layout และ style ถูก reuse อัตโนมัติ
💡 บทเรียนที่ 2: การประกาศฟีเจอร์ที่ยังสร้างไม่เสร็จ ช่วยตั้งความคาดหวังและสร้างความคาดหมาย Coming Soon สองรายการที่ด้านล่าง sidebar บอก user ว่า "A/B Testing และ Dynamic UI กำลังมา" — โดยไม่ต้องดูแล content เพิ่มเติมเลย สัญญาณนี้เข้าถึงคนได้มากกว่า roadmap page เพราะมันแสดงทุกครั้งที่ user เปิดแอป
แชร์ข้อมูล Pricing ระหว่าง LP และ Dashboard: i18n Dictionary เป็น SSOT
ข้อมูล pricing plan ปรากฏสองที่: LP ที่ /en/pricing และ dashboard ที่ /dashboard/billing การกำหนดข้อมูลสองที่หมายความว่าการเปลี่ยนราคาต้องอัปเดตสองครั้ง — และช้าหรือเร็วหนึ่งในนั้นจะถูกพลาดไป
วิธีแก้: ทำให้ i18n dictionary เป็น Single Source of Truth ทั้ง LP และ dashboard อ่านจาก 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>
))}
โครงสร้างข้อมูลเหมือนกันทุกประการ มีแค่ style (dark vs. light) และพฤติกรรม CTA ที่ต่างกัน (LP ให้ login; dashboard ไปที่ checkout ทันที) การอัปเดตราคาเป็นการเปลี่ยนแค่ไฟล์เดียว
Parallel Data Fetching ด้วย Server Components บน Home Page
Home page ต้องดึงค่ารวมหลายอย่าง:
- Plan ปัจจุบันของ user
- จำนวนเว็บที่ลงทะเบียน
- Page view รายเดือน
- AI analysis usage รายเดือน
- จำนวน event ของวันนี้ (รวมทุกเว็บ)
การรอ fetch แบบลำดับใน Server Component ช้า ใช้ Promise.all เพื่อทำ parallel:
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),
])
// ...
}
วิธีนี้ลดเวลา load จากประมาณ 1.5 วินาทีเหลือ ~600ms เพราะ Next.js resolve await ทั้งหมดก่อน render Server Component การออกแบบโดยคำนึงถึง parallelization จึงให้ผลทันที
Progress Bar สีตาม "ระดับอันตราย"
สี progress bar เปลี่ยนแบบ dynamic ตาม usage percentage:
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>
)
}
เมื่อ user ใกล้ถึง quota สีที่เปลี่ยนไปจะกระตุ้นให้ upgrade การใช้ orange-500 เป็นสี "ปกติ" ยังทำให้สีแบรนด์ HeatMapX ปรากฏอยู่ตลอดเวลา
ย้าย Logout จาก Header ไปยัง Sidebar Footer
การ implement ตั้งต้นมีลิงก์ logout ที่มุมขวาบนของ header พอมี sidebar แล้ว มันถูกย้ายไปยัง ส่วนล่างของ sidebar เหตุผล:
- Sidebar footer คือตำแหน่งมาตรฐานสำหรับ action "สำคัญแต่ไม่บ่อย" — Slack, Discord และ Notion ทำแบบนี้หมด
- Header ยังคง focus อยู่ที่ global controls (theme, language) และ branding
- ลดความเสี่ยงจากการ logout โดยไม่ตั้งใจ (มุมขวาบนเป็นโซน click ที่คึกคักมาก)
Claude Code Skill Onboarding
HeatMapX เผยแพร่เป็น Claude Code plugin ดังนั้นหน้า /dashboard/cli ต้องแสดงทั้ง CLI และ Skill ควบคู่กัน ใช้ two-column layout บนจอใหญ่:
// 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 แสดงคำสั่งตัวอย่างทั้งภาษาอังกฤษและญี่ปุ่น:
// English example
"Analyze /pricing with HeatMapX and give me CRO ideas."
// Japanese example
"HeatMapX で /pricing を分析して、改善案を提案して。"
ด้วยวิธีนี้ user ที่ใช้ CLI-first และ user ที่ใช้ Claude Code Skill ต่างก็หาทาง onboarding ของตัวเองได้จากหน้าเดียวกัน
ขั้นตอนการ Migration
- สร้าง Sidebar component (5 nav item + 2 Coming Soon item, inline SVG icon)
- Refactor layout.tsx เป็น flex layout (header + sidebar + main)
- แยก sites list ไปยัง /dashboard/sites (ย้ายเนื้อหา page.tsx ไปยัง sites/page.tsx)
- เขียน /dashboard ใหม่เป็น home screen (stats card + usage bar + quick action)
- เพิ่มหน้า billing / cli / settings (3 ไฟล์ page.tsx ใหม่)
- สร้าง PricingPlans component (ข้อมูลร่วมกับ LP, style ต่างกัน)
- สร้าง SkillOnboarding component (คู่มือ Claude Code Skill)
- สร้าง LanguagePicker component (selector แบบ radio สำหรับหน้า settings)
รวม: 4 component ใหม่ + 5 ไฟล์ page.tsx ใหม่ + อัปเดต layout.tsx และ root page.tsx = 11 ไฟล์ ทำเสร็จในเซสชัน Claude Code เดียว ประมาณ 2 ชั่วโมง
Tradeoff: ข้อเสียของ Sidebar Layout
เพื่อให้บทความ migration นี้น่าเชื่อถือ ต่อไปนี้คือข้อเสียที่พูดตรง ๆ
- การจัดการ mobile: ต่ำกว่า breakpoint
sm:sidebar จะซ่อนและต้องมี hamburger menu ใน header ณ เวลาที่เขียนบทความนี้ยังไม่เสร็จ — บน mobile sidebar แค่หายไป ซึ่งเป็นสภาพชั่วคราวที่ยังหยาบอยู่ - Distributed state management: ข้อมูลกระจายอยู่ใน 5 หน้า ค่าที่ใช้ร่วมกันเช่น "site count" หรือ "current plan" จะถูก fetch อิสระในแต่ละหน้า กับ Server Components ต้นทุน latency เล็กน้อย แต่ถ้าพึ่ง client state จะต้องใช้แนวทางที่มีการวางแผนมากขึ้น
- ต้นทุนเริ่มต้นที่สูงกว่า: หน้าเดียวเคยหมายถึงไฟล์เดียว ห้าหน้าบวก shared layout หมายถึง 11 ไฟล์ สำหรับจำนวนฟีเจอร์น้อย ๆ นี่เกินจำเป็นอย่างชัดเจน
- ปัญหา "อะไรควรอยู่บน home screen?": Single-page layout ข้ามปัญหานี้ไปเลย — แสดงทุกอย่างก็พอ การตัดสินใจว่าอะไรควรอยู่บน overview screen เป็น design call ที่ซับซ้อนกว่าที่ฟังดู
- การจัดการ URL ที่ backward-compatible: เราออกแบบให้
/dashboardยังทำงานเป็น home screen ดังนั้น bookmark เดิมไม่พัง แต่ถ้า sub-path เช่น/dashboard/cliถูกจัดใหม่ในอนาคต จะต้องตั้งค่า 301 redirect อย่างถูกต้อง
มุมมองของเรา: นี่คือต้นทุนที่คุ้มค่าเมื่อมีฟีเจอร์ที่แตกต่างกันมากกว่า 3 อย่าง และด้านกลับก็เป็นจริงเช่นกัน — ถ้า product ยังอยู่ในช่วงแรกและมีฟีเจอร์น้อย single-page layout น่าจะเป็นตัวเลือกที่ถูกต้อง
สรุป
Next.js App Router ให้โครงสร้างที่ทรงพลังสำหรับการ migrate แบบ multi-page — หน้าใหม่คือแค่ directory ใหม่ Sticky sidebar อยู่ร่วมกับ sticky header ได้ด้วย top-14 + h-[calc(100vh-3.5rem)] Active state คือการตรวจ usePathname Coming Soon item คือ <div> + aria-disabled และการแชร์ i18n dictionary เดียวเป็น SSOT ระหว่าง LP กับ dashboard ป้องกันอุบัติเหตุในการอัปเดตราคา
Pattern เหล่านี้รวมกันทำให้ไปจาก "single-page dump" สู่ "Supabase-style scalable multi-tier SaaS UI" ได้โดยแทบไม่มีอุปสรรคสำหรับหน้าใหม่ แต่ tradeoff ข้างต้นเป็นเรื่องจริง — ซึ่งหมายความว่า เวลาที่เหมาะสมในการ migrate คือเมื่อมีฟีเจอร์ที่แตกต่างกันมากกว่า 3 อย่าง ไม่ใช่ก่อนหน้านั้น