Migrasi Dashboard SaaS ke Tata Letak Sidebar dengan Next.js App Router
- engineering
- nextjs
- app-router
- claude-code
TL;DR
- Masalah: Tata letak satu halaman di mana "tidak ada yang bisa ditemukan" dan "tidak ada ruang untuk menambah Settings" telah mencapai batasnya secara struktural
- Solusi: Migrasi ke struktur sidebar + multi-halaman yang digunakan Supabase, Vercel, dan Linear (5 halaman, ~2 jam pengerjaan)
- Teknik: Halaman baru = cukup menambahkan direktori di Next.js App Router; sticky multi-layer sidebar (
top-14+h-[calc(100vh-3.5rem)]); deteksi status aktif denganusePathname; item Coming Soon menggunakan<div> + aria-disabled; kamus i18n bersama sebagai SSOT antara LP dan dashboard- Tradeoff: Desain drawer sidebar mobile, manajemen state terdistribusi, biaya implementasi awal yang lebih tinggi untuk halaman baru (dibahas di bawah)
Mengapa Setiap SaaS Menggunakan Tata Letak Sidebar + Multi-Halaman
Supabase, Vercel, Linear, Notion, Stripe Dashboard โ setiap produk SaaS besar menggunakan tata letak sidebar kiri + multi-halaman. Ini adalah solusi standar untuk dua kebutuhan yang bersaing: meminimalkan biaya navigasi dan menyisakan ruang struktural untuk fitur di masa depan.
Pendekatan "tampilkan semuanya di satu tempat" hanya berhasil untuk waktu tertentu. Begitu Anda melewati tiga fitur yang berbeda, retakan struktural mulai terlihat. HeatMapX mencapai batas itu.
Latar Belakang: Keterbatasan Tata Letak Satu Halaman
Selama ini, dashboard HeatMapX sepenuhnya berada di satu halaman /dashboard:
- Banner pengumuman
- Kartu penggunaan paket (PlanCard)
- Daftar situs yang terdaftar
- Panduan instalasi CLI (CliOnboarding)
- Panduan Getting Started
- Berbagai modal (OnboardingModal, UpgradeDialog, AddSiteDialog)
Semuanya ditumpuk secara vertikal. Baik-baik saja di awal, tetapi seiring fitur bertambah, dua masalah menjadi tidak bisa diabaikan: "Saya tidak tahu di mana menemukan apapun," dan "tidak ada tempat yang bersih untuk menambah Settings." Struktur satu halaman telah menjadi jalan buntu secara struktural.
Solusinya: migrasi ke tata letak sidebar kiri + multi-halaman yang digunakan Supabase, Vercel, dan Linear.
Struktur Rute Akhir
| Rute | Konten |
|---|---|
/dashboard |
Home (ikhtisar: stats + penggunaan + quick actions) |
/dashboard/sites |
Daftar Heatmap (daftar situs) |
/dashboard/cli |
CLI & Skills (panduan setup CLI + panduan Claude Code Skill) |
/dashboard/billing |
Plan & Billing (ringkasan penggunaan saat ini + tabel perbandingan paket lengkap) |
/dashboard/settings |
Settings (pemilihan bahasa, dll.) |
Mengapa Next.js App Router Membuat Ini Mudah
Dengan App Router, struktur direktori Anda adalah struktur URL Anda. Menambahkan halaman baru berarti membuat folder dan file page.tsx. Hanya itu:
src/app/dashboard/
โโโ layout.tsx โ digunakan bersama di semua 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 halaman baru = 5 file baru + memperbarui layout.tsx bersama. Server-side rendering (Server Components), pemeriksaan auth (requireUser), dan i18n (useLocale) semuanya langsung berfungsi.
Sticky Header + Sticky Sidebar: Membuat Keduanya Berdampingan
Struktur tata letak
// dashboard/layout.tsx
<div className="flex min-h-screen flex-col">
<header className="sticky top-0 z-30 h-14 ...">
{/* logo + tema + lokal */}
</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>
Membuat sidebar menempel di bawah header
Sidebar dengan sticky top-0 akan masuk ke bawah header. Solusinya: geser sesuai tinggi 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">
{/* item menu */}
</nav>
<div className="border-t">
{/* logout */}
</div>
</aside>
Dua hal yang harus diperhatikan:
top-14= 3.5rem = 56px (sesuai tinggi header)h-[calc(100vh-3.5rem)]mengurangi header dari tinggi viewport โ tanpa ini, sidebar akan meluap ke bawah
Pola flex-col + flex-1 overflow-y-auto + border-t di bawah menyematkan tombol logout ke footer sidebar.
๐ก Pelajaran 1: Saat menumpuk beberapa elemen sticky, hitung nilai
topsecara akurat.top-14(56px) danh-[calc(100vh-3.5rem)]Tailwind adalah pasangan yang cocok. Jika Anda mengubah tinggi header di kemudian hari, perbarui kedua nilai secara bersamaan.
Deteksi Status Aktif di Sidebar
Gunakan usePathname untuk menyorot item menu yang sesuai dengan halaman saat ini:
'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 => {
// kecocokan tepat untuk /dashboard, kecocokan awalan untuk sisanya
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 menggunakan kesetaraan ketat (===); semua yang lain menggunakan startsWith. Ini berarti halaman detail seperti /dashboard/sites/abc123 tetap menyorot "Heatmaps" di sidebar.
Pola Coming Soon: Mengumumkan Fitur yang Belum Dibangun
Kami ingin menampilkan A/B Testing dan Dynamic UI sebagai "coming soon" โ terlihat, tetapi tidak bisa diklik. Solusinya: gunakan <div> dengan aria-disabled alih-alih <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>
))}
Ketika sebuah fitur diluncurkan, pindahkan entrinya dari comingSoonItems ke menuItems. Tata letak dan style digunakan ulang secara otomatis.
๐ก Pelajaran 2: Mengumumkan fitur yang belum dibangun menetapkan ekspektasi dan membangun antisipasi. Dua item Coming Soon di bagian bawah sidebar memberi tahu pengguna bahwa "A/B Testing dan Dynamic UI sedang dalam pengerjaan" โ tanpa konten tambahan yang perlu dikelola. Sinyal itu menjangkau lebih banyak orang daripada halaman roadmap manapun, karena muncul setiap kali seseorang membuka aplikasi.
Berbagi Data Harga antara LP dan Dashboard: Kamus i18n sebagai SSOT
Informasi paket harga muncul di dua tempat: LP di /en/pricing dan dashboard di /dashboard/billing. Mendefinisikan data di dua tempat berarti perubahan harga membutuhkan dua pembaruan โ dan cepat atau lambat, salah satunya akan terlewat.
Solusinya: jadikan kamus i18n sebagai Single Source of Truth. Baik LP maupun dashboard membaca dari t(locale).pricing.plans yang sama:
// 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>
))}
Struktur datanya identik. Hanya style (gelap vs. terang) dan perilaku CTA yang berbeda (LP meminta login; dashboard langsung ke checkout). Memperbarui harga adalah perubahan satu file.
Pengambilan Data Paralel dengan Server Components di Halaman Home
Halaman home perlu mengambil beberapa nilai agregat:
- Paket pengguna saat ini
- Jumlah situs yang terdaftar
- Tampilan halaman bulanan
- Penggunaan analisis AI bulanan
- Jumlah event hari ini (di semua situs)
Menunggu ini secara berurutan di Server Component itu lambat. Gunakan Promise.all untuk memparalelkan:
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),
])
// ...
}
Ini memotong waktu muat dari sekitar 1,5 detik menjadi ~600ms. Karena Next.js menyelesaikan semua await sebelum merender Server Component, merancang dengan paralelisasi dalam pikiran langsung terbayar.
Bilah Penggunaan Berkode Warna untuk Memberi Sinyal "Bahaya"
Warna bilah kemajuan berubah secara dinamis berdasarkan persentase penggunaan:
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>
)
}
Saat pengguna mendekati kuota mereka, perubahan warna mendorong mereka untuk melakukan upgrade. Menggunakan orange-500 sebagai warna "normal" juga menjaga warna merek HeatMapX selalu terlihat.
Memindahkan Logout dari Header ke Footer Sidebar
Implementasi awal memiliki tautan logout di pojok kanan atas header. Dengan sidebar terpasang, ia dipindahkan ke bagian bawah sidebar. Alasannya:
- Footer sidebar adalah lokasi standar untuk aksi "penting tapi jarang" โ Slack, Discord, dan Notion semuanya melakukan ini
- Header tetap fokus pada kontrol global (tema, bahasa) dan branding
- Mengurangi risiko logout tidak sengaja (pojok kanan atas adalah zona klik yang ramai)
Onboarding Claude Code Skill
HeatMapX diterbitkan sebagai plugin Claude Code, sehingga halaman /dashboard/cli perlu menampilkan CLI dan Skill secara berdampingan. Tata letak dua kolom di layar yang lebih besar:
// 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 menampilkan contoh perintah dalam bahasa Inggris dan Jepang:
// Contoh bahasa Inggris
"Analyze /pricing with HeatMapX and give me CRO ideas."
// Contoh bahasa Jepang
"HeatMapX ใง /pricing ใๅๆใใฆใๆนๅๆกใๆๆกใใฆใ"
Dengan cara ini, pengguna yang mengutamakan CLI dan pengguna Claude Code Skill sama-sama menemukan jalur onboarding mereka dari halaman yang sama.
Langkah-Langkah Migrasi
- Buat komponen Sidebar (5 item nav + 2 item Coming Soon, ikon SVG inline)
- Refactor layout.tsx ke tata letak flex (header + sidebar + main)
- Ekstrak daftar situs ke /dashboard/sites (pindahkan konten page.tsx ke sites/page.tsx)
- Tulis ulang /dashboard sebagai layar home baru (kartu stats + bilah penggunaan + quick actions)
- Tambahkan halaman billing / cli / settings (3 file page.tsx baru)
- Buat komponen PricingPlans (data bersama dengan LP, style berbeda)
- Buat komponen SkillOnboarding (panduan Claude Code Skill)
- Buat komponen LanguagePicker (selektor bergaya radio untuk halaman settings)
Total: 4 komponen baru + 5 file page.tsx baru + pembaruan pada layout.tsx dan page.tsx root = 11 file. Selesai dalam satu sesi Claude Code, sekitar 2 jam.
Tradeoff: Sisi Negatif Tata Letak Sidebar
Demi memberikan kredibilitas pada artikel migrasi ini, berikut adalah kekurangan yang jujur.
- Penanganan mobile: Di bawah breakpoint
sm:, sidebar disembunyikan dan Anda membutuhkan menu hamburger di header. Pada saat artikel ini ditulis, hal ini belum selesai โ di mobile sidebar hanya menghilang, yang merupakan kondisi sementara yang kasar. - Manajemen state terdistribusi: Dengan data yang tersebar di 5 halaman, nilai bersama seperti "jumlah situs" atau "paket saat ini" diambil secara independen di setiap halaman. Dengan Server Components biaya latensinya kecil, tetapi jika Anda mengandalkan state klien, Anda memerlukan pendekatan yang lebih disengaja.
- Biaya implementasi awal yang lebih tinggi: Satu halaman dulu berarti satu file. Lima halaman ditambah tata letak bersama berarti 11 file. Pada jumlah fitur yang rendah, ini jelas berlebihan.
- Masalah "apa yang ada di layar home?": Tata letak satu halaman sama sekali menghindari hal ini โ Anda hanya menampilkan semuanya. Memutuskan apa yang ada di layar ikhtisar adalah keputusan desain yang lebih halus dari yang terdengar.
- Manajemen URL yang kompatibel ke belakang: Kami merancang
/dashboardagar tetap berfungsi sebagai layar home, sehingga bookmark yang ada tidak rusak. Tetapi jika sub-path seperti/dashboard/clidiatur ulang di masa depan, pengalihan 301 perlu disiapkan dengan benar.
Pandangan kami: ini adalah biaya yang layak dibayar begitu Anda memiliki lebih dari tiga fitur yang berbeda. Dan sisi sebaliknya sama-sama benar โ jika produk Anda masih awal dan fiturnya sedikit, tata letak satu halaman mungkin adalah pilihan yang tepat.
Kesimpulan
Next.js App Router memberi Anda struktur yang kuat untuk migrasi multi-halaman โ halaman baru hanyalah direktori baru. Sticky sidebar berdampingan dengan sticky header via top-14 + h-[calc(100vh-3.5rem)]. Status aktif adalah pemeriksaan usePathname. Item Coming Soon adalah <div> + aria-disabled. Dan berbagi satu kamus i18n sebagai SSOT antara LP dan dashboard mencegah kesalahan pembaruan harga.
Bersama-sama, pola-pola ini memungkinkan transisi dari "tampilan satu halaman yang penuh sesak" ke "UI SaaS multi-tier yang skalabel ala Supabase" dengan hampir tanpa hambatan untuk halaman baru. Meski demikian, tradeoff di atas nyata โ yang berarti waktu yang tepat untuk migrasi adalah ketika Anda melewati tiga fitur yang berbeda, tidak lebih awal dari itu.