Migrating a SaaS Dashboard to a Sidebar Layout with Next.js App Router
- engineering
- nextjs
- app-router
- claude-code
TL;DR
- Problem: A single-page layout where "nothing is findable" and "there's no room to add Settings" had hit its structural limit
- Solution: Migrated to the sidebar + multi-page structure used by Supabase, Vercel, and Linear (5 pages, ~2 hours of work)
- Techniques: New pages = just adding directories in Next.js App Router; sticky multi-layer sidebar (
top-14+h-[calc(100vh-3.5rem)]); active state detection withusePathname; Coming Soon items using<div> + aria-disabled; a shared i18n dictionary as SSOT across the LP and dashboard- Tradeoffs: Mobile sidebar drawer design, distributed state management, higher upfront implementation cost for new pages (discussed below)
Why Every SaaS Uses a Sidebar + Multi-Page Layout
Supabase, Vercel, Linear, Notion, Stripe Dashboard โ every major SaaS product uses a left sidebar + multi-page layout. It's the standard solution for two competing needs: minimizing navigation cost and leaving structural room for future features.
A single-page "dump everything here" approach only works for so long. Once you cross three distinct features, structural cracks start to show. HeatMapX hit that wall.
Background: The Limits of a Single-Page Layout
For a long time, the HeatMapX dashboard lived entirely on a single /dashboard page:
- Announcement banner
- Plan usage card (PlanCard)
- Registered sites list
- CLI installation guide (CliOnboarding)
- Getting Started guide
- Various modals (OnboardingModal, UpgradeDialog, AddSiteDialog)
Everything stacked vertically. Fine at the start, but as features accumulated, two problems became impossible to ignore: "I can't tell where anything is," and "there's no clean place to add Settings." The single-page structure had become a structural dead end.
The fix: migrate to the left sidebar + multi-page layout that Supabase, Vercel, and Linear all use.
Final Route Structure
| Route | Content |
|---|---|
/dashboard |
Home (overview: stats + usage + quick actions) |
/dashboard/sites |
Heatmaps list (site list) |
/dashboard/cli |
CLI & Skills (CLI setup + Claude Code Skill guide) |
/dashboard/billing |
Plan & Billing (current usage + full plan comparison table) |
/dashboard/settings |
Settings (language selection, etc.) |
Why Next.js App Router Makes This Painless
With App Router, your directory structure is your URL structure. Adding a new page means creating a folder and a page.tsx file. That's it:
src/app/dashboard/
โโโ layout.tsx โ shared across all 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 new pages = 5 new files + updating the shared layout.tsx. Server-side rendering (Server Components), auth checks (requireUser), and i18n (useLocale) all just work.
Sticky Header + Sticky Sidebar: Making Them Coexist
Layout structure
// 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>
Getting the sidebar to stick below the header
A sidebar with sticky top-0 will slide under the header. The fix: offset it by the header's height:
// 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>
Two things to get right:
top-14= 3.5rem = 56px (matches the header height)h-[calc(100vh-3.5rem)]subtracts the header from the viewport height โ without this, the sidebar overflows the bottom
The flex-col + flex-1 overflow-y-auto + border-t at the bottom pattern pins the logout button to the footer of the sidebar.
๐ก Lesson 1: When stacking multiple sticky elements, calculate
topvalues precisely. Tailwind'stop-14(56px) andh-[calc(100vh-3.5rem)]are a matched pair. If you change the header height later, update both values in sync.
Active State Detection in the Sidebar
Use usePathname to highlight the menu item that corresponds to the current page:
'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 for /dashboard, prefix match for everything else
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 uses strict equality (===); everything else uses startsWith. This means a detail page like /dashboard/sites/abc123 keeps "Heatmaps" highlighted in the sidebar.
The Coming Soon Pattern: Announcing Unbuilt Features
We wanted to surface A/B Testing and Dynamic UI as "coming soon" โ visible, but not clickable. The solution: use <div> with aria-disabled instead of <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>
))}
When a feature ships, move its entry from comingSoonItems to menuItems. The layout and styles reuse automatically.
๐ก Lesson 2: Announcing unbuilt features sets expectations and builds anticipation. Two Coming Soon items at the bottom of the sidebar tell users "A/B Testing and Dynamic UI are on the way" โ with zero additional content to maintain. That signal reaches more people than a roadmap page ever will, because it shows up every time someone opens the app.
Sharing Pricing Data Between the LP and Dashboard: i18n Dictionary as SSOT
Pricing plan information appears in two places: the LP at /en/pricing and the dashboard at /dashboard/billing. Defining the data in two places means a price change requires two updates โ and sooner or later, one of them gets missed.
The fix: make the i18n dictionary the Single Source of Truth. Both the LP and the dashboard read from the same 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>
))}
The data structure is identical. Only the styles (dark vs. light) and the CTA behavior differ (LP prompts login; dashboard goes straight to checkout). Updating pricing is a one-file change.
Parallel Data Fetching with Server Components on the Home Page
The home page needs to fetch several aggregated values:
- User's current plan
- Number of registered sites
- Monthly page views
- Monthly AI analysis usage
- Today's event count (across all sites)
Awaiting these sequentially in a Server Component is slow. Use Promise.all to parallelize:
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),
])
// ...
}
This cuts load time from roughly 1.5s down to ~600ms. Because Next.js resolves all awaits before rendering the Server Component, designing with parallelization in mind pays off immediately.
Color-Coded Usage Bars to Signal "Danger"
Progress bar colors change dynamically based on the 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>
)
}
As users approach their quota, the color shift nudges them toward upgrading. Using orange-500 as the "normal" color also keeps HeatMapX's brand color in view at all times.
Moving Logout from the Header to the Sidebar Footer
The initial implementation had a logout link in the top-right corner of the header. With the sidebar in place, it moved to the sidebar's bottom section. Reasons:
- Sidebar footer is the standard location for "important but infrequent" actions โ Slack, Discord, and Notion all do this
- The header stays focused on global controls (theme, language) and branding
- Reduces accidental logout risk (the top-right corner is a high-traffic click zone)
Claude Code Skill Onboarding
HeatMapX is published as a Claude Code plugin, so the /dashboard/cli page needed to surface both the CLI and the Skill side by side. A two-column layout on larger screens:
// 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 shows sample commands in both English and Japanese:
// English example
"Analyze /pricing with HeatMapX and give me CRO ideas."
// Japanese example
"HeatMapX ใง /pricing ใๅๆใใฆใๆนๅๆกใๆๆกใใฆใ"
This way, CLI-first users and Claude Code Skill users both find their onboarding path from the same page.
Migration Steps
- Create the Sidebar component (5 nav items + 2 Coming Soon items, inline SVG icons)
- Refactor layout.tsx to a flex layout (header + sidebar + main)
- Extract the sites list to /dashboard/sites (move page.tsx content to sites/page.tsx)
- Rewrite /dashboard as the new home screen (stats cards + usage bars + quick actions)
- Add billing / cli / settings pages (3 new page.tsx files)
- Create the PricingPlans component (shared data with LP, different styles)
- Create the SkillOnboarding component (Claude Code Skill guide)
- Create the LanguagePicker component (radio-style selector for the settings page)
Total: 4 new components + 5 new page.tsx files + updates to layout.tsx and the root page.tsx = 11 files. Done in a single Claude Code session, roughly 2 hours.
Tradeoffs: The Downsides of a Sidebar Layout
In the interest of giving this migration write-up some credibility, here are the honest drawbacks.
- Mobile handling: Below the
sm:breakpoint, the sidebar is hidden and you need a hamburger menu in the header. At the time of writing this is unfinished โ on mobile the sidebar simply disappears, which is a rough interim state. - Distributed state management: With data spread across 5 pages, shared values like "site count" or "current plan" get fetched independently on each page. With Server Components the latency cost is small, but if you're relying on client state, you'll need a more deliberate approach.
- Higher upfront implementation cost: One page used to mean one file. Five pages plus a shared layout means 11 files. At low feature counts, this is clearly overkill.
- The "what goes on the home screen?" problem: A single-page layout sidesteps this entirely โ you just show everything. Deciding what belongs on the overview screen is a subtler design call than it sounds.
- Backward-compatible URL management: We designed
/dashboardto still work as the home screen, so existing bookmarks aren't broken. But if sub-paths like/dashboard/cliget reorganized in the future, 301 redirects will need to be set up properly.
Our take: these are costs worth paying once you have more than three distinct features. And the flip side is equally true โ if your product is still early and feature-light, a single-page layout is probably the right call.
Conclusion
Next.js App Router gives you a powerful structure for multi-page migrations โ new pages are just new directories. Sticky sidebars coexist with sticky headers via top-14 + h-[calc(100vh-3.5rem)]. Active state is a usePathname check. Coming Soon items are <div> + aria-disabled. And sharing a single i18n dictionary as SSOT between the LP and dashboard prevents pricing update accidents.
Together, these patterns make it possible to go from "single-page dump" to "Supabase-style scalable multi-tier SaaS UI" with almost no barrier to entry for new pages. That said, the tradeoffs above are real โ which means the right moment to migrate is when you cross three distinct features, not before.