Migrare un Dashboard SaaS a un Layout con Sidebar usando Next.js App Router

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

TL;DR

  • Problema: Un layout a pagina singola dove "non si trova niente" e "non c'è spazio per aggiungere le Settings" aveva raggiunto il suo limite strutturale
  • Soluzione: Migrazione alla struttura sidebar + multi-pagina usata da Supabase, Vercel e Linear (5 pagine, circa 2 ore di lavoro)
  • Tecniche: Nuove pagine = aggiungere directory nel Next.js App Router; sidebar multi-layer sticky (top-14 + h-[calc(100vh-3.5rem)]); rilevamento dello stato attivo con usePathname; elementi Coming Soon con <div> + aria-disabled; un dizionario i18n condiviso come SSOT tra LP e dashboard
  • Compromessi: Design del drawer mobile per la sidebar, gestione dello stato distribuita, costo di implementazione iniziale più alto per le nuove pagine (discusso di seguito)

Perché Ogni SaaS Usa un Layout Sidebar + Multi-Pagina

Supabase, Vercel, Linear, Notion, Stripe Dashboard — ogni prodotto SaaS di rilievo usa un layout sidebar sinistra + multi-pagina. È la soluzione standard per due esigenze in competizione: minimizzare il costo di navigazione e lasciare spazio strutturale per le funzionalità future.

Un approccio a pagina singola "metti tutto qui" funziona solo fino a un certo punto. Una volta superati tre feature distinte, le crepe strutturali cominciano a mostrarsi. HeatMapX ha colpito quel muro.

Contesto: I Limiti di un Layout a Pagina Singola

Per molto tempo, il dashboard di HeatMapX viveva interamente su una singola pagina /dashboard:

  • Banner annunci
  • Card utilizzo piano (PlanCard)
  • Lista siti registrati
  • Guida installazione CLI (CliOnboarding)
  • Guida Getting Started
  • Vari modal (OnboardingModal, UpgradeDialog, AddSiteDialog)

Tutto impilato verticalmente. Funzionava all'inizio, ma man mano che le funzionalità si accumulavano, due problemi sono diventati impossibili da ignorare: "non riesco a capire dove si trova niente," e "non c'è un posto pulito dove aggiungere le Settings." La struttura a pagina singola era diventata un vicolo cieco strutturale.

La soluzione: migrare al layout sidebar sinistra + multi-pagina usato da Supabase, Vercel e Linear.

Struttura Route Finale

Route Contenuto
/dashboard Home (panoramica: statistiche + utilizzo + azioni rapide)
/dashboard/sites Lista Heatmap (lista siti)
/dashboard/cli CLI & Skills (setup CLI + guida Claude Code Skill)
/dashboard/billing Piano & Fatturazione (riepilogo utilizzo attuale + tabella comparativa piani completa)
/dashboard/settings Impostazioni (selezione lingua, ecc.)

Perché Next.js App Router Rende Tutto Indolore

Con App Router, la struttura delle directory è la struttura degli URL. Aggiungere una nuova pagina significa creare una cartella e un file page.tsx. Tutto qui:

src/app/dashboard/
├── layout.tsx              ← condiviso tra tutti i 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 nuove pagine = 5 nuovi file + aggiornamento del layout.tsx condiviso. Il rendering server-side (Server Components), i controlli auth (requireUser), e i18n (useLocale) funzionano tutti senza configurazione aggiuntiva.

Header Sticky + Sidebar Sticky: Come Farli Coesistere

Struttura del layout

// dashboard/layout.tsx
<div className="flex min-h-screen flex-col">
  <header className="sticky top-0 z-30 h-14 ...">
    {/* logo + tema + 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>

Far sì che la sidebar si ancori sotto l'header

Una sidebar con sticky top-0 scivolerebbe sotto l'header. La soluzione: offsettarla dell'altezza dell'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">
    {/* elementi menu */}
  </nav>
  <div className="border-t">
    {/* logout */}
  </div>
</aside>

Due cose da fare correttamente:

  1. top-14 = 3.5rem = 56px (corrisponde all'altezza dell'header)
  2. h-[calc(100vh-3.5rem)] sottrae l'header dall'altezza della viewport — senza questo, la sidebar va in overflow in basso

Il pattern flex-col + flex-1 overflow-y-auto + border-t in fondo fissa il pulsante di logout al footer della sidebar.

💡 Lezione 1: Quando si impilano più elementi sticky, calcola i valori top con precisione. top-14 (56px) e h-[calc(100vh-3.5rem)] di Tailwind sono una coppia abbinata. Se in seguito cambi l'altezza dell'header, aggiorna entrambi i valori in sincronia.

Rilevamento dello Stato Attivo nella Sidebar

Usa usePathname per evidenziare l'elemento di menu che corrisponde alla pagina corrente:

'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 => {
        // corrispondenza esatta per /dashboard, corrispondenza prefisso per tutto il resto
        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 usa la corrispondenza esatta (===); tutto il resto usa startsWith. Questo significa che una pagina di dettaglio come /dashboard/sites/abc123 mantiene "Heatmaps" evidenziato nella sidebar.

Il Pattern Coming Soon: Annunciare Funzionalità Non Ancora Costruite

Volevamo mostrare A/B Testing e Dynamic UI come "coming soon" — visibili, ma non cliccabili. La soluzione: usare <div> con aria-disabled invece di <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>
))}

Quando una funzionalità viene rilasciata, sposta la sua voce da comingSoonItems a menuItems. Il layout e gli stili si riutilizzano automaticamente.

💡 Lezione 2: Annunciare funzionalità non ancora costruite imposta le aspettative e crea anticipazione. Due elementi Coming Soon in fondo alla sidebar dicono agli utenti "A/B Testing e Dynamic UI sono in arrivo" — senza contenuti aggiuntivi da mantenere. Quel segnale raggiunge più persone di qualsiasi pagina roadmap, perché appare ogni volta che qualcuno apre l'app.

Condividere i Dati di Pricing tra LP e Dashboard: Il Dizionario i18n come SSOT

Le informazioni sui piani di pricing appaiono in due posti: la LP su /it/pricing e il dashboard su /dashboard/billing. Definire i dati in due posti significa che un cambio di prezzo richiede due aggiornamenti — e prima o poi uno viene dimenticato.

La soluzione: rendere il dizionario i18n la Single Source of Truth. Sia la LP che il dashboard leggono dallo stesso 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>
))}

La struttura dati è identica. Solo gli stili (dark vs. light) e il comportamento del CTA differiscono (la LP richiede login; il dashboard va direttamente al checkout). Aggiornare il pricing è un cambiamento a un solo file.

Fetch Parallelo dei Dati con Server Components nella Home Page

La home page deve recuperare diversi valori aggregati:

  • Piano corrente dell'utente
  • Numero di siti registrati
  • Visualizzazioni di pagina mensili
  • Utilizzo mensile analisi AI
  • Conteggio eventi di oggi (su tutti i siti)

Attendere questi in sequenza in un Server Component è lento. Usa Promise.all per parallelizzare:

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

Questo riduce il tempo di caricamento da circa 1,5s a circa 600ms. Poiché Next.js risolve tutti gli await prima di renderizzare il Server Component, progettare con la parallelizzazione in mente ripaga immediatamente.

Barre di Utilizzo Color-Coded per Segnalare il "Pericolo"

I colori delle barre di avanzamento cambiano dinamicamente in base alla percentuale di utilizzo:

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

Man mano che gli utenti si avvicinano alla loro quota, il cambio di colore li spinge verso l'upgrade. Usare orange-500 come colore "normale" mantiene anche il colore brand di HeatMapX sempre visibile.

Spostare il Logout dall'Header al Footer della Sidebar

L'implementazione iniziale aveva un link di logout nell'angolo in alto a destra dell'header. Con la sidebar in posizione, si è spostato nella sezione inferiore della sidebar. Motivazioni:

  • Il footer della sidebar è la posizione standard per le azioni "importanti ma poco frequenti" — Slack, Discord e Notion fanno tutti così
  • L'header rimane focalizzato sui controlli globali (tema, lingua) e il branding
  • Riduce il rischio di logout accidentale (l'angolo in alto a destra è una zona di click ad alto traffico)

Onboarding della Claude Code Skill

HeatMapX è pubblicato come plugin di Claude Code, quindi la pagina /dashboard/cli doveva mostrare sia la CLI che il lato Skill fianco a fianco. Un layout a due colonne su schermi più grandi:

// 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 mostra comandi di esempio sia in inglese che in giapponese:

// Esempio in inglese
"Analyze /pricing with HeatMapX and give me CRO ideas."

// Esempio in giapponese
"HeatMapX で /pricing を分析して、改善案を提案して。"

In questo modo, gli utenti CLI-first e gli utenti della Claude Code Skill trovano entrambi il loro percorso di onboarding dalla stessa pagina.

Passi della Migrazione

  1. Creare il componente Sidebar (5 voci di navigazione + 2 elementi Coming Soon, icone SVG inline)
  2. Refactoring di layout.tsx a un layout flex (header + sidebar + main)
  3. Estrarre la lista siti in /dashboard/sites (spostare il contenuto di page.tsx in sites/page.tsx)
  4. Riscrivere /dashboard come nuova home screen (stats card + barre utilizzo + azioni rapide)
  5. Aggiungere le pagine billing / cli / settings (3 nuovi file page.tsx)
  6. Creare il componente PricingPlans (dati condivisi con LP, stili diversi)
  7. Creare il componente SkillOnboarding (guida Claude Code Skill)
  8. Creare il componente LanguagePicker (selettore stile radio per la pagina settings)

Totale: 4 nuovi componenti + 5 nuovi file page.tsx + aggiornamenti a layout.tsx e al root page.tsx = 11 file. Completato in una singola sessione Claude Code, circa 2 ore.

Compromessi: Gli Svantaggi di un Layout con Sidebar

Nell'interesse di dare credibilità a questo articolo sulla migrazione, ecco gli svantaggi onesti.

  • Gestione mobile: Sotto il breakpoint sm:, la sidebar è nascosta e hai bisogno di un menu hamburger nell'header. Al momento della stesura questo non è completato — su mobile la sidebar semplicemente scompare, che è uno stato intermedio piuttosto grezzo.
  • Gestione dello stato distribuita: Con i dati distribuiti su 5 pagine, i valori condivisi come "conteggio siti" o "piano corrente" vengono recuperati indipendentemente su ogni pagina. Con i Server Components il costo di latenza è ridotto, ma se fai affidamento sullo stato client, avrai bisogno di un approccio più deliberato.
  • Costo di implementazione iniziale più alto: Una pagina significava un file. Cinque pagine più un layout condiviso significano 11 file. Con un numero ridotto di funzionalità, questo è chiaramente eccessivo.
  • Il problema "cosa metto nella home screen?": Un layout a pagina singola aggira completamente questo — mostri semplicemente tutto. Decidere cosa appartiene alla schermata panoramica è una scelta di design più sottile di quanto sembri.
  • Gestione degli URL retrocompatibile: Abbiamo progettato /dashboard per funzionare ancora come home screen, così i segnalibri esistenti non si rompono. Ma se i sotto-percorsi come /dashboard/cli vengono riorganizzati in futuro, sarà necessario configurare correttamente i redirect 301.

Il nostro punto di vista: questi sono costi che vale la pena pagare una volta che hai più di tre funzionalità distinte. E il rovescio della medaglia è ugualmente vero — se il tuo prodotto è ancora agli inizi e con poche funzionalità, un layout a pagina singola è probabilmente la scelta giusta.

Conclusione

Next.js App Router ti offre una struttura potente per le migrazioni multi-pagina — le nuove pagine sono semplicemente nuove directory. Le sidebar sticky coesistono con gli header sticky tramite top-14 + h-[calc(100vh-3.5rem)]. Lo stato attivo è un controllo con usePathname. Gli elementi Coming Soon sono <div> + aria-disabled. E condividere un singolo dizionario i18n come SSOT tra LP e dashboard previene gli incidenti negli aggiornamenti di pricing.

Insieme, questi pattern rendono possibile passare da "dump a pagina singola" a "UI SaaS multi-livello scalabile in stile Supabase" con quasi nessuna barriera all'ingresso per le nuove pagine. Detto questo, i compromessi sopra elencati sono reali — il che significa che il momento giusto per migrare è quando si superano tre funzionalità distinte, non prima.

Heatmap da Claude Code — inizia gratis.

Incolla un tag di tracking, ricevi analisi e suggerimenti CRO dalla CLI.