Next.js App Router en la práctica: grupos de rutas y layouts anidados para proyectos grandes

En el explorador de VS Code, el árbol de app muestra más de 120 carpetas apretadas. Buscas la página de gestión de usuarios del panel y pasas cinco minutos saltando entre dashboard-user-list, admin-users y backend-user-management: todas parecen la correcta.
En la revisión de código, Xiaoli añade la ruta /about y choca con la /about que marketing subió la semana pasada. Os miráis: «Tu about es sobre nosotros; el mío, sobre el producto. ¿Por qué tengo que cambiar yo?»
No es la primera vez. Al inicio había una docena de páginas y la estructura plana se veía limpia. Seis meses después, diez veces más funcionalidad y app parece un armario sin orden: sabes que está ahí, pero cada búsqueda es un revoltijo.
Si trabajas con Next.js, si el equipo supera tres personas o si pasas de 50 páginas, probablemente vivas lo mismo. La buena noticia: App Router trae cuatro herramientas para esto: grupos de rutas, layouts anidados, rutas paralelas e interceptadas. Pero la mayoría de tutoriales son demos «Hello World»; en un proyecto real sigues perdido.
En este artículo uso un e-commerce real para ver cómo aplicar las cuatro y reorganizar un directorio caótico en algo mantenible y escalable.
Dolores habituales: tres problemas de la estructura tradicional
El lío de un directorio plano
Así era nuestra estructura antes:
app/
├── page.tsx # Inicio
├── about/page.tsx # Sobre nosotros
├── products/page.tsx # Listado de productos
├── product-detail/[id]/page.tsx
├── cart/page.tsx
├── checkout/page.tsx
├── dashboard/page.tsx # Inicio del panel
├── dashboard-users/page.tsx
├── dashboard-users-active/page.tsx
├── dashboard-users-blocked/page.tsx
├── dashboard-orders/page.tsx
├── dashboard-orders-pending/page.tsx
├── dashboard-settings/page.tsx
├── auth-login/page.tsx # Login
├── auth-register/page.tsx
└── ... (80+ carpetas más)
Duele solo mirarlo. Peor aún, las URLs quedan raras: /dashboard-users-active en lugar de /dashboard/users/active. Para evitar choques añadimos prefijos a las carpetas, pero eso solo maquilla el problema.
No distingues de un vistazo qué es público, qué es panel o qué es autenticación. Un nuevo miembro tarda días en orientarse.
Layouts duplicados y mantenimiento difícil
El front y el panel tienen layouts muy distintos: navegación y pie en el front; sidebar y permisos en el panel. Lo clásico es importar el layout en cada página:
// app/dashboard-users/page.tsx
import DashboardLayout from '@/components/DashboardLayout'
export default function UsersPage() {
return (
<DashboardLayout>
<div>Contenido de gestión de usuarios</div>
</DashboardLayout>
)
}
Problemas: olvidas el layout en una página nueva y queda desnuda; unos usan DashboardLayout y otros AdminLayout; cambiar el sidebar del panel implica revisar 20 archivos. Cada cambio de layout da miedo.
Modales y rutas: el callejón sin salida
Producto pide: en el listado, clic en un producto abre un modal con detalle y la URL pasa a /product/123 para compartir. Suena razonable; implementarlo, un dolor.
Lo habitual: estado en cliente, modal manual, URL a mano. Código feo y, al refrescar, el modal desaparece.
Dos versiones — modal y página completa — duplican mantenimiento. Cada cambio de lógica hay que hacerlo dos veces.
La experiencia tipo Instagram — modal en el feed, página completa al refrescar — parece simple con rutas clásicas, pero no lo es.
Grupos de rutas (Route Groups): agrupar sin tocar la URL
Qué son
Paréntesis alrededor del nombre de carpeta, p. ej. (marketing): ese nombre no aparece en la URL. Mejor verlo en código:
app/
├── (marketing)/ # Grupo de marketing
│ ├── layout.tsx # Layout del front
│ ├── page.tsx # URL: /
│ ├── about/page.tsx # URL: /about
│ └── products/page.tsx # URL: /products
├── (shop)/ # Grupo e-commerce
│ ├── layout.tsx
│ ├── cart/page.tsx # URL: /cart
│ └── checkout/page.tsx # URL: /checkout
└── (dashboard)/ # Grupo panel
├── layout.tsx
├── dashboard/page.tsx # URL: /dashboard
├── users/page.tsx # URL: /users (¡no /dashboard/users!)
└── orders/page.tsx # URL: /orders
(marketing), (shop) y (dashboard) no salen en la URL. (marketing)/about/page.tsx sigue siendo /about, no /marketing/about.
¿Para qué entonces? El valor está en organizar código, no en cambiar rutas. Agrupas por negocio, equipo o módulo; la estructura se lee clara y la URL sigue corta.
Caso práctico: un grupo por equipo
Tres equipos: marketing (web y landing), producto (tienda), backend (admin). Antes, todos en el mismo app y conflictos constantes. Con grupos:
app/
├── (team-marketing)/ # Equipo marketing
│ ├── layout.tsx
│ ├── page.tsx # Inicio
│ ├── about/page.tsx
│ └── pricing/page.tsx
├── (team-product)/ # Equipo producto
│ ├── layout.tsx
│ ├── products/page.tsx
│ └── product/[id]/page.tsx
└── (team-backend)/ # Equipo backend
├── layout.tsx
├── dashboard/page.tsx
└── admin/page.tsx
Ventajas:
- Menos conflictos de archivos. Cada equipo en su carpeta; menos choques en Git.
- PRs más claros. Ves de inmediato qué equipo toca el cambio.
- Layouts independientes. Cada grupo con su
layout.tsxsin importarlo página a página.
Otro caso: agrupar por tipo de layout
app/
├── (with-nav)/ # Páginas con barra superior
│ ├── layout.tsx
│ ├── page.tsx
│ ├── about/page.tsx
│ └── products/page.tsx
├── (fullscreen)/ # Pantalla completa
│ ├── layout.tsx
│ └── video/[id]/page.tsx
└── (auth)/ # Auth (layout mínimo)
├── layout.tsx
├── login/page.tsx
└── register/page.tsx
Login y registro sin nav ni pie; vídeo a pantalla completa en su grupo.
Precauciones
Dos grupos no pueden resolver la misma ruta. No puedes tener (marketing)/about/page.tsx y (shop)/about/page.tsx: ambos son /about y Next.js falla.
Planifica rutas únicas o renombra, p. ej. (shop)/about-us/page.tsx.
Nombra grupos con sentido: (marketing), (dashboard), (auth), no (group1).
Layouts anidados (Nested Layouts): herencia automática
Cómo funcionan
Los grupos ordenan carpetas; falta la jerarquía de layouts. En un panel suele haber:
- Nivel 1: barra superior + sidebar (todas las páginas del panel)
- Nivel 2: pestañas del módulo usuarios (activos, bloqueados)
- Nivel 3: contenido de la página
Coloca layout.tsx en cada nivel y se anidan solos:
app/(dashboard)/
├── layout.tsx # Nivel 1: nav + sidebar
├── users/
│ ├── layout.tsx # Nivel 2: pestañas usuarios
│ ├── active/page.tsx # /users/active
│ └── blocked/page.tsx # /users/blocked
└── orders/
├── layout.tsx # Nivel 2: pestañas pedidos
├── pending/page.tsx
└── completed/page.tsx
En /users/active:
DashboardLayout (nivel 1)
└─ UsersLayout (nivel 2)
└─ ActiveUsersPage (página)
Código:
// app/(dashboard)/layout.tsx - Nivel 1
export default function DashboardLayout({
children
}: {
children: React.ReactNode
}) {
return (
<div className="dashboard-container">
<TopBar />
<div className="content-area">
<Sidebar />
<main>{children}</main>
</div>
</div>
)
}
// app/(dashboard)/users/layout.tsx - Nivel 2
export default function UsersLayout({
children
}: {
children: React.ReactNode
}) {
return (
<div className="users-section">
<div className="tabs">
<Link href="/users/active">Usuarios activos</Link>
<Link href="/users/blocked">Usuarios bloqueados</Link>
</div>
{children}
</div>
)
}
// app/(dashboard)/users/active/page.tsx - Página
export default function ActiveUsersPage() {
return <div>Lista de usuarios activos...</div>
}
La página no importa layouts: Next.js los anida.
Ventaja de renderizado parcial
Al pasar de «activos» a «bloqueados»:
- El layout de nivel 1 (nav, sidebar) no se re-renderiza
- El de nivel 2 (pestañas) tampoco
- Solo cambia el contenido de la página
Resultado: mejor rendimiento y estado en cliente conservado (p. ej. búsqueda en el sidebar no se borra al cambiar de ruta).
Caso: navegación multinivel
app/(dashboard)/
├── layout.tsx # Nivel 1: barra + sidebar
├── users/
│ ├── layout.tsx # Nivel 2: zona usuarios
│ ├── active/page.tsx # Nivel 3
│ └── blocked/page.tsx
└── orders/
├── layout.tsx # Nivel 2: zona pedidos
├── pending/page.tsx
└── completed/page.tsx
Consejos de rendimiento
Los layouts son Server Components por defecto. Si hay interacción (búsqueda, menús), extrae un Client Component:
// app/(dashboard)/layout.tsx - Server Component
import SearchBar from '@/components/SearchBar' // Client Component
export default function DashboardLayout({ children }) {
return (
<div>
<SearchBar />
<main>{children}</main>
</div>
)
}
// components/SearchBar.tsx
'use client'
import { useState } from 'react'
export default function SearchBar() {
const [query, setQuery] = useState('')
// ...lógica de interacción
}
Añade loading.tsx por nivel para estados de carga independientes:
app/(dashboard)/
├── layout.tsx
├── loading.tsx
└── users/
├── layout.tsx
├── loading.tsx
└── active/
├── page.tsx
└── loading.tsx
Rutas paralelas (Parallel Routes): varias páginas a la vez
Qué resuelven
Un dashboard suele mostrar módulos independientes: analítica, equipo, notificaciones. Cada uno con datos y tiempos distintos. En una sola página, un panel lento bloquea todo.
Las rutas paralelas dividen slots (@) con loading y error propios.
Sintaxis básica
Carpetas que empiezan por @:
app/dashboard/
├── layout.tsx
├── @analytics/page.tsx
├── @team/page.tsx
├── @notifications/page.tsx
└── page.tsx
En layout.tsx recibes los slots como props:
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
analytics,
team,
notifications,
}: {
children: React.ReactNode
analytics: React.ReactNode
team: React.ReactNode
notifications: React.ReactNode
}) {
return (
<div className="dashboard-grid">
<div className="main-content">{children}</div>
<div className="top-panels">
<div className="panel">{analytics}</div>
<div className="panel">{team}</div>
</div>
<div className="bottom-panel">{notifications}</div>
</div>
)
}
Cada slot con su loading.tsx y error.tsx:
app/dashboard/
├── @analytics/
│ ├── page.tsx
│ ├── loading.tsx
│ └── error.tsx
├── @team/
│ ├── page.tsx
│ ├── loading.tsx
│ └── error.tsx
└── @notifications/
├── page.tsx
├── loading.tsx
└── error.tsx
Si analítica tarda, solo ese panel muestra carga; un error no tumba la página entera.
Renderizado condicional
Solo admins ven el panel de equipo:
// app/dashboard/layout.tsx
import { auth } from '@/lib/auth'
export default async function DashboardLayout({
analytics,
team,
notifications,
}) {
const user = await auth()
const isAdmin = user?.role === 'admin'
return (
<div className="dashboard-grid">
<div>{analytics}</div>
{isAdmin && <div>{team}</div>}
<div>{notifications}</div>
</div>
)
}
Rol de default.tsx
Al ir de /dashboard a /dashboard/settings, un slot puede no tener página. default.tsx evita el error:
// app/dashboard/@team/default.tsx
export default function Default() {
return null
}
Cuándo usarlas
Menos universal que grupos y layouts anidados. Encajan en:
- Dashboard multipanel con carga independiente
- A/B testing por slot
- Permisos que ocultan módulos
Si solo apilas contenido estático, basta un page.tsx.
Rutas interceptadas (Intercepting Routes): modales con URL
Experiencia tipo Instagram
Es la más difícil de las cuatro. En Instagram, clic en una foto del feed: modal con zoom y URL /photo/abc123.
- Refrescar → página completa, sin modal
- Compartir URL → página completa para quien abre el enlace
- Cerrar → vuelves al feed
URL compartible, contexto al refrescar y navegación fluida. Difícil sin interceptación.
Sintaxis
(.)mismo nivel(..)nivel superior(..)(..)dos niveles arriba(...)desde la raíz
app/
├── products/
│ ├── page.tsx # Listado
│ └── (..)product/[id]/page.tsx # Intercepta /product/123 como modal
└── product/
└── [id]/page.tsx # Página completa
En /products, <Link href="/product/123">:
- Navegación cliente: intercepta → modal
- URL directa o refresh: página completa
Caso: modal de detalle de producto
app/
├── (shop)/
│ └── products/
│ ├── page.tsx
│ └── (..)product/[id]/page.tsx
└── product/
└── [id]/page.tsx
Modal:
// app/(shop)/products/(..)product/[id]/page.tsx
'use client'
import { useRouter } from 'next/navigation'
import Modal from '@/components/Modal'
import ProductDetail from '@/components/ProductDetail'
export default function ProductModal({
params
}: {
params: { id: string }
}) {
const router = useRouter()
return (
<Modal onClose={() => router.back()}>
<ProductDetail id={params.id} />
</Modal>
)
}
Página completa:
// app/product/[id]/page.tsx
import ProductDetail from '@/components/ProductDetail'
export default function ProductPage({
params
}: {
params: { id: string }
}) {
return (
<div className="product-page">
<ProductDetail id={params.id} />
</div>
)
}
ProductDetail se reutiliza; cambia el contenedor (modal vs página).
Con rutas paralelas
app/(shop)/products/
├── layout.tsx
├── page.tsx
├── @modal/
│ ├── (..)product/[id]/page.tsx
│ └── default.tsx
// app/(shop)/products/layout.tsx
export default function ProductsLayout({
children,
modal,
}: {
children: React.ReactNode
modal: React.ReactNode
}) {
return (
<>
{children}
{modal}
</>
)
}
// app/(shop)/products/@modal/default.tsx
export default function Default() {
return null
}
Modal y contenido principal separados; estado más claro.
Precauciones
- Solo en navegación cliente. URL directa o F5 → no intercepta.
- Dos versiones que mantener (aunque compartas componentes).
- Coincidencia por ruta URL, no por carpetas. Los grupos no cambian la URL;
(..)desde/productsapunta a/product/[id].
Cuándo usarlas
Encajan en galerías, detalle de producto en modal, login en modal con /login accesible directo.
No las necesitas para popups sin URL ni sin deep linking.
Caso integrado: estructura completa de e-commerce
Requisitos
Front (usuario):
- Inicio, about (marketing)
- Productos, detalle (con modal)
- Carrito, checkout
Panel (admin):
- Dashboard (analítica, equipo, notificaciones)
- Usuarios (activos, bloqueados)
- Pedidos (pendientes, completados)
Auth:
- Login, registro (layout sin nav)
Estructura final
app/
├── layout.tsx # Layout raíz
│
├── (marketing)/ # Grupo marketing
│ ├── layout.tsx
│ ├── page.tsx # /
│ ├── about/page.tsx # /about
│ └── pricing/page.tsx # /pricing
│
├── (shop)/ # Grupo tienda
│ ├── layout.tsx
│ ├── products/
│ │ ├── layout.tsx
│ │ ├── page.tsx # /products
│ │ └── @modal/
│ │ ├── (..)product/[id]/page.tsx
│ │ └── default.tsx
│ ├── cart/page.tsx # /cart
│ └── checkout/page.tsx # /checkout
│
├── product/
│ └── [id]/page.tsx # /product/123 (página completa)
│
├── (dashboard)/ # Grupo panel
│ ├── layout.tsx
│ ├── dashboard/
│ │ ├── layout.tsx
│ │ ├── page.tsx # /dashboard
│ │ ├── @analytics/
│ │ │ ├── page.tsx
│ │ │ ├── loading.tsx
│ │ │ └── default.tsx
│ │ ├── @team/
│ │ │ ├── page.tsx
│ │ │ ├── loading.tsx
│ │ │ └── default.tsx
│ │ └── @notifications/
│ │ ├── page.tsx
│ │ ├── loading.tsx
│ │ └── default.tsx
│ ├── users/
│ │ ├── layout.tsx
│ │ ├── active/page.tsx # /users/active
│ │ └── blocked/page.tsx # /users/blocked
│ └── orders/
│ ├── layout.tsx
│ ├── pending/page.tsx # /orders/pending
│ └── completed/page.tsx # /orders/completed
│
└── (auth)/ # Grupo auth
├── layout.tsx
├── login/page.tsx # /login
└── register/page.tsx # /register
Comparación con estructura plana
| Dimensión | Estructura plana | Grupos + layouts anidados |
|---|---|---|
| Buscar archivos | 100+ archivos, prefijos confusos | Agrupado por módulo, claro |
| Layouts | Import manual en cada página | Herencia automática |
| Equipo | Un solo directorio, muchos conflictos | Carpetas por equipo/módulo |
| URLs | Prefijos (dashboard-users-active) | URLs limpias (/users/active) |
| Modales | Estado cliente, se pierde al refrescar | Rutas, refresh → página completa |
| Rendimiento | Re-render de layouts | Render parcial |
Beneficios reales
- ~50 % más rápido encontrar páginas.
- Un solo layout.tsx para cambiar el sidebar del panel.
- ~60 % menos conflictos Git entre equipos.
- Onboarding más rápido para nuevos miembros.
Consejos prácticos
- No reestructures todo de golpe. Empieza por el panel.
- Nombres claros:
(marketing),(shop),(dashboard),(auth). - Documenta la estructura en el README.
- TypeScript con alias de rutas:
// tsconfig.json
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"],
"@/components/*": ["./src/components/*"],
"@/app/*": ["./src/app/*"]
}
}
}
Buenas prácticas y precauciones
Convención de nombres de grupos
Recomendado:
(marketing)— marketing(dashboard)o(admin)— panel(auth)— autenticación(team-xxx)— por equipo(feature-xxx)— por función
Evitar:
(group1),(group2)(temp),(test)- Nombres largos como
(marketing-and-sales-pages)
El nombre es para desarrolladores; que sea obvio.
Evitar conflictos de rutas
❌ Incorrecto:
app/
├── (marketing)/about/page.tsx # URL: /about
└── (shop)/about/page.tsx # URL: /about — conflicto
Soluciones: mapa de rutas único, prefijos distintos o niveles diferentes.
Cuándo usar rutas paralelas
Sí:
- Paneles con datos independientes
- Loading por módulo
- Permisos condicionales
- A/B por slot
No:
- Contenido apilado simple
- Sin loading independiente
- Zonas estáticas
Si dudas, probablemente no las necesitas.
Límites de rutas interceptadas
- Solo navegación cliente.
- Dos versiones que mantener.
(..)sigue la URL, no la carpeta del sistema de archivos.
Sin URL compartible, un modal cliente basta.
Optimización de rendimiento
- Layouts como Server Components; cliente solo donde haga falta.
loading.tsxpor nivel.Suspensecon loading para streaming fino.- No más de ~4 niveles de layout anidado.
Estrategia de migración
Desde Pages Router:
- Migración incremental (
appypagescoexisten). - Primero layouts con grupos y anidación.
- Después datos:
getServerSideProps→fetch, etc. - Rutas:
getStaticPaths→generateStaticParams. - Feature flags para rollback.
Colaboración en equipo
- Norma de estructura en README/Wiki.
- En PR: conflictos de rutas y anidación correcta.
- ESLint para convenciones de carpetas.
- Refactor periódico de rutas obsoletas.
Depuración
- React DevTools — árbol de layouts.
- console.log en layouts — ¿re-render en cada navegación?
- Network — SSR vs cliente.
- Terminal Next.js — avisos de conflicto y layouts.
Conclusión
La escena del inicio: 120 carpetas, cinco minutos para encontrar una página.
Si tu proyecto Next.js vive esto, estas cuatro piezas ayudan:
Grupos de rutas — organización por negocio o equipo sin alargar URLs.
Layouts anidados — herencia automática; un cambio, todas las páginas del nivel.
Rutas paralelas — módulos independientes con loading y error propios.
Rutas interceptadas — modales con URL compartible y página completa al refrescar.
Empieza pequeño: un módulo piloto, valida y extiende. Migración incremental, riesgo controlado.
Abajo tienes la plantilla completa del e-commerce para copiar o adaptar. Si te queda alguna duda, comenta.
¡Que tu app deje de ser un caos y sea un placer mantenerlo!
Flujo completo para reestructurar directorios en proyectos Next.js grandes
Reorganiza una estructura caótica con grupos de rutas, layouts anidados, rutas paralelas e interceptadas
⏱️ Estimated time: 8 hr
- 1
Step 1: Analizar la estructura actual
Evalúa los problemas actuales:
• Cuenta carpetas (más de 50 → conviene reestructurar)
• Identifica conflictos de rutas
• Localiza layouts duplicados
• Registra los dolores del equipo
Identifica zonas funcionales:
• Marketing (inicio, about, pricing)
• Tienda (productos, carrito, pedidos)
• Panel de administración (usuarios, pedidos, ajustes) - 2
Step 2: Crear grupos de rutas por zona funcional
Usa paréntesis para crear grupos:
• (marketing): páginas de marketing
• (shop): páginas de la tienda
• (dashboard): panel de administración
Notas:
• El nombre del grupo no afecta la URL
• La misma URL no puede estar en varios grupos
• Cada grupo puede tener su propio layout.js - 3
Step 3: Diseñar la jerarquía de layouts anidados
Diseña según la jerarquía de UI:
• Nivel 1: app/layout.js (global)
• Nivel 2: layout.js de zona (p. ej. shop/layout.js)
• Nivel 3: layout.js de detalle (p. ej. shop/products/[id]/layout.js)
Puntos clave:
• Cada nivel solo añade UI propia de ese nivel
• Los layouts hijos heredan del padre
• Al cambiar de página, el layout no se vuelve a renderizar - 4
Step 4: Implementar rutas paralelas (si hace falta)
Crea slots con @:
• @modal: slot de modal
• @sales, @orders: módulos independientes del panel
Recíbelos en layout.js:
• export default function Layout({ children, modal })
• En JSX: {modal}
Cada slot puede tener su loading.js y error.js - 5
Step 5: Implementar rutas interceptadas (si necesitas modales)
Crea rutas interceptadas:
• En @modal: (.)photos/[id]/page.js
• photos/[id]/page.js (página completa)
Sintaxis:
• (.): intercepta rutas del mismo nivel
• (..): intercepta el nivel superior
• (...): intercepta desde la raíz
Debes crear default.js que devuelva null - 6
Step 6: Probar y validar
Comprueba:
• Todas las rutas funcionan
• Los layouts anidan correctamente
• Las transiciones son fluidas
• Los modales se comportan bien
Herramientas de depuración:
• React DevTools para el árbol de componentes
• console.log para contar renders
• Pestaña Network para peticiones
• Salida de terminal de Next.js para avisos
FAQ
¿Cuándo debería usar grupos de rutas?
¿Los grupos de rutas afectan la URL?
¿Los layouts anidados afectan el rendimiento?
¿En qué se diferencian las rutas paralelas de un componente normal?
¿Cómo elegir la sintaxis de rutas interceptadas?
¿Cómo evitar conflictos de rutas?
¿Cuánto tarda reestructurar un proyecto grande?
12 min de lectura · Publicado el: 18 dic 2025 · Actualizado el: 21 ago 2026
Guía completa de Next.js
Si llegaste desde búsqueda, lo más rápido es ir al artículo anterior o siguiente de esta misma serie.
Anterior
Next.js: rutas avanzadas en la práctica — grupos, layouts anidados, rutas paralelas e interceptadas
Guía completa de las cuatro rutas avanzadas de Next.js: grupos de rutas para directorios claros, layouts anidados reutilizables, rutas paralelas para mostrar varias páginas a la vez e interceptadas para modales elegantes. Con ejemplos de código y trampas a evitar.
Parte 4 de 51
Siguiente
Rutas dinámicas y parámetros en Next.js: guía completa de principiante a tipado seguro
Aprende paso a paso el sistema de rutas dinámicas de Next.js 14+. Cubre parámetros dinámicos, rutas catch-all, parámetros opcionales, cuándo usar generateStaticParams y prácticas de tipado seguro con TypeScript. Resuelve la confusión sobre cómo obtener parámetros de ruta, con muchos ejemplos de código.
Parte 6 de 51



Comentarios
Inicia sesión con GitHub para dejar un comentario