Migrando um Dashboard SaaS para Layout com Sidebar usando Next.js App Router
- 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 comusePathname; 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:
top-14= 3.5rem = 56px (corresponde à altura do header)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
topcom precisão. Otop-14(56px) e oh-[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
- Criar o componente Sidebar (5 itens de navegação + 2 itens Em Breve, ícones SVG inline)
- Refatorar layout.tsx para um layout flex (header + sidebar + main)
- Extrair a lista de sites para /dashboard/sites (mover conteúdo de page.tsx para sites/page.tsx)
- Reescrever /dashboard como a nova tela inicial (cards de estatísticas + barras de uso + ações rápidas)
- Adicionar páginas billing / cli / settings (3 novos arquivos page.tsx)
- Criar o componente PricingPlans (dados compartilhados com a LP, estilos diferentes)
- Criar o componente SkillOnboarding (guia de Claude Code Skill)
- 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
/dashboardpara continuar funcionando como a tela inicial, então bookmarks existentes não quebram. Mas se subcaminhos como/dashboard/cliforem 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.