Migrando un Dashboard SaaS a un Layout con Sidebar usando Next.js App Router
- engineering
- nextjs
- app-router
- claude-code
TL;DR
- Problema: Un layout de página única donde "nada es encontrable" y "no hay espacio para añadir Configuración" había llegado a su límite estructural
- Solución: Migración a la estructura de sidebar + múltiples páginas usada por Supabase, Vercel y Linear (5 páginas, ~2 horas de trabajo)
- Técnicas: Nuevas páginas = solo añadir directorios en Next.js App Router; sidebar sticky de múltiples capas (
top-14+h-[calc(100vh-3.5rem)]); detección de estado activo conusePathname; elementos Coming Soon usando<div> + aria-disabled; un diccionario i18n compartido como SSOT entre la LP y el dashboard- Compromisos: Diseño del drawer del sidebar en móvil, gestión de estado distribuido, mayor costo de implementación inicial para nuevas páginas (discutido abajo)
Por qué Todos los SaaS Usan un Layout de Sidebar + Múltiples Páginas
Supabase, Vercel, Linear, Notion, Stripe Dashboard — todos los grandes productos SaaS usan un layout de sidebar izquierdo + múltiples páginas. Es la solución estándar para dos necesidades en competencia: minimizar el costo de navegación y dejar espacio estructural para funcionalidades futuras.
Un enfoque de página única de "volcar todo aquí" solo funciona hasta cierto punto. Una vez que superas tres funcionalidades distintas, las grietas estructurales empiezan a aparecer. HeatMapX llegó a ese límite.
Contexto: Los Límites de un Layout de Página Única
Durante mucho tiempo, el dashboard de HeatMapX vivió completamente en una única página /dashboard:
- Banner de anuncios
- Tarjeta de uso del plan (PlanCard)
- Lista de sitios registrados
- Guía de instalación CLI (CliOnboarding)
- Guía de Primeros Pasos
- Varios modales (OnboardingModal, UpgradeDialog, AddSiteDialog)
Todo apilado verticalmente. Bien al principio, pero a medida que se acumularon funcionalidades, dos problemas se volvieron imposibles de ignorar: "No sé dónde está nada" y "no hay un lugar limpio para añadir Configuración". La estructura de página única se había convertido en un callejón sin salida estructural.
La solución: migrar al layout de sidebar izquierdo + múltiples páginas que usan Supabase, Vercel y Linear.
Estructura de Rutas Final
| Ruta | Contenido |
|---|---|
/dashboard |
Home (resumen: estadísticas + uso + acciones rápidas) |
/dashboard/sites |
Lista de Heatmaps (lista de sitios) |
/dashboard/cli |
CLI & Skills (configuración de CLI + guía del Claude Code Skill) |
/dashboard/billing |
Plan & Facturación (uso actual + tabla comparativa completa de planes) |
/dashboard/settings |
Configuración (selección de idioma, etc.) |
Por qué Next.js App Router Hace esto Sencillo
Con App Router, la estructura de directorios es la estructura de URLs. Añadir una nueva página significa crear una carpeta y un archivo page.tsx. Eso es todo:
src/app/dashboard/
├── layout.tsx ← compartido entre todos los 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 nuevas páginas = 5 nuevos archivos + actualizar el layout.tsx compartido. El renderizado del lado del servidor (Server Components), las verificaciones de autenticación (requireUser), y el i18n (useLocale) simplemente funcionan.
Header Sticky + Sidebar Sticky: Hacerlos Coexistir
Estructura 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>
Hacer que el sidebar se quede por debajo del header
Un sidebar con sticky top-0 se deslizará bajo el header. La solución: desplazarlo por la altura del 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">
{/* elementos del menú */}
</nav>
<div className="border-t">
{/* cerrar sesión */}
</div>
</aside>
Dos cosas que hay que hacer bien:
top-14= 3.5rem = 56px (coincide con la altura del header)h-[calc(100vh-3.5rem)]resta el header de la altura del viewport — sin esto, el sidebar desborda por abajo
El patrón flex-col + flex-1 overflow-y-auto + border-t al final ancla el botón de cierre de sesión al pie del sidebar.
💡 Lección 1: Al apilar múltiples elementos sticky, calcula los valores de
topcon precisión.top-14(56px) yh-[calc(100vh-3.5rem)]de Tailwind son un par coordinado. Si cambias la altura del header más adelante, actualiza ambos valores al mismo tiempo.
Detección del Estado Activo en el Sidebar
Usa usePathname para resaltar el elemento del menú que corresponde a la página actual:
'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 => {
// coincidencia exacta para /dashboard, coincidencia por prefijo para todo lo demás
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 igualdad estricta (===); todo lo demás usa startsWith. Esto significa que una página de detalle como /dashboard/sites/abc123 mantiene "Heatmaps" resaltado en el sidebar.
El Patrón Coming Soon: Anunciando Funcionalidades No Construidas
Queríamos mostrar A/B Testing y Dynamic UI como "próximamente" — visibles, pero no clicables. La solución: usar <div> con aria-disabled en lugar 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>
))}
Cuando una funcionalidad se lanza, mueve su entrada de comingSoonItems a menuItems. El layout y los estilos se reutilizan automáticamente.
💡 Lección 2: Anunciar funcionalidades no construidas establece expectativas y genera anticipación. Dos elementos Coming Soon al fondo del sidebar le dicen a los usuarios "A/B Testing y Dynamic UI están en camino" — sin ningún contenido adicional que mantener. Esa señal llega a más personas que una página de roadmap, porque aparece cada vez que alguien abre la app.
Compartiendo Datos de Precios entre la LP y el Dashboard: Diccionario i18n como SSOT
La información de planes de precios aparece en dos lugares: la LP en /es/pricing y el dashboard en /dashboard/billing. Definir los datos en dos lugares significa que un cambio de precio requiere dos actualizaciones — y tarde o temprano, una de ellas se olvida.
La solución: hacer del diccionario i18n la Fuente Única de Verdad. Tanto la LP como el dashboard leen del mismo 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 estructura de datos es idéntica. Solo los estilos (oscuro vs. claro) y el comportamiento del CTA difieren (la LP solicita login; el dashboard va directo al checkout). Actualizar los precios es un cambio de un solo archivo.
Fetching Paralelo de Datos con Server Components en la Página Principal
La página principal necesita obtener varios valores agregados:
- Plan actual del usuario
- Número de sitios registrados
- Vistas de página mensuales
- Uso mensual de análisis con IA
- Conteo de eventos de hoy (en todos los sitios)
Esperar estos secuencialmente en un Server Component es lento. Usa 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),
])
// ...
}
Esto reduce el tiempo de carga de aproximadamente 1.5s a ~600ms. Como Next.js resuelve todos los awaits antes de renderizar el Server Component, diseñar con la paralelización en mente da resultados inmediatos.
Barras de Uso con Código de Color para Señalar "Peligro"
Los colores de las barras de progreso cambian dinámicamente según el porcentaje 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>
)
}
A medida que los usuarios se acercan a su cuota, el cambio de color los empuja hacia la actualización. Usar orange-500 como color "normal" también mantiene el color de marca de HeatMapX visible en todo momento.
Mover el Cierre de Sesión del Header al Pie del Sidebar
La implementación inicial tenía un enlace de cierre de sesión en la esquina superior derecha del header. Con el sidebar implementado, se movió a la sección inferior del sidebar. Los motivos:
- El pie del sidebar es la ubicación estándar para acciones "importantes pero poco frecuentes" — Slack, Discord y Notion hacen esto
- El header se mantiene enfocado en controles globales (tema, idioma) y marca
- Reduce el riesgo de cierre de sesión accidental (la esquina superior derecha es una zona de clic de alto tráfico)
Onboarding del Claude Code Skill
HeatMapX está publicado como plugin de Claude Code, por lo que la página /dashboard/cli necesitaba mostrar tanto el CLI como el Skill uno al lado del otro. Un layout de dos columnas en pantallas más grandes:
// 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 muestra comandos de ejemplo tanto en inglés como en japonés:
// Ejemplo en inglés
"Analyze /pricing with HeatMapX and give me CRO ideas."
// Ejemplo en japonés
"HeatMapX で /pricing を分析して、改善案を提案して。"
De esta forma, los usuarios que prefieren el CLI y los que usan el Claude Code Skill encuentran su camino de onboarding desde la misma página.
Pasos de la Migración
- Crear el componente Sidebar (5 elementos de navegación + 2 elementos Coming Soon, iconos SVG inline)
- Refactorizar layout.tsx a un layout flex (header + sidebar + main)
- Extraer la lista de sitios a /dashboard/sites (mover el contenido de page.tsx a sites/page.tsx)
- Reescribir /dashboard como la nueva pantalla principal (tarjetas de estadísticas + barras de uso + acciones rápidas)
- Añadir páginas de billing / cli / settings (3 nuevos archivos page.tsx)
- Crear el componente PricingPlans (datos compartidos con la LP, estilos diferentes)
- Crear el componente SkillOnboarding (guía del Claude Code Skill)
- Crear el componente LanguagePicker (selector tipo radio para la página de configuración)
Total: 4 nuevos componentes + 5 nuevos archivos page.tsx + actualizaciones a layout.tsx y el page.tsx raíz = 11 archivos. Hecho en una sola sesión de Claude Code, aproximadamente 2 horas.
Compromisos: Las Desventajas de un Layout con Sidebar
En aras de dar credibilidad a este artículo sobre la migración, aquí están los inconvenientes honestos.
- Manejo en móvil: Por debajo del breakpoint
sm:, el sidebar está oculto y necesitas un menú hamburguesa en el header. En el momento de escribir esto está sin terminar — en móvil el sidebar simplemente desaparece, lo cual es un estado intermedio bastante brusco. - Gestión de estado distribuido: Con los datos repartidos en 5 páginas, los valores compartidos como "número de sitios" o "plan actual" se obtienen de forma independiente en cada página. Con Server Components el costo de latencia es pequeño, pero si dependes del estado del cliente, necesitarás un enfoque más deliberado.
- Mayor costo de implementación inicial: Una página significaba un archivo. Cinco páginas más un layout compartido son 11 archivos. Con pocos funcionalidades, esto es claramente excesivo.
- El problema de "¿qué va en la pantalla principal?": Un layout de página única evita esto por completo — simplemente muestras todo. Decidir qué pertenece a la pantalla de resumen es una decisión de diseño más sutil de lo que parece.
- Gestión de URLs compatible hacia atrás: Diseñamos
/dashboardpara que siga funcionando como pantalla principal, de modo que los marcadores existentes no se rompan. Pero si las sub-rutas como/dashboard/clise reorganizan en el futuro, habrá que configurar adecuadamente los redirects 301.
Nuestra opinión: estos son costos que vale la pena pagar una vez que tienes más de tres funcionalidades distintas. Y lo contrario también es igualmente cierto — si tu producto es todavía temprano y tiene pocas funcionalidades, un layout de página única es probablemente la decisión correcta.
Conclusión
Next.js App Router te da una estructura poderosa para migraciones a múltiples páginas — las nuevas páginas son solo nuevos directorios. Los sidebars sticky coexisten con los headers sticky via top-14 + h-[calc(100vh-3.5rem)]. El estado activo es una verificación con usePathname. Los elementos Coming Soon son <div> + aria-disabled. Y compartir un único diccionario i18n como SSOT entre la LP y el dashboard previene accidentes en las actualizaciones de precios.
Juntos, estos patrones hacen posible pasar de "volcado de página única" a "UI SaaS multi-nivel escalable al estilo Supabase" con casi ninguna barrera de entrada para nuevas páginas. Dicho esto, los compromisos mencionados arriba son reales — lo que significa que el momento correcto para migrar es cuando superas tres funcionalidades distintas, no antes.