Migration eines SaaS-Dashboards auf ein Sidebar-Layout mit Next.js App Router

HeatMapX Engineering Team15 min read
  • engineering
  • nextjs
  • app-router
  • claude-code

TL;DR

  • Problem: Ein einseitiges Layout, bei dem „nichts auffindbar ist" und „kein Platz für Settings ist", hatte seine strukturelle Grenze erreicht
  • Lösung: Migration zur Sidebar + Multi-Page-Struktur, wie sie Supabase, Vercel und Linear verwenden (5 Seiten, ~2 Stunden Arbeit)
  • Techniken: Neue Seiten = einfach Verzeichnisse im Next.js App Router anlegen; sticky mehrstufige Sidebar (top-14 + h-[calc(100vh-3.5rem)]); Active-State-Erkennung mit usePathname; Coming-Soon-Einträge mit <div> + aria-disabled; ein gemeinsames i18n-Dictionary als SSOT für LP und Dashboard
  • Kompromisse: Mobile-Sidebar-Drawer-Design, verteiltes State-Management, höhere initiale Implementierungskosten für neue Seiten (wird unten besprochen)

Warum jedes SaaS ein Sidebar + Multi-Page-Layout verwendet

Supabase, Vercel, Linear, Notion, Stripe Dashboard — jedes große SaaS-Produkt setzt auf ein linkes Sidebar + Multi-Page-Layout. Es ist die Standardlösung für zwei konkurrierende Anforderungen: Navigationsaufwand minimieren und strukturellen Raum für zukünftige Features lassen.

Ein einseitiger „Alles-auf-einmal"-Ansatz funktioniert nur so lange. Sobald man drei verschiedene Features überschreitet, zeigen sich strukturelle Risse. HeatMapX hat diese Grenze erreicht.

Hintergrund: Die Grenzen eines einseitigen Layouts

Lange Zeit lebte das HeatMapX-Dashboard vollständig auf einer einzigen /dashboard-Seite:

  • Ankündigungs-Banner
  • Plan-Nutzungskarte (PlanCard)
  • Liste registrierter Sites
  • CLI-Installationsanleitung (CliOnboarding)
  • Getting-Started-Anleitung
  • Verschiedene Modals (OnboardingModal, UpgradeDialog, AddSiteDialog)

Alles vertikal gestapelt. Am Anfang in Ordnung, aber als Features hinzukamen, wurden zwei Probleme unübersehbar: „Ich kann nicht sagen, wo irgendetwas ist" und „es gibt keinen sauberen Ort, Settings hinzuzufügen." Die einseitige Struktur war zu einer strukturellen Sackgasse geworden.

Die Lösung: Migration zum linken Sidebar + Multi-Page-Layout, das Supabase, Vercel und Linear alle verwenden.

Finale Route-Struktur

Route Inhalt
/dashboard Home (Übersicht: Stats + Nutzung + Quick Actions)
/dashboard/sites Heatmap-Liste (Site-Liste)
/dashboard/cli CLI & Skills (CLI-Setup + Claude Code Skill-Anleitung)
/dashboard/billing Plan & Billing (aktuelle Nutzungsübersicht + vollständige Planvergleichstabelle)
/dashboard/settings Settings (Sprachauswahl usw.)

Warum Next.js App Router das einfach macht

Mit App Router ist die Verzeichnisstruktur die URL-Struktur. Eine neue Seite hinzufügen bedeutet einen Ordner und eine page.tsx-Datei anlegen. Das war's:

src/app/dashboard/
├── layout.tsx              ← gemeinsam für alle 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 neue Seiten = 5 neue Dateien + Aktualisierung der gemeinsamen layout.tsx. Server-seitiges Rendering (Server Components), Auth-Prüfungen (requireUser) und i18n (useLocale) funktionieren einfach.

Sticky Header + Sticky Sidebar: Koexistenz herstellen

Layout-Struktur

// 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>

Die Sidebar unter dem Header einrasten lassen

Eine Sidebar mit sticky top-0 schiebt sich unter den Header. Die Lösung: um die Header-Höhe versetzen:

// 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">
    {/* Menüeinträge */}
  </nav>
  <div className="border-t">
    {/* Logout */}
  </div>
</aside>

Zwei Dinge, die stimmen müssen:

  1. top-14 = 3,5rem = 56px (entspricht der Header-Höhe)
  2. h-[calc(100vh-3.5rem)] zieht den Header von der Viewport-Höhe ab — ohne das läuft die Sidebar unten über

Das Muster flex-col + flex-1 overflow-y-auto + border-t am Ende pinnt den Logout-Button an den Fußbereich der Sidebar.

💡 Erkenntnis 1: Beim Stapeln mehrerer Sticky-Elemente top-Werte präzise berechnen. Tailwinds top-14 (56px) und h-[calc(100vh-3.5rem)] sind ein aufeinander abgestimmtes Paar. Wenn sich die Header-Höhe später ändert, beide Werte synchron aktualisieren.

Active-State-Erkennung in der Sidebar

usePathname verwenden, um den Menüeintrag für die aktuelle Seite hervorzuheben:

'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 => {
        // exakter Match für /dashboard, Prefix-Match für alles andere
        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 verwendet striktes Gleichheitszeichen (===); alles andere startsWith. So bleibt bei einer Detailseite wie /dashboard/sites/abc123 „Heatmaps" in der Sidebar hervorgehoben.

Das Coming-Soon-Muster: Noch nicht gebaute Features ankündigen

A/B Testing und Dynamic UI sollten als „Coming Soon" sichtbar, aber nicht klickbar sein. Die Lösung: <div> mit aria-disabled statt <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>
))}

Wenn ein Feature fertig ist, den Eintrag von comingSoonItems nach menuItems verschieben. Layout und Styles werden automatisch wiederverwendet.

💡 Erkenntnis 2: Nicht gebaute Features ankündigen setzt Erwartungen und baut Vorfreude auf. Zwei Coming-Soon-Einträge am Ende der Sidebar sagen Nutzern „A/B Testing und Dynamic UI kommen" — ohne zusätzliche Inhalte zu pflegen. Dieses Signal erreicht mehr Menschen als eine Roadmap-Seite je wird, weil es jedes Mal erscheint, wenn jemand die App öffnet.

Pricing-Daten zwischen LP und Dashboard teilen: i18n-Dictionary als SSOT

Preisplan-Informationen erscheinen an zwei Stellen: auf der LP unter /en/pricing und im Dashboard unter /dashboard/billing. Die Daten an zwei Stellen zu definieren bedeutet, dass eine Preisänderung zwei Updates erfordert — und früher oder später wird eines davon vergessen.

Die Lösung: das i18n-Dictionary zur Single Source of Truth machen. Sowohl LP als auch Dashboard lesen aus demselben 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>
))}

Die Datenstruktur ist identisch. Nur Styles (dunkel vs. hell) und CTA-Verhalten unterscheiden sich (LP fordert Login auf; Dashboard geht direkt zum Checkout). Pricing-Updates sind einmalige Dateiänderungen.

Paralleles Datenladen mit Server Components auf der Startseite

Die Startseite muss mehrere aggregierte Werte laden:

  • Aktueller Plan des Nutzers
  • Anzahl registrierter Sites
  • Monatliche Seitenaufrufe
  • Monatliche KI-Analysenutzung
  • Heutige Event-Anzahl (über alle Sites)

Diese sequenziell in einer Server Component abzuwarten ist langsam. Promise.all verwenden, um zu parallelisieren:

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),
  ])
  // ...
}

Das reduziert die Ladezeit von etwa 1,5s auf ~600ms. Da Next.js alle Awaits vor dem Rendern der Server Component auflöst, zahlt sich das Denken in Parallelisierung sofort aus.

Farbkodierte Nutzungsbalken als „Danger"-Signal

Fortschrittsbalken-Farben ändern sich dynamisch basierend auf dem Nutzungsprozentwert:

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>
  )
}

Wenn Nutzer sich ihrem Kontingent nähern, drängt der Farbwechsel sanft in Richtung Upgrade. orange-500 als „normal"-Farbe hält außerdem HeatMapXs Markenfarbe jederzeit sichtbar.

Logout vom Header in den Sidebar-Footer verschieben

Die ursprüngliche Implementierung hatte einen Logout-Link in der oberen rechten Ecke des Headers. Mit der Sidebar wurde er in den unteren Bereich der Sidebar verschoben. Gründe:

  • Sidebar-Footer ist der Standardort für „wichtige, aber seltene" Aktionen — Slack, Discord und Notion machen das alle so
  • Der Header bleibt fokussiert auf globale Controls (Theme, Sprache) und Branding
  • Reduziert das Risiko versehentlicher Logouts (die obere rechte Ecke ist eine stark frequentierte Klickzone)

Claude Code Skill Onboarding

HeatMapX ist als Claude Code Plugin veröffentlicht, daher musste die /dashboard/cli-Seite CLI und Skill nebeneinander präsentieren. Ein zweispaltiges Layout auf größeren Bildschirmen:

// 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 zeigt Beispielbefehle auf Englisch und Japanisch:

// Englisches Beispiel
"Analyze /pricing with HeatMapX and give me CRO ideas."

// Japanisches Beispiel
"HeatMapX で /pricing を分析して、改善案を提案して。"

So finden CLI-first-Nutzer und Claude Code Skill-Nutzer beide ihren Onboarding-Pfad auf derselben Seite.

Migrationsschritte

  1. Sidebar-Komponente erstellen (5 Nav-Einträge + 2 Coming-Soon-Einträge, Inline-SVG-Icons)
  2. layout.tsx zu Flex-Layout refaktorieren (Header + Sidebar + Main)
  3. Site-Liste nach /dashboard/sites extrahieren (page.tsx-Inhalt nach sites/page.tsx verschieben)
  4. Dashboard als neuen Startbildschirm neu schreiben (Stats-Karten + Nutzungsbalken + Quick Actions)
  5. Billing / CLI / Settings-Seiten hinzufügen (3 neue page.tsx-Dateien)
  6. PricingPlans-Komponente erstellen (gemeinsame Daten mit LP, unterschiedliche Styles)
  7. SkillOnboarding-Komponente erstellen (Claude Code Skill-Anleitung)
  8. LanguagePicker-Komponente erstellen (Radio-Stil-Selektor für die Settings-Seite)

Gesamt: 4 neue Komponenten + 5 neue page.tsx-Dateien + Aktualisierungen von layout.tsx und root page.tsx = 11 Dateien. Fertig in einer einzigen Claude Code Session, etwa 2 Stunden.

Kompromisse: Die Nachteile eines Sidebar-Layouts

Der Vollständigkeit halber — hier sind die ehrlichen Nachteile dieser Migration.

  • Mobile-Handling: Unterhalb des sm:-Breakpoints ist die Sidebar versteckt und ein Hamburger-Menü im Header wird gebraucht. Zum Zeitpunkt des Schreibens ist das unfertig — auf Mobile verschwindet die Sidebar einfach, was ein vorläufiger Zwischenzustand ist.
  • Verteiltes State-Management: Mit Daten auf 5 Seiten werden geteilte Werte wie „Siteanzahl" oder „aktueller Plan" auf jeder Seite unabhängig abgefragt. Mit Server Components sind die Latenzkosten gering, aber bei Verlassen auf Client-State braucht es einen durchdachteren Ansatz.
  • Höhere initiale Implementierungskosten: Eine Seite bedeutete früher eine Datei. Fünf Seiten plus ein gemeinsames Layout bedeuten 11 Dateien. Bei wenigen Features ist das klar überdimensioniert.
  • Das „Was kommt auf den Startbildschirm?"-Problem: Ein einseitiges Layout umgeht das vollständig — einfach alles zeigen. Zu entscheiden, was auf den Übersichtsbildschirm gehört, ist eine subtilere Design-Entscheidung als es klingt.
  • Rückwärtskompatibles URL-Management: Wir haben /dashboard so gestaltet, dass es weiterhin als Startbildschirm funktioniert, damit bestehende Lesezeichen nicht brechen. Wenn aber Unterpfade wie /dashboard/cli künftig reorganisiert werden, müssen 301-Weiterleitungen sauber eingerichtet werden.

Unsere Einschätzung: Diese Kosten sind es wert, sobald man mehr als drei verschiedene Features hat. Und die Kehrseite gilt genauso — wenn das Produkt noch früh und feature-arm ist, ist ein einseitiges Layout wahrscheinlich die richtige Wahl.

Fazit

Next.js App Router bietet eine leistungsfähige Struktur für Multi-Page-Migrationen — neue Seiten sind einfach neue Verzeichnisse. Sticky Sidebars koexistieren mit Sticky Headers via top-14 + h-[calc(100vh-3.5rem)]. Active-State ist ein usePathname-Check. Coming-Soon-Einträge sind <div> + aria-disabled. Und ein einzelnes i18n-Dictionary als SSOT zwischen LP und Dashboard verhindert Pricing-Update-Unfälle.

Zusammen ermöglichen diese Muster den Wechsel von einem „einseitigen Dump" zu einer „Supabase-artigen skalierbaren Multi-Tier-SaaS-UI" mit kaum Einstiegshürden für neue Seiten. Die oben genannten Kompromisse sind jedoch real — was bedeutet, der richtige Zeitpunkt zur Migration ist, wenn man drei verschiedene Features überschreitet, nicht früher.

Heatmaps aus Claude Code — kostenlos starten.

Ein Tracker-Tag einfügen, dann Analyse und CRO-Vorschläge aus der CLI bekommen.