ย้าย Dashboard SaaS ไปใช้ Sidebar Layout ด้วย Next.js App Router

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

สองสิ่งที่ต้องทำให้ถูกต้อง:

  1. top-14 = 3.5rem = 56px (ตรงกับความสูง header)
  2. 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

  1. สร้าง Sidebar component (5 nav item + 2 Coming Soon item, inline SVG icon)
  2. Refactor layout.tsx เป็น flex layout (header + sidebar + main)
  3. แยก sites list ไปยัง /dashboard/sites (ย้ายเนื้อหา page.tsx ไปยัง sites/page.tsx)
  4. เขียน /dashboard ใหม่เป็น home screen (stats card + usage bar + quick action)
  5. เพิ่มหน้า billing / cli / settings (3 ไฟล์ page.tsx ใหม่)
  6. สร้าง PricingPlans component (ข้อมูลร่วมกับ LP, style ต่างกัน)
  7. สร้าง SkillOnboarding component (คู่มือ Claude Code Skill)
  8. สร้าง 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 อย่าง ไม่ใช่ก่อนหน้านั้น

Heatmap จาก Claude Code — เริ่มฟรี

วางแท็ก tracker หนึ่งบรรทัด รับการวิเคราะห์และข้อเสนอ CRO จาก CLI