Migration eines SaaS-Dashboards auf ein Sidebar-Layout mit Next.js App Router
- 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 mitusePathname; 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:
top-14= 3,5rem = 56px (entspricht der Header-Höhe)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. Tailwindstop-14(56px) undh-[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
- Sidebar-Komponente erstellen (5 Nav-Einträge + 2 Coming-Soon-Einträge, Inline-SVG-Icons)
- layout.tsx zu Flex-Layout refaktorieren (Header + Sidebar + Main)
- Site-Liste nach /dashboard/sites extrahieren (page.tsx-Inhalt nach sites/page.tsx verschieben)
- Dashboard als neuen Startbildschirm neu schreiben (Stats-Karten + Nutzungsbalken + Quick Actions)
- Billing / CLI / Settings-Seiten hinzufügen (3 neue page.tsx-Dateien)
- PricingPlans-Komponente erstellen (gemeinsame Daten mit LP, unterschiedliche Styles)
- SkillOnboarding-Komponente erstellen (Claude Code Skill-Anleitung)
- 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
/dashboardso gestaltet, dass es weiterhin als Startbildschirm funktioniert, damit bestehende Lesezeichen nicht brechen. Wenn aber Unterpfade wie/dashboard/clikü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.