Migration d'un tableau de bord SaaS vers une mise en page avec barre latérale sous Next.js App Router

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

TL;DR

  • Problème : une mise en page sur une seule page où « rien n'est trouvable » et « il n'y a pas de place pour ajouter des Paramètres » avait atteint sa limite structurelle
  • Solution : migration vers la structure barre latérale + multi-pages utilisée par Supabase, Vercel et Linear (5 pages, environ 2 heures de travail)
  • Techniques : nouvelles pages = simplement ajouter des répertoires dans Next.js App Router ; barre latérale sticky multi-couche (top-14 + h-[calc(100vh-3.5rem)]) ; détection de l'état actif avec usePathname ; éléments Coming Soon avec <div> + aria-disabled ; un dictionnaire i18n partagé comme source de vérité unique entre la LP et le tableau de bord
  • Compromis : conception du tiroir de barre latérale mobile, gestion d'état distribuée, coût d'implémentation initial plus élevé pour les nouvelles pages (abordé ci-dessous)

Pourquoi tous les SaaS utilisent une mise en page avec barre latérale + multi-pages

Supabase, Vercel, Linear, Notion, Stripe Dashboard — tous les grands produits SaaS utilisent une mise en page barre latérale gauche + multi-pages. C'est la solution standard pour deux besoins concurrents : minimiser le coût de navigation et laisser de la place structurelle pour les fonctionnalités futures.

Une approche « tout sur une seule page » ne fonctionne que jusqu'à un certain point. Une fois que vous dépassez trois fonctionnalités distinctes, les fissures structurelles commencent à apparaître. HeatMapX a atteint ce mur.

Contexte : les limites d'une mise en page sur une seule page

Pendant longtemps, le tableau de bord HeatMapX vivait entièrement sur une seule page /dashboard :

  • Bandeau d'annonce
  • Carte d'utilisation du plan (PlanCard)
  • Liste des sites enregistrés
  • Guide d'installation CLI (CliOnboarding)
  • Guide de démarrage rapide
  • Divers modaux (OnboardingModal, UpgradeDialog, AddSiteDialog)

Tout empilé verticalement. Acceptable au début, mais à mesure que les fonctionnalités s'accumulaient, deux problèmes sont devenus impossibles à ignorer : « Je ne sais pas où trouver quoi que ce soit » et « il n'y a pas d'endroit propre pour ajouter des Paramètres ». La structure sur une seule page était devenue une impasse structurelle.

La solution : migrer vers la mise en page barre latérale gauche + multi-pages qu'utilisent Supabase, Vercel et Linear.

Structure de routes finale

Route Contenu
/dashboard Accueil (aperçu : stats + utilisation + actions rapides)
/dashboard/sites Liste des heatmaps (liste de sites)
/dashboard/cli CLI & Skills (configuration CLI + guide Claude Code Skill)
/dashboard/billing Plan & Facturation (résumé d'utilisation + tableau de comparaison complet des plans)
/dashboard/settings Paramètres (sélection de langue, etc.)

Pourquoi Next.js App Router rend cela indolore

Avec App Router, votre structure de répertoires est votre structure d'URL. Ajouter une nouvelle page signifie créer un dossier et un fichier page.tsx. C'est tout :

src/app/dashboard/
├── layout.tsx              ← partagé entre tous les dashboard/* (barre latérale + en-tête)
├── page.tsx                ← /dashboard (Accueil)
├── 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 nouvelles pages = 5 nouveaux fichiers + mise à jour du layout.tsx partagé. Le rendu côté serveur (Server Components), les vérifications d'authentification (requireUser) et l'i18n (useLocale) fonctionnent tous nativement.

En-tête sticky + barre latérale sticky : faire coexister les deux

Structure du layout

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

Faire en sorte que la barre latérale reste sous l'en-tête

Une barre latérale avec sticky top-0 glissera sous l'en-tête. La solution : la décaler de la hauteur de l'en-tête :

// 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">
    {/* éléments de menu */}
  </nav>
  <div className="border-t">
    {/* déconnexion */}
  </div>
</aside>

Deux points à respecter :

  1. top-14 = 3.5rem = 56px (correspond à la hauteur de l'en-tête)
  2. h-[calc(100vh-3.5rem)] soustrait l'en-tête de la hauteur de la fenêtre — sans cela, la barre latérale déborde en bas

Le schéma flex-col + flex-1 overflow-y-auto + border-t en bas épingle le bouton de déconnexion au pied de la barre latérale.

💡 Apprentissage 1 : Lorsque vous empilez plusieurs éléments sticky, calculez les valeurs top avec précision. top-14 de Tailwind (56px) et h-[calc(100vh-3.5rem)] forment une paire assortie. Si vous modifiez ultérieurement la hauteur de l'en-tête, mettez à jour les deux valeurs en même temps.

Détection de l'état actif dans la barre latérale

Utilisez usePathname pour mettre en surbrillance l'élément de menu correspondant à la page actuelle :

'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 => {
        // correspondance exacte pour /dashboard, correspondance de préfixe pour tout le reste
        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 utilise l'égalité stricte (===) ; tout le reste utilise startsWith. Ainsi, une page de détail comme /dashboard/sites/abc123 garde « Heatmaps » en surbrillance dans la barre latérale.

Le schéma Coming Soon : annoncer des fonctionnalités non encore livrées

Nous voulions afficher les Tests A/B et l'UI Dynamique comme « à venir » — visibles, mais non cliquables. La solution : utiliser <div> avec aria-disabled plutôt que <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>
))}

Lorsqu'une fonctionnalité est livrée, déplacez son entrée de comingSoonItems vers menuItems. La mise en page et les styles se réutilisent automatiquement.

💡 Apprentissage 2 : Annoncer des fonctionnalités non encore livrées pose des attentes et crée de l'anticipation. Deux éléments Coming Soon en bas de la barre latérale disent aux utilisateurs « Les Tests A/B et l'UI Dynamique arrivent » — sans aucun contenu supplémentaire à maintenir. Ce signal atteint plus de personnes qu'une page de feuille de route, parce qu'il apparaît chaque fois que quelqu'un ouvre l'application.

Partager les données de tarification entre la LP et le tableau de bord : le dictionnaire i18n comme source de vérité unique

Les informations sur les plans tarifaires apparaissent à deux endroits : la LP sur /en/pricing et le tableau de bord sur /dashboard/billing. Définir les données à deux endroits signifie qu'un changement de prix nécessite deux mises à jour — et tôt ou tard, l'une d'elles sera oubliée.

La solution : faire du dictionnaire i18n la source de vérité unique. La LP et le tableau de bord lisent tous les deux depuis le même 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>
))}

// Tableau de bord : src/app/dashboard/components/PricingPlans.tsx
const d = t(dashLocale).pricing
{d.plans.map(plan => (
  <article className="light-theme-styles">...</article>
))}

La structure des données est identique. Seuls les styles (sombre vs. clair) et le comportement du CTA diffèrent (la LP invite à se connecter ; le tableau de bord va directement au paiement). Mettre à jour les tarifs est un changement dans un seul fichier.

Récupération parallèle de données avec les Server Components sur la page d'accueil

La page d'accueil doit récupérer plusieurs valeurs agrégées :

  • Le plan actuel de l'utilisateur
  • Le nombre de sites enregistrés
  • Les pages vues mensuelles
  • L'utilisation mensuelle de l'analyse IA
  • Le nombre d'événements du jour (sur tous les sites)

Attendre ces données séquentiellement dans un Server Component est lent. Utilisez Promise.all pour les paralléliser :

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

Cela réduit le temps de chargement d'environ 1,5 s à ~600 ms. Parce que Next.js résout tous les awaits avant de rendre le Server Component, concevoir en pensant à la parallélisation paie immédiatement.

Barres d'utilisation avec code couleur pour signaler le « danger »

Les couleurs des barres de progression changent dynamiquement en fonction du pourcentage d'utilisation :

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

À mesure que les utilisateurs approchent de leur quota, le changement de couleur les incite à passer à un plan supérieur. L'utilisation de orange-500 comme couleur « normale » maintient également la couleur de marque de HeatMapX visible en permanence.

Déplacement de la déconnexion de l'en-tête vers le pied de la barre latérale

L'implémentation initiale plaçait un lien de déconnexion dans le coin supérieur droit de l'en-tête. Avec la barre latérale en place, il a été déplacé vers la section inférieure de la barre latérale. Raisons :

  • Le pied de la barre latérale est l'emplacement standard pour les actions « importantes mais peu fréquentes » — Slack, Discord et Notion font tous ainsi
  • L'en-tête reste concentré sur les contrôles globaux (thème, langue) et l'image de marque
  • Réduit le risque de déconnexion accidentelle (le coin supérieur droit est une zone de clics très fréquentée)

Onboarding pour le Claude Code Skill

HeatMapX est publié en tant que plugin Claude Code, donc la page /dashboard/cli devait présenter à la fois le CLI et le Skill côte à côte. Une mise en page à deux colonnes sur les grands écrans :

// 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 affiche des exemples de commandes en anglais et en japonais :

// Exemple en anglais
"Analyze /pricing with HeatMapX and give me CRO ideas."

// Exemple en japonais
"HeatMapX で /pricing を分析して、改善案を提案して。"

Ainsi, les utilisateurs orientés CLI et ceux qui utilisent Claude Code Skill trouvent tous leur chemin d'onboarding depuis la même page.

Étapes de migration

  1. Créer le composant Sidebar (5 éléments de navigation + 2 éléments Coming Soon, icônes SVG inline)
  2. Refactoriser layout.tsx vers une mise en page flex (en-tête + barre latérale + contenu principal)
  3. Extraire la liste des sites vers /dashboard/sites (déplacer le contenu de page.tsx vers sites/page.tsx)
  4. Réécrire /dashboard comme le nouvel écran d'accueil (cartes de stats + barres d'utilisation + actions rapides)
  5. Ajouter les pages billing / cli / settings (3 nouveaux fichiers page.tsx)
  6. Créer le composant PricingPlans (données partagées avec la LP, styles différents)
  7. Créer le composant SkillOnboarding (guide du Claude Code Skill)
  8. Créer le composant LanguagePicker (sélecteur de type radio pour la page de paramètres)

Total : 4 nouveaux composants + 5 nouveaux fichiers page.tsx + mises à jour de layout.tsx et du page.tsx racine = 11 fichiers. Réalisé en une seule session Claude Code, en environ 2 heures.

Compromis : les inconvénients d'une mise en page avec barre latérale

Dans l'intérêt de donner un peu de crédibilité à ce compte-rendu de migration, voici les inconvénients honnêtes.

  • Gestion du mobile : en dessous du point de rupture sm:, la barre latérale est masquée et vous avez besoin d'un menu hamburger dans l'en-tête. Au moment de la rédaction, cela n'est pas terminé — sur mobile, la barre latérale disparaît simplement, ce qui est un état intermédiaire rudimentaire.
  • Gestion d'état distribuée : avec des données réparties sur 5 pages, les valeurs partagées comme « nombre de sites » ou « plan actuel » sont récupérées indépendamment sur chaque page. Avec les Server Components, le coût de latence est faible, mais si vous vous appuyez sur l'état client, vous aurez besoin d'une approche plus délibérée.
  • Coût d'implémentation initial plus élevé : une page ne signifiait qu'un seul fichier. Cinq pages plus un layout partagé représentent 11 fichiers. Pour un faible nombre de fonctionnalités, c'est clairement excessif.
  • Le problème de « qu'est-ce qui va sur l'écran d'accueil ? » : une mise en page sur une seule page contourne entièrement ce problème — vous montrez simplement tout. Décider ce qui appartient à l'écran de vue d'ensemble est un choix de design plus subtil qu'il n'y paraît.
  • Gestion des URL rétrocompatibles : nous avons conçu /dashboard pour qu'il fonctionne toujours comme l'écran d'accueil, afin que les marque-pages existants ne soient pas cassés. Mais si des sous-chemins comme /dashboard/cli sont réorganisés à l'avenir, des redirections 301 devront être configurées correctement.

Notre avis : ces coûts valent la peine d'être payés une fois que vous avez plus de trois fonctionnalités distinctes. Et l'inverse est tout aussi vrai — si votre produit est encore au stade initial et léger en fonctionnalités, une mise en page sur une seule page est probablement le bon choix.

Conclusion

Next.js App Router vous donne une structure puissante pour les migrations multi-pages — les nouvelles pages ne sont que de nouveaux répertoires. Les barres latérales sticky coexistent avec les en-têtes sticky via top-14 + h-[calc(100vh-3.5rem)]. L'état actif est une vérification usePathname. Les éléments Coming Soon sont des <div> + aria-disabled. Et partager un seul dictionnaire i18n comme source de vérité unique entre la LP et le tableau de bord évite les accidents lors des mises à jour de tarification.

Ensemble, ces schémas permettent de passer d'un « déversoir sur une seule page » à une « interface SaaS multi-niveaux scalable façon Supabase » avec presque aucune barrière à l'entrée pour les nouvelles pages. Cela dit, les compromis ci-dessus sont réels — ce qui signifie que le bon moment pour migrer est lorsque vous dépassez trois fonctionnalités distinctes, pas avant.

Des heatmaps depuis Claude Code — gratuit pour commencer.

Ajoutez une balise de tracking, recevez analyses et suggestions CRO depuis la CLI.