En aplicaciones web de nivel empresarial, las páginas comparten marcos estructurales comunes: barras de navegación, menús laterales (sidebars), widgets de perfil, reproductores de audio globales y pies de página persistentes. En las SPAs tradicionales de cliente, mantener estas estructuras durante la navegación presentaba graves dificultades:
Next.js App Router soluciona estos problemas a nivel fundamental mediante una clara separación de responsabilidades entre Layouts y Templates.
El Layout mantiene viva su instancia: durante la navegación entre páginas hijas nunca se desmonta, preserva el foco de entrada, el estado local de React y no repite peticiones al servidor.
El Template se recrea desde cero: en cada cambio de URL la instancia previa se destruye (unmount), reiniciando el estado y reejecutando animaciones CSS y hooks de ciclo de vida.
En la arquitectura de React Fiber, un layout (layout.tsx) es un nodo estable del árbol virtual cuyo estado local, nodos del DOM y foco de entrada son persistentes. Las rutas hijas se entregan al layout exclusivamente mediante la prop children. Cuando cambia la URL anidada, React ejecuta una Reconciliación de Subárbol (Subtree Reconciliation): únicamente se actualiza el contenido de children, manteniendo el nodo del layout intacto.
La jerarquía de layouts reproduce con exactitud la estructura de directorios:
template.tsx) se compilan como hijos directos del layout del segmento correspondiente, pero envueltos con una clave dinámica key={currentSegmentId}, forzando la recreación completa del nodo en cada navegación.Haga clic en las rutas para observar el aislamiento de Preserved State en layouts
Cambie de ruta: ¡este texto no se reiniciará!
Navegación interna en el panel: ¡DashboardLayout NO se vuelve a montar! El texto escrito se conserva.
La secuencia de renderizado de layouts y templates durante la navegación está regida por las fases de Fiber Reconciliation:
El usuario hace clic en un enlace navegando de /dashboard/orders a /dashboard/analytics.
React compara los segmentos y determina que app/dashboard/layout.tsx es un ancestro común. Su instancia en Fiber se conserva.
Next.js actualiza la clave del wrapper Template (key='analytics'). El nodo anterior se desmonta (unmount), reiniciando el estado.
Se monta una instancia nueva de Template y de la página hija. Se disparan los efectos de entrada y animaciones CSS.
El nuevo subárbol se inyecta en la ranura children del layout padre sin re-renderizar el marco de navegación exterior.
En la barra lateral de app/dashboard/layout.tsx, un usuario escribe una búsqueda en un input local y luego hace clic en un enlace a otra página hija. ¿Qué ocurre con el texto del input?
El Root Layout se define en app/layout.tsx y es obligatorio para cualquier aplicación Next.js. Es el único componente autorizado para declarar las etiquetas <html> y <body>.
import type { Metadata } from 'next';
import './globals.css';
export const metadata: Metadata = {
title: {
template: '%s | Fullstack Agentic Lab',
default: 'Fullstack Agentic Lab',
},
description: 'Plataforma de Ingeniería Fullstack y Desarrollo con IA',
};
interface RootLayoutProps {
children: React.ReactNode;
}
export default function RootLayout({ children }: RootLayoutProps) {
return (
<html lang="es" className="dark">
<body className="min-h-screen bg-bg-base text-text-primary antialiased">
<header className="border-b border-border p-4">Navegación Global</header>
<main>{children}</main>
</body>
</html>
);
}A partir de Next.js 15, cuando un layout se sitúa dentro de una ruta dinámica como app/[tenant]/layout.tsx, el parámetro params se recibe como una Promise:
interface TenantLayoutProps {
children: React.ReactNode;
params: Promise<{ tenant: string }>;
}
export default async function TenantLayout({ children, params }: TenantLayoutProps) {
// Resolución asíncrona de los parámetros de ruta en el layout
const { tenant } = await params;
return (
<div className="tenant-container flex min-h-screen">
<aside className="tenant-sidebar w-64 border-r border-border p-4">
<h2 className="text-lg font-bold">Organización: {tenant}</h2>
</aside>
<section className="tenant-content flex-1 p-6">
{children}
</section>
</div>
);
}A diferencia de page.tsx, los componentes de servidor layout.tsx no reciben la prop searchParams.
Esta decisión es esencial para la eficiencia de la caché:
?page=2&sort=asc), el servidor tendría que re-renderizar toda la estructura padre en cada cambio de paginación o filtro.searchParams permite almacenarlo en la memoria del navegador (Client Router Cache) a largo plazo durante la sesión activa.Si un componente de cliente anidado dentro del layout requiere acceso a los parámetros de búsqueda, utilice el hook de cliente useSearchParams() de next/navigation.
Next.js compila el archivo template.tsx como el envoltorio directo de la página dentro del slot children del layout:
<Layout>
{/* El Template se envuelve con la clave dinámica del segmento actual */}
<Template key={currentSegmentId}>
<ErrorBoundary fallback={<Error />}>
<Suspense fallback={<Loading />}>
<Page />
</Suspense>
</ErrorBoundary>
</Template>
</Layout>Cuando currentSegmentId cambia de orders a analytics, la mutación de la prop key ordena al motor de React Fiber: «Destruye el nodo previo del DOM y monta una instancia completamente nueva desde cero».
Debe implementar una animación CSS de aparición (fade-in) cada vez que el usuario navega entre las secciones del panel de administración, y además registrar el evento de analítica page_view. ¿Qué archivo debe utilizar?
'use client';
import { useEffect } from 'react';
import { usePathname } from 'next/navigation';
interface TemplateProps {
children: React.ReactNode;
}
export default function DashboardTemplate({ children }: TemplateProps) {
const pathname = usePathname();
useEffect(() => {
// Registro de analítica garantizado en cada cambio de ruta
console.log(`[Telemetry] Page view registered: ${pathname}`);
}, [pathname]);
return (
<div className="animate-in fade-in duration-300">
{children}
</div>
);
}Un requerimiento frecuente es integrar un contexto de React de cliente (tema visual, idioma) en el layout raíz sin convertir todo el layout en un Client Component:
'use client';
import { createContext, useContext, useState } from 'react';
type Theme = 'dark' | 'light';
const ThemeContext = createContext<{
theme: Theme;
toggleTheme: () => void;
}>({
theme: 'dark',
toggleTheme: () => {},
});
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>('dark');
const toggleTheme = () => {
setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'));
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export const useTheme = () => useContext(ThemeContext);import { ThemeProvider } from '@/components/providers/ThemeProvider';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="es">
<body>
{/* ThemeProvider es un límite de cliente, pero {children} se entrega como RSC desde el servidor sin sobrecargar el bundle JS. */}
<ThemeProvider>
{children}
</ThemeProvider>
</body>
</html>
);
}import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
interface AdminLayoutProps {
children: React.ReactNode;
}
export default async function AdminLayout({ children }: AdminLayoutProps) {
// Lectura asíncrona de cookies en Next.js 15+
const cookieStore = await cookies();
const sessionToken = cookieStore.get('auth_token')?.value;
if (!sessionToken) {
redirect('/login?from=/admin');
}
return (
<div className="admin-shell min-h-screen bg-bg-surface">
<header className="border-b border-border p-4 font-bold">
Panel de Administración
</header>
<main className="p-6">{children}</main>
</div>
);
}Frecuentemente se intenta acceder a parámetros de consulta mediante props.searchParams en un layout de servidor, obteniendo siempre undefined.
Síntoma: Los filtros o paginaciones se evalúan como undefined dentro del layout.
Causa: Los layouts de servidor están deliberadamente aislados de searchParams para maximizar la permanencia en la Router Cache.
Solución: Lea los parámetros en el page.tsx de servidor o consuma el hook useSearchParams() en un componente de cliente aislado.
Añadir 'use client' a app/layout.tsx transforma todo el árbol de componentes inferior en Client Components, bloqueando el streaming del servidor y disparando el peso del bundle.
Síntoma: Operaciones de servidor fallan en archivos hijos y el bundle de JavaScript aumenta drásticamente.
Causa: La directiva 'use client' establece una frontera de serialización que convierte en cliente todas las importaciones descendientes.
Solución: Conserve el Root Layout como Server Component. Encapsule proveedores de cliente en componentes hoja y pase las páginas de servidor mediante {children}.
A diferencia de Pages Router donde _document.tsx inyectaba estas etiquetas de manera implícita, en App Router app/layout.tsx debe declararlas explícitamente.
Síntoma: Error fatal de compilación en Turbopack: "Root layout must contain <html> and <body> tags."
Solución: Asegúrese de que el layout superior retorne las etiquetas base <html> y <body>.