HeatMapXHeatMapX
PreçosEntrar

Migrando um Dashboard SaaS para Layout com Sidebar usando Next.js App Router

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

TL;DR

  • Problema: Um layout de página única onde "nada se acha" e "não há espaço para adicionar Configurações" havia atingido seu limite estrutural
  • Solução: Migração para a estrutura de sidebar + múltiplas páginas usada pelo Supabase, Vercel e Linear (5 páginas, ~2 horas de trabalho)
  • Técnicas: Novas páginas = apenas adicionar diretórios no Next.js App Router; sidebar sticky multicamada (top-14 + h-[calc(100vh-3.5rem)]); detecção de estado ativo com usePathname; itens "Em Breve" usando <div> + aria-disabled; um dicionário i18n compartilhado como SSOT entre a LP e o dashboard
  • Desvantagens: Drawer mobile para sidebar, gerenciamento de estado distribuído, custo de implementação inicial mais alto para novas páginas (discutido abaixo)

Por Que Todo SaaS Usa Layout de Sidebar + Múltiplas Páginas

Supabase, Vercel, Linear, Notion, Stripe Dashboard — todo produto SaaS relevante usa um layout de sidebar esquerda + múltiplas páginas. É a solução padrão para duas necessidades concorrentes: minimizar o custo de navegação e deixar espaço estrutural para funcionalidades futuras.

Uma abordagem de página única "despeja tudo aqui" só funciona por um tempo. Assim que você ultrapassa três funcionalidades distintas, as rachaduras estruturais começam a aparecer. O HeatMapX atingiu esse limite.

Contexto: Os Limites de um Layout de Página Única

Por muito tempo, o dashboard do HeatMapX vivia inteiramente em uma única página /dashboard:

  • Banner de aviso
  • Card de uso do plano (PlanCard)
  • Lista de sites registrados
  • Guia de instalação CLI (CliOnboarding)
  • Guia de Primeiros Passos
  • Vários modais (OnboardingModal, UpgradeDialog, AddSiteDialog)

Tudo empilhado verticalmente. Bom no começo, mas à medida que as funcionalidades se acumularam, dois problemas se tornaram impossíveis de ignorar: "Não consigo encontrar nada" e "Não há lugar limpo para adicionar Configurações". A estrutura de página única havia se tornado um beco sem saída.

A solução: migrar para o layout de sidebar esquerda + múltiplas páginas que Supabase, Vercel e Linear usam.

Estrutura Final de Rotas

Rota Conteúdo
/dashboard Home (visão geral: estatísticas + uso + ações rápidas)
/dashboard/sites Lista de Heatmaps (lista de sites)
/dashboard/cli CLI & Skills (configuração CLI + guia de Claude Code Skill)
/dashboard/billing Plano & Cobrança (uso atual + tabela completa de comparação de planos)
/dashboard/settings Configurações (seleção de idioma, etc.)

Por Que o Next.js App Router Torna Isso Simples

Com o App Router, sua estrutura de diretórios é sua estrutura de URLs. Adicionar uma nova página significa criar uma pasta e um arquivo page.tsx. Só isso:

src/app/dashboard/
├── layout.tsx              ← compartilhado entre todos os 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 novas páginas = 5 novos arquivos + atualização do layout.tsx compartilhado. Renderização no servidor (Server Components), verificações de autenticação (requireUser) e i18n (useLocale) funcionam diretamente.

Header Sticky + Sidebar Sticky: Fazendo-os Coexistir

Estrutura do Layout

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

Fazendo o Sidebar Ficar Abaixo do Header

Um sidebar com sticky top-0 desliza sob o header. A solução: deslocá-lo pela altura do 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">
    {/* itens do menu */}
  </nav>
  <div className="border-t">
    {/* logout */}
  </div>
</aside>

Duas coisas para acertar:

  1. top-14 = 3.5rem = 56px (corresponde à altura do header)
  2. h-[calc(100vh-3.5rem)] subtrai o header da altura da viewport — sem isso, o sidebar extravasa por baixo

O padrão flex-col + flex-1 overflow-y-auto + border-t na parte inferior fixa o botão de logout no rodapé do sidebar.

💡 Lição 1: Ao empilhar múltiplos elementos sticky, calcule os valores top com precisão. O top-14 (56px) e o h-[calc(100vh-3.5rem)] do Tailwind são um par combinado. Se você alterar a altura do header depois, atualize os dois valores em sincronia.

Detecção de Estado Ativo no Sidebar

Use usePathname para destacar o item de menu que corresponde à página atual:

'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 => {
        // correspondência exata para /dashboard, correspondência por prefixo para o restante
        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 igualdade estrita (===); tudo o mais usa startsWith. Isso significa que uma página de detalhe como /dashboard/sites/abc123 mantém "Heatmaps" destacado no sidebar.

O Padrão "Em Breve": Anunciando Funcionalidades Ainda Não Construídas

Quisemos exibir A/B Testing e Dynamic UI como "em breve" — visíveis, mas não clicáveis. A solução: usar <div> com aria-disabled em vez de <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 uma funcionalidade for lançada, mova sua entrada de comingSoonItems para menuItems. O layout e os estilos são reutilizados automaticamente.

💡 Lição 2: Anunciar funcionalidades ainda não construídas define expectativas e cria antecipação. Dois itens "Em Breve" no final do sidebar dizem aos usuários que "A/B Testing e Dynamic UI estão a caminho" — sem nenhum conteúdo adicional para manter. Esse sinal alcança mais pessoas do que uma página de roadmap jamais alcançará, porque aparece toda vez que alguém abre o app.

Compartilhando Dados de Preços Entre a LP e o Dashboard: Dicionário i18n como SSOT

As informações do plano de preços aparecem em dois lugares: a LP em /en/pricing e o dashboard em /dashboard/billing. Definir os dados em dois lugares significa que uma mudança de preço requer duas atualizações — e cedo ou tarde, uma delas é esquecida.

A solução: tornar o dicionário i18n a Fonte Única da Verdade. Tanto a LP quanto o dashboard leem do mesmo 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>
))}

A estrutura de dados é idêntica. Apenas os estilos (escuro vs. claro) e o comportamento do CTA diferem (a LP solicita login; o dashboard vai direto para o checkout). Atualizar preços é uma mudança em um único arquivo.

Busca de Dados em Paralelo com Server Components na Página Inicial

A página inicial precisa buscar vários valores agregados:

  • Plano atual do usuário
  • Número de sites registrados
  • Visualizações de página mensais
  • Uso mensal de análise de IA
  • Contagem de eventos de hoje (em todos os sites)

Aguardar essas buscas sequencialmente em um Server Component é lento. Use Promise.all para paralelizar:

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

Isso reduz o tempo de carregamento de aproximadamente 1,5s para ~600ms. Como o Next.js resolve todos os awaits antes de renderizar o Server Component, projetar com paralelização em mente tem retorno imediato.

Barras de Uso com Código de Cores para Sinalizar "Perigo"

As cores das barras de progresso mudam dinamicamente com base na porcentagem de uso:

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

À medida que os usuários se aproximam de sua cota, a mudança de cor os incentiva a fazer upgrade. Usar orange-500 como cor "normal" também mantém a cor da marca do HeatMapX sempre visível.

Movendo o Logout do Header para o Rodapé do Sidebar

A implementação inicial tinha um link de logout no canto superior direito do header. Com o sidebar no lugar, ele foi movido para a seção inferior do sidebar. Motivos:

  • O rodapé do sidebar é o local padrão para ações "importantes mas pouco frequentes" — Slack, Discord e Notion fazem isso
  • O header permanece focado em controles globais (tema, idioma) e branding
  • Reduz o risco de logout acidental (o canto superior direito é uma zona de clique de alto tráfego)

Onboarding do Claude Code Skill

O HeatMapX é publicado como um plugin do Claude Code, então a página /dashboard/cli precisava exibir tanto o CLI quanto o Skill lado a lado. Um layout de duas colunas em telas maiores:

// 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 comandos de exemplo em inglês e japonês:

// Exemplo em inglês
"Analyze /pricing with HeatMapX and give me CRO ideas."

// Exemplo em japonês
"HeatMapX で /pricing を分析して、改善案を提案して。"

Assim, usuários que preferem CLI e usuários do Claude Code Skill encontram seu caminho de onboarding na mesma página.

Passos da Migração

  1. Criar o componente Sidebar (5 itens de navegação + 2 itens Em Breve, ícones SVG inline)
  2. Refatorar layout.tsx para um layout flex (header + sidebar + main)
  3. Extrair a lista de sites para /dashboard/sites (mover conteúdo de page.tsx para sites/page.tsx)
  4. Reescrever /dashboard como a nova tela inicial (cards de estatísticas + barras de uso + ações rápidas)
  5. Adicionar páginas billing / cli / settings (3 novos arquivos page.tsx)
  6. Criar o componente PricingPlans (dados compartilhados com a LP, estilos diferentes)
  7. Criar o componente SkillOnboarding (guia de Claude Code Skill)
  8. Criar o componente LanguagePicker (seletor estilo radio para a página de configurações)

Total: 4 novos componentes + 5 novos arquivos page.tsx + atualizações em layout.tsx e no page.tsx raiz = 11 arquivos. Feito em uma única sessão do Claude Code, aproximadamente 2 horas.

Desvantagens: Os Pontos Negativos de um Layout com Sidebar

Para dar alguma credibilidade a este relato de migração, aqui estão as desvantagens honestas.

  • Tratamento mobile: Abaixo do breakpoint sm:, o sidebar fica oculto e você precisa de um menu hambúrguer no header. No momento da escrita isso está inacabado — no mobile o sidebar simplesmente desaparece, o que é um estado intermediário ruim.
  • Gerenciamento de estado distribuído: Com dados espalhados por 5 páginas, valores compartilhados como "contagem de sites" ou "plano atual" são buscados independentemente em cada página. Com Server Components o custo de latência é pequeno, mas se você depende de estado do cliente, precisará de uma abordagem mais deliberada.
  • Custo de implementação inicial mais alto: Uma página costumava significar um arquivo. Cinco páginas mais um layout compartilhado significam 11 arquivos. Com poucos recursos, isso é claramente excessivo.
  • O problema "o que fica na tela inicial?": Um layout de página única contorna isso completamente — você simplesmente mostra tudo. Decidir o que pertence à tela de visão geral é uma decisão de design mais sutil do que parece.
  • Gerenciamento de URL retrocompatível: Projetamos /dashboard para continuar funcionando como a tela inicial, então bookmarks existentes não quebram. Mas se subcaminhos como /dashboard/cli forem reorganizados no futuro, redirecionamentos 301 precisarão ser configurados adequadamente.

Nossa opinião: esses são custos que valem a pena pagar quando você tem mais de três funcionalidades distintas. E o oposto é igualmente verdadeiro — se seu produto ainda está em estágio inicial e tem poucas funcionalidades, um layout de página única provavelmente é a escolha certa.

Conclusão

O Next.js App Router oferece uma estrutura poderosa para migrações de múltiplas páginas — novas páginas são apenas novos diretórios. Sidebars sticky coexistem com headers sticky via top-14 + h-[calc(100vh-3.5rem)]. Estado ativo é uma verificação com usePathname. Itens "Em Breve" são <div> + aria-disabled. E compartilhar um único dicionário i18n como SSOT entre a LP e o dashboard previne acidentes em atualizações de preços.

Juntos, esses padrões tornam possível ir de "despejo em página única" para "UI SaaS escalável em múltiplas camadas no estilo Supabase" com quase nenhuma barreira para novas páginas. Dito isso, as desvantagens acima são reais — o que significa que o momento certo para migrar é quando você ultrapassa três funcionalidades distintas, não antes.

Heatmaps no Claude Code — comece grátis.

Cole uma tag de tracking e receba análises e sugestões CRO via CLI.