HeatMapXHeatMapX
PricingLog in

Migrating a SaaS Dashboard to a Sidebar Layout with Next.js App Router

HeatMapX Engineering Team15 min read
  • 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 with usePathname; 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:

  1. top-14 = 3.5rem = 56px (matches the header height)
  2. 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 top values precisely. Tailwind's top-14 (56px) and h-[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

  1. Create the Sidebar component (5 nav items + 2 Coming Soon items, inline SVG icons)
  2. Refactor layout.tsx to a flex layout (header + sidebar + main)
  3. Extract the sites list to /dashboard/sites (move page.tsx content to sites/page.tsx)
  4. Rewrite /dashboard as the new home screen (stats cards + usage bars + quick actions)
  5. Add billing / cli / settings pages (3 new page.tsx files)
  6. Create the PricingPlans component (shared data with LP, different styles)
  7. Create the SkillOnboarding component (Claude Code Skill guide)
  8. 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 /dashboard to still work as the home screen, so existing bookmarks aren't broken. But if sub-paths like /dashboard/cli get 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.

Heatmaps you run from Claude Code โ€” free to start.

Drop in one tracker tag. Analyze and ship CRO improvement PRs from the CLI. No credit card ยท 30-second setup.