Módulo 03: App Router: páginas y layouts
Objetivos del módulo
- Entender cómo el App Router convierte carpetas y ficheros en URL públicas
- Distinguir los ficheros especiales (
page,layout,template,loading,error,not-found) de los ficheros normales que puedes colocar junto a ellos - Escribir el layout raíz y layouts anidados, y saber qué se conserva y qué se vuelve a montar al navegar
- Usar
template.tsxcuando necesites justo lo contrario que un layout: montar de nuevo en cada navegación - Navegar con
<Link>y conocer todas sus opciones:hrefcomo texto u objeto,replace,scroll,prefetch,onNavigateytransitionTypes - Leer y cambiar la ruta desde el cliente con
useRouter,usePathname,useSelectedLayoutSegment(s)yuseLinkStatus - Redirigir desde el servidor con
redirectypermanentRedirect, y evitar la trampa deltry/catch - Saber qué es la navegación suave, qué precarga Next y cómo verlo en la pestaña Red
- Comprender a fondo cómo Next.js 16 conserva el estado de las páginas al navegar gracias a
<Activity>, qué límites tiene y cómo controlar lo que se reinicia - Montar la estructura de la aplicación EMPRESA: tienda pública con carrito en el layout y zona de gestión con pestañas y resumen
- Depurar navegaciones con las herramientas del navegador: peticiones RSC, cabeceras, payload y rutas ocultas
- Reconocer los mensajes de error reales de Next al romper una convención, y saber corregirlos
💡 Consejo: Todo lo que ves en este módulo se ha compilado con
next buildy ejecutado connext starten Next.js 16.4.3, y se ha repetido en Next.js 15.5.28 para comprobar las diferencias marcadas. Las salidas que aparecen son las reales. Las pruebas de navegación se han hecho con un navegador controlado por Playwright, porque la precarga solo funciona en producción: connext devno la verías.
1. El sistema de ficheros es el enrutador
En una aplicación React con Vite, las rutas se declaran en código: una lista de objetos { path, element } que le pasas a React Router. En Next.js con el App Router no hay lista. La estructura de carpetas dentro de app/ es la tabla de rutas. A esto se le llama enrutamiento basado en el sistema de ficheros (file-system based routing).
La regla tiene dos partes, y conviene aprenderlas por separado:
- Cada carpeta es un segmento de la URL. La carpeta
app/tienda/ofertas/corresponde al camino/tienda/ofertas. - Una carpeta solo es pública si contiene un
page.tsx. Sin él, la carpeta existe en el árbol, pero si visitas su URL obtienes un 404.
Figura 1. Solo las carpetas con page.tsx generan una URL. El resto de ficheros, como pestanas.tsx o la carpeta ui/, conviven con las rutas sin publicarse.
La segunda regla es la que más sorprende a quien viene del Pages Router (el sistema anterior de Next, que verás en el módulo 24). Allí, cualquier fichero dentro de pages/ era una página. En el App Router puedes guardar componentes, estilos, pruebas o utilidades junto a la página que los usa, y nada de eso se convierte en ruta. A esa práctica se la llama colocación (colocation), y la organizarás con más detalle en el módulo 04.
📘 Concepto: Un segmento es cada trozo de la URL entre barras. En
/tienda/ofertashay dos segmentos,tiendayofertas, más el segmento raíz, que corresponde a la carpetaapp/misma. Muchas APIs de Next hablan de segmentos, así que acostúmbrate a la palabra.
1.1. Los ficheros especiales
Dentro de cada carpeta, Next reconoce unos nombres de fichero concretos. Cada uno tiene un papel fijo y Next los encaja automáticamente en el árbol de componentes. Se pueden escribir con extensión .js, .jsx o .tsx; en este curso usarás .tsx.
| Fichero | Para qué sirve | Dónde se ve en el curso |
|---|---|---|
page.tsx | La interfaz propia de la ruta. Hace pública la carpeta | Este módulo |
layout.tsx | Interfaz compartida que envuelve a la página y a las rutas hijas. Se conserva al navegar | Este módulo |
template.tsx | Como un layout, pero se vuelve a montar en cada navegación | Este módulo |
loading.tsx | Interfaz de carga mientras la página llega, mediante <Suspense> | Este módulo y 08 |
error.tsx | Interfaz de error del segmento | Módulo 14 |
not-found.tsx | Interfaz para “no encontrado” | Módulos 04 y 14 |
default.tsx | Contenido por defecto de una ruta paralela | Módulo 15 |
route.ts | Un punto de acceso de API en lugar de una página | Módulo 12 |
Hay más nombres reservados en la raíz del proyecto o de app/ (proxy.ts, instrumentation.ts, favicon.ico, sitemap.ts…), que irán apareciendo en su módulo. Para este módulo bastan los cuatro primeros.
⚠️ Importante: Un
page.tsxy unroute.tsno pueden convivir en la misma carpeta, porque los dos reclaman la misma URL. Tampoco hay forma de que un fichero cualquiera, comoapp/tienda/catalogo.tsx, publique la ruta/tienda/catalogo: tendrías que crearapp/tienda/catalogo/page.tsx.
1.2. La aplicación de ejemplo
Durante el módulo construirás la base de la aplicación EMPRESA: una portada, una zona de tienda con un submenú lateral, una zona de gestión con pestañas, y un par de páginas sueltas. Este es el árbol completo:
app/
├── layout.tsx Layout raíz: <html>, cabecera, pie
├── page.tsx /
├── globals.css
├── ui/ Componentes compartidos (no es ruta)
│ ├── navegacion.tsx Menú principal con enlace activo
│ ├── indicador-carga.tsx useLinkStatus
│ ├── contador.tsx Botón con estado, para los experimentos
│ ├── eventos-navegacion.tsx Registro de cambios de ruta
│ ├── migas.tsx Migas de pan de la tienda
│ ├── boton-volver.tsx
│ ├── enlace-precarga-hover.tsx
│ └── reinicio-al-navegar.tsx bfcacheId
├── acerca/ /acerca
│ ├── page.tsx
│ └── menu-opciones.tsx
├── contacto/page.tsx /contacto
├── antigua/page.tsx /antigua (redirige a /tienda)
├── catalogo-viejo/page.tsx /catalogo-viejo (redirección permanente)
├── enlaces/page.tsx /enlaces (pruebas de precarga)
├── ayuda/ Sección con layout y template (ejercicio 12)
│ ├── layout.tsx, template.tsx, buscador.tsx, valoracion.tsx
│ ├── page.tsx /ayuda
│ ├── envios/page.tsx /ayuda/envios
│ └── devoluciones/page.tsx
├── tienda/
│ ├── layout.tsx Menú lateral y carrito
│ ├── template.tsx Marco que se monta de nuevo en cada página
│ ├── carrito.tsx Contexto del carrito (no es ruta)
│ ├── page.tsx /tienda
│ ├── productos/
│ │ ├── page.tsx /tienda/productos (lee la API EMPRESA)
│ │ └── loading.tsx
│ ├── ofertas/page.tsx /tienda/ofertas
│ └── novedades/page.tsx /tienda/novedades
└── gestion/
├── layout.tsx Título, pestañas y resumen lateral
├── pestanas.tsx Componente de cliente (no es ruta)
├── resumen.tsx Server Component con datos (no es ruta)
├── page.tsx /gestion
├── departamentos/
│ ├── page.tsx /gestion/departamentos (lee la API EMPRESA)
│ └── loading.tsx
├── nuevo/
│ ├── page.tsx /gestion/nuevo
│ └── formulario-empleado.tsx
└── servidor/
├── page.tsx /gestion/servidor (router.refresh)
└── boton-refrescar.tsx
Los ficheros sin page.tsx al lado (carrito.tsx, resumen.tsx, pestanas.tsx…) conviven con las rutas y no generan ninguna URL. Fíjate también en que no todo lo compartido tiene que ir a ui/: pestanas.tsx solo lo usa la zona de gestión, así que vive en su carpeta.
Si quieres seguirlo en tu equipo, parte de un proyecto recién creado con create-next-app (lo viste en el módulo 01), borra el contenido de app/page.tsx y app/layout.tsx, y ve creando los ficheros a medida que aparecen. La página de departamentos usa la API EMPRESA (recursos/empresa-api.zip): arráncala con node server.mjs y quedará en http://localhost:3000/api. Como Next también usa el puerto 3000 por defecto, arranca Next en otro:
npx next dev -p 3001
2. Páginas: page.tsx
Una página es un componente de React exportado por defecto desde un fichero page.tsx. El nombre de la función no importa a Next (podría llamarse Page o Inicio), pero sí importa que la exportación sea default.
// app/tienda/page.tsx
export default function Catalogo() {
return (
<>
<h1 className="text-2xl font-bold">Catálogo</h1>
<p>Todos los productos de EMPRESA.</p>
</>
);
}
Por defecto, las páginas son Server Components: se ejecutan en el servidor (al compilar o en cada petición) y su código no viaja al navegador. Eso tiene dos consecuencias inmediatas:
- Pueden ser
asyncy esperar datos directamente. No necesitasuseEffectni un estado de carga para pedir algo a una API. - No pueden usar hooks de estado ni eventos (
useState,onClick…). Para eso hay que crear un Client Component con'use client', como verás enseguida. La frontera entre ambos tipos se estudia a fondo en el módulo 05; si necesitas repasar qué es un Server Component, tienes el módulo 23 del Curso de React.
Esta es una página asíncrona que lee los departamentos de la API EMPRESA:
// app/gestion/departamentos/page.tsx
const API = process.env.API_URL ?? "http://localhost:3000/api";
type Departamento = {
deptId: number;
nombre: string;
localidad: string;
};
export default async function Departamentos() {
const respuesta = await fetch(`${API}/departamentos`);
const { contenido } = (await respuesta.json()) as { contenido: Departamento[] };
return (
<ul className="list-disc pl-6">
{contenido.map((d) => (
<li key={d.deptId}>
{d.nombre} ({d.localidad})
</li>
))}
</ul>
);
}
La dirección de la API se lee de una variable de entorno con un valor por defecto, para no escribir el puerto en el código. Las variables de entorno se explican en el módulo 13; de momento, si no defines API_URL, se usará http://localhost:3000/api.
2.1. Qué recibe una página
Una página recibe dos props, ambas como promesas:
| Prop | Qué contiene | Ejemplo para /tienda/ofertas?categoria=portatiles |
|---|---|---|
params | Los valores de los segmentos dinámicos ([id]) | {} (esta ruta no tiene segmentos dinámicos) |
searchParams | Los parámetros de la cadena de consulta | { categoria: 'portatiles' } |
Las dos se tratan con detalle en el módulo 04, porque en Next.js 16 son solo asíncronas y eso cambia la forma de escribirlas respecto a versiones anteriores. Además, leer searchParams hace que la página dependa de cada petición, lo que afecta a cómo se prerenderiza (lo viste en el módulo 02). En este módulo trabajarás con páginas que no las necesitan.
2.2. Qué pasa si una página no cumple las reglas
Next es estricto con la forma de un page.tsx, y conviene reconocer sus mensajes. Se probaron dos errores típicos con next dev.
Exportación con nombre en lugar de default. Es fácil equivocarse cuando el resto de componentes del proyecto se exportan con nombre:
// app/errores/sin-default/page.tsx (MAL)
export function SinDefault() {
return <p>Me falta el export default</p>;
}
La petición devolvió un 500 con este error:
Error: The default export is not a React Component in "/errores/sin-default/page"
La solución es añadir default: export default function SinDefault(). Puedes seguir exportando otras cosas con nombre desde el mismo fichero, como metadata, pero el componente de la página tiene que ser la exportación por defecto.
Una página y un route.ts en la misma carpeta. Con los dos ficheros en app/errores/conflicto/, Next no llegó a servir ninguna página de la aplicación; todas respondían 500 y el terminal mostraba:
Error: An issue occurred while preparing your Next.js app
Conflicting route and page at /errores/conflicto: route at /errores/conflicto/route and page at /errores/conflicto/page
Este error es de los que bloquean todo el proyecto, no solo la ruta afectada: Next no puede construir la tabla de rutas. Lo mismo pasa con un error de compilación en cualquier módulo, como verás en el apartado 12. Si de repente todas tus páginas fallan, lee el primer error del terminal: casi siempre está en un fichero concreto que acabas de tocar.
2.3. Tipos de las props
💡 Consejo: Next genera tipos para cada ruta de tu proyecto. En lugar de escribir el tipo de las props a mano, puedes usar los tipos globales
PageProps<'/ruta'>yLayoutProps<'/ruta'>, que se generan al ejecutarnext dev,next buildonext typegen. Existen desde Next.js 15.5, así que valen en ambas versiones. Si escribes una ruta que no existe, TypeScript se queja.
3. Layouts: layout.tsx
Un layout es la interfaz que comparten varias páginas: una cabecera, un menú lateral, un pie. Se define exportando por defecto un componente que recibe children, y Next coloca dentro de children la página (o el layout del nivel inferior) que corresponda a la URL.
La idea clave es que un layout envuelve a todo lo que hay por debajo de su carpeta. El layout de app/tienda/ envuelve a /tienda, /tienda/ofertas y /tienda/novedades. El layout de app/ envuelve a la aplicación entera.
3.1. El layout raíz
Todo proyecto con App Router debe tener un layout raíz, normalmente en app/layout.tsx. Es el único sitio donde escribes las etiquetas <html> y <body>: Next no las añade por ti.
// app/layout.tsx
import type { Metadata } from "next";
import "./globals.css";
import { Navegacion } from "./ui/navegacion";
import { Contador } from "./ui/contador";
import { EventosNavegacion } from "./ui/eventos-navegacion";
export const metadata: Metadata = {
title: { default: "EMPRESA", template: "%s | EMPRESA" },
description: "Tienda y gestión de EMPRESA",
};
export default function LayoutRaiz({ children }: LayoutProps<"/">) {
return (
<html lang="es">
<body className="min-h-screen bg-slate-50 text-slate-800">
<header className="flex items-center gap-6 bg-indigo-900 px-6 py-3 text-white">
<strong className="text-lg">EMPRESA</strong>
<Navegacion />
<div className="ml-auto">
<Contador etiqueta="Layout raíz" />
</div>
</header>
<main className="p-6">{children}</main>
<footer className="px-6 py-4 text-sm text-slate-500">
© EMPRESA. Este pie no se vuelve a renderizar al navegar.
</footer>
<EventosNavegacion />
</body>
</html>
);
}
Fíjate en varios detalles:
lang="es". La plantilla decreate-next-apptraelang="en". Cámbialo: los lectores de pantalla y los traductores automáticos lo usan para saber en qué idioma está la página. El HTML servido empieza, efectivamente, por<html lang="es">.- No hay
<head>. El título y la descripción se declaran con el objetometadata, y Next genera las etiquetas<title>y<meta>. Es la Metadata API, que verás en el módulo 07. Escribir<title>a mano en el layout es un error habitual. - El título es una plantilla. Con
title: { default, template }, cada página declara solo su parte (export const metadata = { title: "Contacto" }) y el layout la completa. La página de contacto se sirvió con<title>Contacto | EMPRESA</title>, y la de Acerca de conAcerca de | EMPRESA. Una página sin título propio, como la portada, usadefault:EMPRESA. Es un buen ejemplo de cómo un layout aporta algo a todas sus páginas sin que estas lo repitan. EventosNavegaciones un componente de cliente que no pinta nada y registra en la consola cada cambio de ruta. Lo verás en el apartado 6.5.- El CSS global se importa aquí, una sola vez, para toda la aplicación.
Contadores un componente de cliente que se ha colocado en el layout para observar qué pasa con su estado al navegar. Lo usarás en varios experimentos de este módulo.
// app/ui/contador.tsx
"use client";
import { useEffect, useState } from "react";
export function Contador({ etiqueta }: { etiqueta: string }) {
const [clics, setClics] = useState(0);
useEffect(() => {
console.log(`[${etiqueta}] montado`);
return () => console.log(`[${etiqueta}] desmontado u ocultado`);
}, [etiqueta]);
return (
<button
type="button"
data-contador={etiqueta}
onClick={() => setClics((c) => c + 1)}
className="rounded border border-current px-2 py-1 text-sm"
>
{etiqueta}: {clics}
</button>
);
}
⚠️ Importante: El layout raíz debe definir
<html>y<body>. Si los olvidas, Next no puede construir el documento y te lo indica con un error. Y al revés: no pongas<html>ni<body>en ningún otro layout, porque acabarías con etiquetas anidadas dentro del<body>, que es HTML no válido y provoca errores de hidratación.
3.2. Layouts anidados
Para que la tienda tenga su propio menú lateral, crea un layout en su carpeta. Recibe children igual que el raíz, pero ya no lleva <html> ni <body>:
// app/tienda/layout.tsx
import Link from "next/link";
import { Contador } from "../ui/contador";
import { Migas } from "../ui/migas";
import { IconoCarrito, ProveedorCarrito } from "./carrito";
export default function LayoutTienda({ children }: LayoutProps<"/tienda">) {
return (
<ProveedorCarrito>
<div className="flex gap-6">
<aside className="w-48 space-y-3 rounded bg-indigo-50 p-4">
<h2 className="font-semibold">Tienda</h2>
<ul className="space-y-1">
<li><Link href="/tienda">Catálogo</Link></li>
<li><Link href="/tienda/productos">Productos</Link></li>
<li><Link href="/tienda/ofertas">Ofertas</Link></li>
<li><Link href="/tienda/novedades">Novedades</Link></li>
</ul>
<IconoCarrito />
<Contador etiqueta="Layout tienda" />
</aside>
<div className="flex-1 space-y-3">
<Migas />
{children}
</div>
</div>
</ProveedorCarrito>
);
}
El layout es un Server Component, pero puede contener componentes de cliente (ProveedorCarrito, IconoCarrito, Migas, Contador) y pasarles como children contenido renderizado en el servidor. El carrito se explica en el caso práctico del apartado 10; por ahora quédate con la idea de que vive en el layout precisamente para que no se pierda al cambiar de página.
Cuando el usuario visita /tienda/ofertas, Next compone el árbol de fuera hacia dentro:
<LayoutRaiz> {/* app/layout.tsx */}
<LayoutTienda> {/* app/tienda/layout.tsx */}
<Ofertas /> {/* app/tienda/ofertas/page.tsx */}
</LayoutTienda>
</LayoutRaiz>
Tú nunca escribes ese anidamiento: Next lo deduce de las carpetas. Por eso un layout no sabe qué página tiene dentro; solo recibe children.
3.3. Qué ocurre con un layout al navegar
Este es el comportamiento que hace útiles a los layouts: cuando navegas entre dos páginas que comparten un layout, el layout no se vuelve a montar. Su estado de React se conserva, sus efectos no se repiten, y en el servidor ni siquiera se vuelve a renderizar. Solo cambia lo que hay dentro de children.
Compruébalo con el contador. En la versión compilada de la aplicación, entra en /tienda, pulsa dos veces el contador de la cabecera (Layout raíz) y tres veces el del menú lateral (Layout tienda). Después pulsa Ofertas en el menú lateral. Esta es la lectura real de los contadores tras la navegación:
En /tienda/ofertas: Layout raíz: 2 | Layout tienda: 3 | Template tienda: 0
Los dos layouts han conservado su cuenta. El tercer contador, el del template, ha vuelto a cero: lo explica el apartado 4.

Figura 2. Captura real después de navegar de /tienda a /tienda/ofertas. Los layouts conservan su estado; el template (marco discontinuo naranja) se ha montado de nuevo.
Este comportamiento tiene una cara buena y una cara a vigilar:
- La buena: un reproductor de audio, un chat abierto o un menú desplegado en el layout siguen funcionando mientras el usuario navega. Y el servidor ahorra trabajo, porque no vuelve a generar lo que ya está en pantalla.
- La que hay que vigilar: como el layout no se vuelve a renderizar, no puede depender de la ruta actual en el servidor. Un layout no recibe
searchParams, y no tiene forma de saber en el servidor cuál es la URL completa. Si un layout quiere resaltar el enlace activo, necesita un componente de cliente que lea la ruta en el navegador. Es justo lo que haceNavegacion, que verás en el apartado 6.
📘 Concepto: Que un layout “no se vuelva a renderizar” significa que su función no se ejecuta otra vez al navegar entre sus páginas hijas. Si un layout pide datos al servidor, esos datos se calculan al entrar en la sección y no se refrescan en cada cambio de página. Si necesitas datos que cambien con cada página, pídelos en la página.
3.4. Varios layouts raíz
Cualquier layout que no tenga otro por encima es un layout raíz. Si en lugar de app/layout.tsx tuvieras app/(tienda)/layout.tsx y app/(gestion)/layout.tsx, cada zona tendría su propio <html> y <body>. Esas carpetas entre paréntesis son grupos de rutas, que no aparecen en la URL y se explican en el módulo 04.
Tiene un precio que conviene conocer: navegar entre dos layouts raíz distintos provoca una recarga completa de la página, no una navegación suave. Es lógico: hay que sustituir el <html> entero.
3.5. Layouts que piden datos 🔄 16.0
🔄 Cambia en Next.js 16.0. En Next.js 15 no existe
cacheComponents: unfetchsin opciones en un layout compila sin error, se ejecuta al compilar y la ruta queda estática (○), con los datos congelados hasta el siguiente build. Si usas Next.js 15: para tener datos actuales, llama aawait connection()(denext/server) al principio deResumeny mantén el<Suspense>del ejemplo; la ruta pasará a ser dinámica (ƒ).
Un layout también puede ser asíncrono y pedir datos. La zona de gestión muestra en un lateral un resumen con el número de empleados, departamentos y clientes, que sale de /api/estadisticas:
// app/gestion/resumen.tsx
const API = process.env.API_URL ?? "http://localhost:3000/api";
type Estadisticas = { empleados: number; departamentos: number; clientes: number };
// Server Component asíncrono: datos que cambian, pedidos en cada petición
export async function Resumen() {
const respuesta = await fetch(`${API}/estadisticas`);
const datos = (await respuesta.json()) as Estadisticas;
return (
<dl data-resumen className="grid grid-cols-2 gap-x-3 text-sm">
<dt>Empleados</dt><dd className="font-semibold">{datos.empleados}</dd>
<dt>Departamentos</dt><dd className="font-semibold">{datos.departamentos}</dd>
<dt>Clientes</dt><dd className="font-semibold">{datos.clientes}</dd>
</dl>
);
}
La primera versión del layout lo colocaba tal cual en el lateral:
// app/gestion/layout.tsx (primera versión: NO compila)
<aside className="w-56 space-y-2 rounded bg-indigo-50 p-4">
<h2 className="font-semibold">Resumen</h2>
<Resumen />
</aside>
next build se detuvo con este error, que merece la pena leer entero porque explica el problema y las tres salidas posibles:
Error: Route "/gestion": Next.js encountered uncached or runtime data during prerendering.
`fetch(...)`, `cookies()`, `headers()`, `params`, `searchParams`, or `connection()` accessed outside of `<Suspense>` prevents the route from being prerendered, blocking the page load and leading to a slower user experience.
Ways to fix this:
- [stream] Provide a placeholder with `<Suspense fallback={...}>` around the data access
- [cache] For uncached data (`fetch`, database calls): cache the access with `"use cache"` (does not apply to `connection()`)
- [block] Set `export const instant = false` to allow a blocking route
Learn more: https://nextjs.org/docs/messages/blocking-prerender-dynamic
at aside (<anonymous>)
at div (<anonymous>)
at main (<anonymous>)
at body (<anonymous>)
at html (<anonymous>)
¿Por qué en el layout sí y en la página de departamentos no, si las dos hacen un fetch sin caché? Por la jerarquía del apartado 4.1: el loading.tsx de una carpeta está por debajo de su layout. El <Suspense> que crea loading.tsx envuelve a la página, pero no al layout. Así que el fetch del layout queda fuera de cualquier <Suspense>, y con Cache Components activado (cacheComponents: true, que trae la plantilla) Next exige que todo dato no cacheado tenga un sitio donde esperar. La pila del error (at aside, at div…) señala dónde está el componente culpable.
La solución más directa es la primera de la lista, un <Suspense> propio alrededor del dato:
// app/gestion/layout.tsx
import { Suspense } from "react";
import { Pestanas } from "./pestanas";
import { Resumen } from "./resumen";
export default function LayoutGestion({ children }: LayoutProps<"/gestion">) {
return (
<div className="flex gap-6">
<div className="flex-1 space-y-4">
<h1 className="text-2xl font-bold">Gestión</h1>
<Pestanas />
<div className="rounded bg-white p-4 shadow">{children}</div>
</div>
<aside className="w-56 space-y-2 rounded bg-indigo-50 p-4">
<h2 className="font-semibold">Resumen</h2>
<Suspense fallback={<p className="text-sm text-slate-500">Calculando...</p>}>
<Resumen />
</Suspense>
</aside>
</div>
);
}
Con este cambio, el build terminó, y la tabla de rutas mostró un efecto colateral importante:
├ ◐ /gestion
├ ◐ /gestion/departamentos
├ ◐ /gestion/nuevo
├ ◐ /gestion/servidor
Todas las rutas de la zona de gestión pasaron a ser de prerenderizado parcial (◐), incluida /gestion/nuevo, que no pide ningún dato. Lo que hace un layout afecta a todas las páginas que envuelve. El armazón (cabecera, pestañas, título del resumen) se genera al compilar y se sirve al instante; el resumen se envía después, por streaming. Las otras dos salidas, cachear con 'use cache' o aceptar una ruta bloqueante, se estudian en el módulo 09.
⚠️ Importante: Recuerda el apartado 3.3: un layout no se vuelve a renderizar al navegar entre sus páginas. El resumen se calcula al entrar en la zona de gestión y no se refresca al pasar de Departamentos a Nuevo empleado. Si un dato del layout debe estar siempre al día, tendrás que refrescarlo explícitamente (
router.refresh(), apartado 6.3) o moverlo a las páginas.
En Next.js 15, la misma aplicación compiló sin quejarse, pero con una tabla de rutas muy distinta: /gestion, /gestion/departamentos y /tienda/productos salieron como estáticas (○), es decir, con los datos de la API leídos una sola vez al compilar. Solo /gestion/servidor, que llama a connection(), salió como dinámica (ƒ). El error de Next.js 16 es incómodo, pero evita un fallo silencioso: datos que crees actuales y que en realidad son de la última compilación.
4. Templates: template.tsx
Un template se escribe igual que un layout, pero se comporta al revés: se vuelve a montar cada vez que cambia el segmento que tiene debajo. Next le asigna una key distinta para cada ruta hija, y en React cambiar la key de un componente equivale a desmontarlo y crear uno nuevo. Si necesitas repasar este truco, está explicado en el Curso de React.
// app/tienda/template.tsx
import { Contador } from "../ui/contador";
export default function TemplateTienda({ children }: { children: React.ReactNode }) {
return (
<div className="animacion-entrada space-y-4 rounded border border-dashed border-orange-400 p-4">
<Contador etiqueta="Template tienda" />
{children}
</div>
);
}
Al navegar de /tienda a /tienda/ofertas, la consola del navegador mostró exactamente esto:
[Template tienda] desmontado u ocultado
[Template tienda] montado
El contador del template empezó de nuevo en cero, el efecto se ejecutó otra vez y el DOM del marco se creó desde cero. Por eso la clase animacion-entrada hace que el contenido aparezca con una pequeña animación en cada página: la animación CSS se dispara cada vez que el elemento se crea.
/* app/globals.css */
@keyframes entrada {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: none; }
}
.animacion-entrada {
animation: entrada 300ms ease-out;
}
4.1. Dónde se coloca cada fichero especial
Dentro de un mismo segmento, Next anida los ficheros especiales siempre en el mismo orden. Conocerlo evita muchas sorpresas:
Figura 3. Jerarquía de los ficheros especiales dentro de un segmento. El template queda entre el layout y todo lo demás.
En código, para la carpeta app/tienda/, sería aproximadamente esto:
<LayoutTienda>
<TemplateTienda key="...">
<ErrorBoundary fallback={<Error />}>
<Suspense fallback={<Cargando />}>
<NotFoundBoundary fallback={<NoEncontrado />}>
<Catalogo />
</NotFoundBoundary>
</Suspense>
</ErrorBoundary>
</TemplateTienda>
</LayoutTienda>
De este orden salen dos reglas prácticas:
- El
error.tsxde una carpeta no captura los errores dellayout.tsxde esa misma carpeta, porque el layout está fuera. Los captura elerror.tsxde la carpeta superior. Lo verás en el módulo 14. - El
template.tsxenvuelve alloading.tsx, así que con un template el estado de carga se vuelve a mostrar en cada navegación, mientras que un<Suspense>dentro de un layout solo lo muestra la primera vez.
4.2. Cuándo usar un template
La documentación propone tres casos, y en la práctica son los únicos:
| Necesitas… | Ejemplo |
|---|---|
| Volver a ejecutar un efecto en cada página | Registrar una visita, enviar una métrica de analítica, mover el foco al título |
| Reiniciar el estado de componentes de cliente | Un campo de búsqueda que debe vaciarse al cambiar de página |
| Mostrar el estado de carga en cada navegación | Una sección donde siempre quieres ver el esqueleto de carga |
Fuera de esos casos, usa un layout. El template es más caro: descarta DOM y estado en cada navegación.
⚠️ Importante: Un template solo se vuelve a montar cuando cambia el segmento justo por debajo de él (incluidos los valores de un segmento dinámico). Si navegas a un nivel más profundo, los templates de niveles superiores no se tocan. Y cambiar solo los
searchParamsno lo vuelve a montar: de/tienda?pagina=1a/tienda?pagina=2, el template de la tienda sigue siendo el mismo.
4.3. Comprobación: qué vuelve a montar el template
El catálogo tiene un enlace que solo cambia la cadena de consulta:
// app/tienda/page.tsx
import Link from "next/link";
export const metadata = { title: "Catálogo" };
export default function Catalogo() {
return (
<>
<h1 className="text-2xl font-bold">Catálogo</h1>
<p>Todos los productos de EMPRESA.</p>
<Link href="/tienda?orden=precio" className="text-indigo-700 underline">
Ordenar por precio
</Link>
</>
);
}
Se pulsaron 3 veces el contador del layout de la tienda y 4 el del template, y después se navegó dos veces: primero a /tienda?orden=precio y luego a /tienda/ofertas. Esta es la lectura real de los contadores tras cada paso, y lo que mostró la consola en el segundo:
/tienda?orden=precio -> Layout tienda: 3 | Template tienda: 4
/tienda/ofertas -> Layout tienda: 3 | Template tienda: 0
Consola: [Template tienda] desmontado u ocultado ; [Template tienda] montado ; [navegación] ahora en /tienda/ofertas
| Navegación | ¿Cambia el segmento bajo tienda/? | Layout de la tienda | Template de la tienda |
|---|---|---|---|
/tienda → /tienda?orden=precio | No (solo la consulta) | Se conserva | Se conserva |
/tienda → /tienda/ofertas | Sí (__PAGE__ → ofertas) | Se conserva | Se monta de nuevo |
/tienda/ofertas → /gestion | Sale de la tienda | Se oculta o se desmonta (apartado 9) | Igual que el layout |
La última fila adelanta algo que verás en el apartado 9: al salir de una sección, lo que pasa con su layout depende de la versión de Next.
5. Navegar con <Link>
El componente <Link> de next/link es la forma principal de moverse por la aplicación. Se renderiza como un <a> normal, con su href, así que funciona sin JavaScript, los buscadores lo siguen y el usuario puede abrirlo en una pestaña nueva. Pero cuando React ya está en marcha en el navegador, intercepta el clic y hace una navegación suave.
// app/page.tsx
import Link from "next/link";
export default function Inicio() {
return (
<section className="space-y-4">
<h1 className="text-2xl font-bold">Bienvenido a EMPRESA</h1>
<p>Elige una zona:</p>
<ul className="list-disc pl-6">
<li>
<Link href="/tienda" className="text-indigo-700 underline">
Catálogo de la tienda
</Link>
</li>
<li>
<Link
href={{ pathname: "/tienda/ofertas", query: { categoria: "portatiles" } }}
className="text-indigo-700 underline"
>
Ofertas en portátiles
</Link>
</li>
<li>
{/* Enlace HTML normal: provoca una carga completa de la página */}
<a href="/acerca" className="text-indigo-700 underline">
Acerca de (con <a>)
</a>
</li>
<li>
<Link href="/antigua" className="text-indigo-700 underline">
Dirección antigua de la tienda
</Link>
</li>
</ul>
</section>
);
}
Cualquier atributo propio de <a> (className, target, aria-current, title…) se puede poner en <Link> y pasa tal cual al elemento final. Desde Next.js 13, <Link> no necesita un <a> hijo: si ves código antiguo con <Link><a>...</a></Link>, es de versiones anteriores.
5.1. Navegación suave frente a recarga completa
Con un <a href> normal, el navegador tira la página entera, pide HTML nuevo, descarga el JavaScript y React hidrata todo desde cero. Con <Link>, Next pide solo lo que falta (o nada, si ya lo había precargado), cambia la URL con la History API y React sustituye solo la parte del árbol que cambia.
Figura 4. Qué sobrevive a cada tipo de navegación.
Una forma sencilla de comprobarlo: guarda una variable en window y navega. Esto es lo que se obtuvo en el navegador, primero pulsando un <Link> y después el <a> de la portada:
Tras <Link>, window.marca = sigo aqui
Tras <a>, window.marca = undefined
Con <Link> la página no se ha recargado: el mismo documento sigue vivo. Con <a>, el documento es nuevo y la variable ha desaparecido. Puedes hacer la prueba tú mismo en la consola de las herramientas de desarrollo con window.marca = 1.
💡 Consejo: Usa
<a>(o<Link>igualmente, que no hace daño) para enlaces externos. Para enlaces internos, usa siempre<Link>. Un<a>interno no es un error grave, pero pierdes el estado de la aplicación, la precarga y la rapidez de la navegación suave.
5.2. La prop href: texto u objeto
href admite una cadena o un objeto con la forma de una URL. El objeto es cómodo cuando la cadena de consulta tiene varios valores, porque Next se encarga de codificarlos:
<Link href={{ pathname: "/tienda/ofertas", query: { categoria: "portatiles" } }}>
Ofertas en portátiles
</Link>
El HTML que generó Next para ese enlace es:
<a class="text-indigo-700 underline" href="/tienda/ofertas?categoria=portatiles">Ofertas en portátiles</a>
Para construir enlaces a rutas dinámicas, como /tienda/productos/7, basta una plantilla de texto: href={`/tienda/productos/${producto.id}`}. Las rutas dinámicas son el tema del módulo 04.
También puedes enlazar a un ancla dentro de una página con href="/acerca#equipo". Next navega y después se desplaza hasta el elemento con ese id.
5.3. replace y scroll
| Prop | Por defecto | Efecto |
|---|---|---|
replace | false | Si es true, sustituye la entrada actual del historial en lugar de añadir una nueva. El botón Atrás del navegador ya no vuelve a la página anterior |
scroll | true | Al navegar, si la nueva página no está a la vista, Next se desplaza hasta su principio. Con false no toca el desplazamiento |
{/* Cambiar de pestaña sin llenar el historial */}
<Link href="/gestion/departamentos" replace>Departamentos</Link>
{/* Cambiar de página en una lista sin saltar arriba */}
<Link href="/tienda?pagina=2" scroll={false}>Siguiente</Link>
replace tiene sentido cuando el usuario no debería poder volver al estado anterior, por ejemplo después de enviar un formulario, o cuando se cambia de pestaña o de filtro y no quieres que el botón Atrás tenga que pasar por cada uno.
💡 Consejo: Si tu cabecera es fija (
position: stickyofixed) y tapa el principio del contenido después de navegar, no es culpa de<Link>. Añadescroll-padding-topen CSS al elementohtmlcon la altura de la cabecera.
5.4. onNavigate: interceptar la navegación
onNavigate es un manejador que se ejecuta solo cuando <Link> va a hacer una navegación suave. Recibe un evento con preventDefault(), que cancela la navegación. Es distinto de onClick:
- Con Ctrl o Cmd pulsado (abrir en pestaña nueva),
onClickse ejecuta yonNavigateno. - Con una URL externa o un enlace con
download,onNavigateno se ejecuta.
Su uso típico es avisar de cambios sin guardar. Este formulario de la zona de gestión lo hace:
// app/gestion/nuevo/formulario-empleado.tsx
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
export function FormularioEmpleado() {
const router = useRouter();
const [nombre, setNombre] = useState("");
const hayCambios = nombre.trim() !== "";
function guardar(evento: React.FormEvent<HTMLFormElement>) {
evento.preventDefault();
// En el módulo 10 esto será una Server Action; aquí solo navegamos
setNombre("");
router.replace("/gestion/departamentos");
}
return (
<form onSubmit={guardar} className="space-y-3">
<label className="block">
Nombre:{" "}
<input
value={nombre}
onChange={(e) => setNombre(e.target.value)}
className="border px-2"
/>
</label>
<div className="flex gap-3">
<button type="submit" className="rounded bg-indigo-700 px-3 py-1 text-white">
Guardar
</button>
<button type="button" onClick={() => router.back()} className="underline">
Cancelar
</button>
<Link
href="/gestion"
onNavigate={(e) => {
if (hayCambios && !window.confirm("Hay cambios sin guardar. ¿Salir?")) {
e.preventDefault();
}
}}
className="underline"
>
Volver al panel
</Link>
</div>
</form>
);
}
Probado en el navegador: se escribe “Marta”, se pulsa Volver al panel y se rechaza el diálogo.
Dialogo: Hay cambios sin guardar. ¿Salir?
Tras cancelar, URL = /gestion/nuevo
Como el componente usa estado y eventos, lleva 'use client'. Lo mismo ocurre con onNavigate: es una función, y las funciones no se pueden pasar desde un Server Component a <Link>.
⚠️ Importante:
onNavigatesolo protege los<Link>en los que lo pongas. No intercepta el botón Atrás del navegador, ni cerrar la pestaña, ni escribir otra URL. Para cerrar la pestaña o recargar está el eventobeforeunloaddel navegador. Úsalo como ayuda para el usuario, no como garantía de que nunca perderá datos.
5.5. transitionTypes 🆕 16.2
🆕 Desde Next.js 16.2. No existe en Next.js 15. Si usas Next.js 15: quita la prop y la navegación funcionará igual, pero sin poder elegir una animación distinta según el enlace. No hay equivalente en
<Link>.
transitionTypes recibe una lista de nombres que Next pasa a React.addTransitionType durante la navegación. Sirve para que los componentes <ViewTransition> de React apliquen una animación u otra según el tipo de navegación, por ejemplo “deslizar a la izquierda” al avanzar y “a la derecha” al retroceder:
<Link href="/tienda/novedades" transitionTypes={["avanzar"]}>
Novedades
</Link>
Por sí sola no anima nada: necesita <ViewTransition>, que se trata en el módulo 15. router.push y router.replace aceptan la misma opción.
6. Hooks de navegación en el cliente
<Link> cubre la mayoría de los casos, pero a veces necesitas leer la ruta actual o navegar desde código. Para eso están los hooks de next/navigation. Como todo hook, solo funcionan en Client Components: el fichero que los usa debe empezar por 'use client'.
⚠️ Importante: Importa siempre de
next/navigation. El paquetenext/routeres del Pages Router y en el App Router no funciona. Es un error muy común al copiar ejemplos antiguos: el editor lo autocompleta y luego la aplicación falla. Lo curioso es que compila: TypeScript no protesta, porquenext/routerexiste. El fallo llega al ejecutar. Una página con'use client'que hacíauseRouter()importado denext/routerrespondió con un 500 y este error:Error: NextRouter was not mounted. https://nextjs.org/docs/messages/next-router-not-mounted
6.1. usePathname: el enlace activo
usePathname() devuelve el camino de la URL actual, sin dominio y sin cadena de consulta: para http://127.0.0.1:3001/tienda/ofertas?categoria=portatiles devuelve /tienda/ofertas. Es lo que necesita un menú para resaltar la sección en la que estás:
// app/ui/navegacion.tsx
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { IndicadorCarga } from "./indicador-carga";
const enlaces = [
{ href: "/", texto: "Inicio" },
{ href: "/tienda", texto: "Tienda" },
{ href: "/gestion", texto: "Gestión" },
{ href: "/ayuda", texto: "Ayuda" },
{ href: "/acerca", texto: "Acerca de" },
{ href: "/contacto", texto: "Contacto" },
];
function estaActiva(ruta: string, href: string) {
if (href === "/") return ruta === "/";
return ruta === href || ruta.startsWith(href + "/");
}
export function Navegacion() {
const ruta = usePathname();
return (
<nav className="flex gap-4">
{enlaces.map(({ href, texto }) => {
const activa = estaActiva(ruta, href);
return (
<Link
key={href}
href={href}
aria-current={activa ? "page" : undefined}
className={
activa
? "rounded bg-white px-2 py-1 font-semibold text-indigo-900"
: "rounded px-2 py-1 hover:bg-indigo-700"
}
>
{texto}
<IndicadorCarga />
</Link>
);
})}
</nav>
);
}
Tres detalles que se suelen hacer mal, y que estaActiva resuelve:
- La portada necesita comparación exacta. Todas las rutas empiezan por
/, así que constartsWith("/")el enlace Inicio estaría siempre activo. - Las subrutas deben activar a su sección: en
/tienda/ofertasse resalta Tienda. Por eso no bastaruta === href. - Cuidado con los prefijos parecidos:
startsWith("/tienda")también sería cierto para una ruta/tiendas-fisicas. Comparar conhref + "/"lo evita.
Además, aria-current="page" indica a los lectores de pantalla cuál es la página actual. El estilo visual no basta.
Observa cómo encaja todo: el layout raíz es un Server Component, y solo Navegacion, que necesita la ruta, es de cliente. No conviertas el layout entero en cliente para resaltar un enlace. ¿Y si lo intentas sin 'use client'? Se probó con una página que llamaba a usePathname sin la directiva, y next dev respondió con un 500 y este error:
Error: You're importing a module that depends on `usePathname` into a React Server Component module. This API is only available in Client Components. To fix, mark the file (or its parent) with the `"use client"` directive.
El mensaje indica la línea exacta del import. Igual que el conflicto de route.ts, este error de compilación hizo que todas las páginas devolvieran 500 hasta corregirlo.
6.2. useSelectedLayoutSegment y useSelectedLayoutSegments
usePathname da la URL completa. A veces lo que quieres saber es más concreto: qué segmento hay activo justo por debajo del layout en el que estoy. Para eso existe useSelectedLayoutSegment(), que se usa en un componente de cliente colocado dentro de un layout. Las pestañas de la zona de gestión lo aprovechan:
// app/gestion/pestanas.tsx
"use client";
import Link from "next/link";
import { useSelectedLayoutSegment } from "next/navigation";
const pestanas = [
{ segmento: null, href: "/gestion", texto: "Panel" },
{ segmento: "departamentos", href: "/gestion/departamentos", texto: "Departamentos" },
{ segmento: "nuevo", href: "/gestion/nuevo", texto: "Nuevo empleado" },
];
export function Pestanas() {
// Segmento activo justo por debajo de este layout: null, "departamentos" o "nuevo"
const activo = useSelectedLayoutSegment();
return (
<div className="flex gap-2 border-b border-indigo-200">
{pestanas.map((p) => (
<Link
key={p.href}
href={p.href}
className={
p.segmento === activo
? "border-b-2 border-orange-500 px-3 py-2 font-semibold"
: "px-3 py-2 text-slate-500"
}
>
{p.texto}
</Link>
))}
</div>
);
}
// app/gestion/layout.tsx
import { Pestanas } from "./pestanas";
export default function LayoutGestion({ children }: LayoutProps<"/gestion">) {
return (
<div className="space-y-4">
<h1 className="text-2xl font-bold">Gestión</h1>
<Pestanas />
<div className="rounded bg-white p-4 shadow">{children}</div>
</div>
);
}
En /gestion devuelve null, porque por debajo del layout solo está la página de la propia carpeta. En /gestion/departamentos devuelve "departamentos". La ventaja frente a usePathname es que el componente no depende de dónde está montada la sección: si mañana gestion pasa a llamarse admin, las pestañas siguen funcionando sin tocar las comparaciones.
useSelectedLayoutSegments() (en plural) devuelve todos los segmentos activos por debajo del layout, como un array. Es perfecto para unas migas de pan, como verás en el ejercicio 4.
📘 Concepto: Ambos hooks miden “desde el layout en el que se usan”. El mismo componente devuelve cosas distintas según el layout donde lo coloques. Si los usas en un componente del layout raíz, equivalen a mirar la URL entera por segmentos.
6.3. useRouter: navegar desde código
useRouter() devuelve un objeto con métodos para navegar sin un enlace. La documentación lo deja claro: usa <Link> siempre que puedas, y useRouter cuando la navegación sea consecuencia de una acción, no de un clic en un enlace.
| Método | Qué hace |
|---|---|
router.push(href, { scroll }) | Navega añadiendo una entrada al historial |
router.replace(href, { scroll }) | Navega sustituyendo la entrada actual del historial |
router.back() | Vuelve a la entrada anterior del historial (como el botón Atrás) |
router.forward() | Avanza a la siguiente entrada del historial |
router.refresh() | Vuelve a pedir al servidor la ruta actual y mezcla el resultado sin perder el estado del cliente |
router.prefetch(href) | Precarga una ruta a mano |
El formulario del apartado 5.4 los usa: Cancelar llama a router.back() y Guardar llama a router.replace("/gestion/departamentos"). ¿Por qué replace y no push? Mira el historial que resulta. El usuario estaba en /gestion, pulsó Nuevo empleado y después Guardar:
Tras guardar, URL = /gestion/departamentos
Tras atras (replace), URL = /gestion
Al pulsar Atrás no vuelve al formulario ya enviado, sino a la página desde la que entró. Con push, Atrás lo devolvería a /gestion/nuevo, a un formulario que ya no tiene sentido.
router.refresh() merece una explicación aparte. No recarga la página: pide al servidor que vuelva a ejecutar los Server Components de la ruta actual y mezcla el resultado con lo que hay en pantalla, conservando el estado de React y la posición de desplazamiento. Sirve cuando sabes que los datos del servidor han cambiado. Pero no invalida la caché del servidor: si los datos están en caché, refresh traerá lo mismo. Las formas correctas de refrescar datos después de una mutación se ven en los módulos 09 y 10.
La pestaña Servidor de la zona de gestión lo demuestra. Muestra la hora del servidor, generada en cada petición, junto a un contador de cliente:
// app/gestion/servidor/page.tsx
import { Suspense } from "react";
import { connection } from "next/server";
import { Contador } from "../../ui/contador";
import { BotonRefrescar } from "./boton-refrescar";
export const metadata = { title: "Servidor" };
async function HoraServidor() {
await connection(); // a partir de aquí, este componente se genera en cada petición
const hora = new Date().toLocaleTimeString("es-ES", { timeZone: "Europe/Madrid" });
return <p data-hora>Hora del servidor: {hora}</p>;
}
export default function Servidor() {
return (
<div className="space-y-3">
<Suspense fallback={<p>Consultando la hora...</p>}>
<HoraServidor />
</Suspense>
<Contador etiqueta="Página servidor" />
<BotonRefrescar />
</div>
);
}
// app/gestion/servidor/boton-refrescar.tsx
"use client";
import { useRouter } from "next/navigation";
import { useTransition } from "react";
export function BotonRefrescar() {
const router = useRouter();
const [refrescando, iniciar] = useTransition();
return (
<button
type="button"
disabled={refrescando}
onClick={() => iniciar(() => router.refresh())}
className="rounded bg-indigo-700 px-3 py-1 text-white disabled:opacity-50"
>
{refrescando ? "Refrescando..." : "Refrescar datos"}
</button>
);
}
connection() le dice a Next que lo que viene después depende de la petición, así que no se puede prerenderizar, y por eso va dentro de un <Suspense>, por la misma razón que el resumen del apartado 3.5. Cómo trata Next la hora actual y otros valores que cambian con Cache Components se explica en el módulo 09. Envolver router.refresh() en startTransition (aquí, mediante useTransition) te da refrescando para desactivar el botón mientras llega la respuesta.
Se pulsó 3 veces el contador, se esperó un segundo y medio y se pulsó Refrescar datos:
Hora del servidor: 12:27:55 -> Hora del servidor: 12:27:58 | Página servidor: 3
La hora cambió (el Server Component se volvió a ejecutar) y el contador sigue en 3 (el estado del cliente no se tocó). Esa es exactamente la diferencia con una recarga de la página (F5), que habría puesto el contador a 0.
⚠️ Importante: No pases a
router.pushni arouter.replaceuna URL que venga del usuario sin validarla. Una URLjavascript:...se ejecutaría en el contexto de tu página: es una puerta a ataques XSS. Si necesitas redirigir a un parámetro como?volver=/tienda, comprueba que empiece por/y no por//.
6.4. useLinkStatus: dar respuesta inmediata
Si la red es lenta, puede pasar un instante entre el clic y el cambio de página. useLinkStatus() devuelve { pending }, que vale true mientras la navegación de ese enlace está en curso. Solo funciona dentro de un componente que esté dentro de un <Link>:
// app/ui/indicador-carga.tsx
"use client";
import { useLinkStatus } from "next/link";
// Debe estar dentro de un <Link>: informa del estado de ESE enlace
export function IndicadorCarga() {
const { pending } = useLinkStatus();
return (
<span
aria-hidden
className={pending ? "ml-1 inline-block animate-pulse" : "hidden"}
>
…
</span>
);
}
Fíjate en que se importa de next/link, no de next/navigation. En Navegacion está puesto dentro de cada <Link>, así que muestra puntos suspensivos junto al enlace pulsado. Si la ruta ya estaba precargada, la navegación es tan rápida que el indicador no llega a verse, que es lo que quieres. Si te molesta un parpadeo en navegaciones rápidas, la documentación propone retrasar su aparición con una animación CSS que empiece con opacity: 0 y un retardo de unos 100 ms.
Para verlo de verdad hay que provocar una navegación lenta. La página /enlaces tiene un enlace sin precarga con el indicador dentro:
<Link href="/tienda/novedades" prefetch={false}>
Novedades (sin precarga)
<IndicadorCarga />
</Link>
Con Playwright se simuló una red con 2,5 segundos de latencia (lo mismo que hace el menú Throttling de la pestaña Red de Chrome) y se pulsó el enlace. Seiscientos milisegundos después, la URL seguía siendo /enlaces y el indicador estaba activo:
pendiente: si | URL /enlaces

Figura 5. useLinkStatus durante una navegación lenta: los puntos suspensivos junto al enlace pulsado son la única señal de que algo está pasando.
Sin ese indicador, el usuario vería una página quieta durante dos segundos y medio, y probablemente pulsaría otra vez.
6.5. Escuchar los cambios de ruta
En el Pages Router existía router.events para enterarse de cada navegación. En el App Router no existe. La alternativa es observar la ruta con un efecto:
// app/ui/eventos-navegacion.tsx
"use client";
import { useEffect } from "react";
import { usePathname } from "next/navigation";
// El App Router no tiene router.events: se observa la ruta con un efecto
export function EventosNavegacion() {
const ruta = usePathname();
useEffect(() => {
console.log(`[navegación] ahora en ${ruta}`);
}, [ruta]);
return null;
}
Colocado en el layout raíz (que nunca se desmonta), se ejecuta en cada cambio de ruta. Si también te interesan los cambios de la cadena de consulta, añade useSearchParams() a las dependencias; lo verás en el módulo 18, porque con Cache Components ese hook exige un <Suspense> alrededor.
Este registro sirve también para depurar. Al pulsar en la portada el enlace a /antigua, que redirige a /tienda, la consola mostró:
[navegación] ahora en / ; [navegación] ahora en /antigua ; [navegación] ahora en /tienda
La navegación pasa fugazmente por /antigua antes de que llegue la orden de redirección. Si envías métricas de analítica con este patrón, tenlo en cuenta: contarías una visita a una página que el usuario nunca vio.
7. Redirigir desde el servidor
Hasta ahora, la navegación la decidía el usuario. Otras veces es la aplicación la que decide: una URL antigua que ya no existe, un usuario sin sesión que debe ir al login, un formulario que tras guardar lleva al detalle. Para eso, next/navigation ofrece dos funciones que se llaman desde el servidor.
7.1. redirect y permanentRedirect
// app/antigua/page.tsx
import { redirect } from "next/navigation";
export default function Antigua() {
// redirect() lanza una excepción especial: nada después de esta línea se ejecuta
redirect("/tienda");
}
// app/catalogo-viejo/page.tsx
import { permanentRedirect } from "next/navigation";
export default function CatalogoViejo() {
permanentRedirect("/tienda");
}
Petición real con curl a la aplicación compilada:
curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" http://127.0.0.1:3001/antigua
curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" http://127.0.0.1:3001/catalogo-viejo
307 http://127.0.0.1:3001/tienda
308 http://127.0.0.1:3001/tienda
| Función | Código HTTP | Significado | Cuándo usarla |
|---|---|---|---|
redirect(url) | 307 | Temporal | La redirección depende de algo que puede cambiar: sesión, permisos, estado |
permanentRedirect(url) | 308 | Permanente | La URL antigua no volverá; los buscadores deben actualizar sus enlaces |
Los códigos 307 y 308 son las versiones modernas de 302 y 301: obligan al navegador a repetir la petición con el mismo método. Si una redirección es permanente, los navegadores y los buscadores la recuerdan, así que no uses permanentRedirect para algo que dependa del usuario.
Cuando el usuario llega a /antigua mediante un <Link>, no hay petición HTTP con un 307: Next recibe la orden de redirigir dentro de la respuesta y hace una navegación suave a /tienda. El resultado para el usuario es el mismo.
redirect se puede usar al renderizar Server Components, en Route Handlers y en Server Actions. Dentro de una Server Action la respuesta es una navegación en el cliente (lo verás en el módulo 10). También se puede llamar durante el render de un Client Component, pero no en un manejador de eventos: ahí usa router.push.
💡 Consejo: Si tienes muchas redirecciones fijas de URL antiguas, no crees una página por cada una. Decláralas en
next.config.tsconredirects(), o enproxy.ts. Ambas opciones se ven en el módulo 13.redirectes para decisiones que se toman al renderizar.
7.2. La trampa del try/catch
redirect no devuelve nada: lanza una excepción especial que Next intercepta más arriba para hacer la redirección. Su tipo de retorno en TypeScript es never, y por eso en Antigua no hace falta un return. Si la llamas dentro de un try, el catch se la traga:
// app/prueba-try/page.tsx
import { redirect } from "next/navigation";
export default function PruebaTry() {
try {
redirect("/tienda");
} catch (error) {
// El "error" es la señal de redirección, y aquí se la traga el catch
console.log("Capturado:", (error as Error).message);
}
return <p>Esta página no debería verse.</p>;
}
Al compilar, el registro mostró la excepción capturada:
Capturado: NEXT_REDIRECT
Y la petición a /prueba-try devolvió 200 con el texto “Esta página no debería verse.”. La redirección se perdió por completo, sin ningún aviso de error. Hay dos soluciones:
// Opción 1: decidir dentro del try, redirigir fuera
let destino: string | null = null;
try {
destino = await calcularDestino();
} catch {
destino = "/error";
}
redirect(destino);
// Opción 2: volver a lanzar los errores internos de Next
import { unstable_rethrow } from "next/navigation";
try {
redirect("/tienda");
} catch (error) {
unstable_rethrow(error); // si es NEXT_REDIRECT, NEXT_NOT_FOUND..., lo relanza
console.error(error);
}
La primera opción es la más clara y la que recomienda la documentación. Lo mismo vale para notFound(), forbidden() y unauthorized(), que funcionan con el mismo mecanismo.
8. Precarga y navegación: qué pasa por la red 🔄 16.0
🔄 Cambia en Next.js 16.0. En Next.js 15 la precarga pide cada ruta entera en una sola petición, y los layouts compartidos se descargan una vez por enlace. Si usas Next.js 15: no tienes que cambiar nada en tu código;
<Link>precarga igual, solo que con menos peticiones y más grandes.
Ya sabes que <Link> hace navegaciones suaves. Lo que las hace casi instantáneas es la precarga (prefetch): cuando un <Link> aparece en la pantalla, Next pide en segundo plano los datos de esa ruta. Cuando el usuario pulsa, ya los tiene.
⚠️ Importante: La precarga solo funciona en producción (
next buildynext start). Ennext devno se precarga nada, para no saturar el servidor mientras programas. Si mides la velocidad de la navegación en desarrollo, sacarás conclusiones equivocadas.
8.1. Lo que se ve en la red
Se cargó la portada de la aplicación compilada y se anotaron las peticiones de precarga que hizo el navegador. Next las marca con las cabeceras RSC: 1 y Next-Router-Prefetch: 1, y en Next.js 16 añade Next-Router-Segment-Prefetch con el trozo que pide. Además, a cada URL le añade un parámetro _rsc con un valor aleatorio, para que ninguna caché intermedia confunda estas respuestas con la página HTML de la misma dirección. Esta es la lista real (los valores de _rsc se han acortado a …):
/?_rsc=… prefetch=1 segment=/_tree
/tienda?_rsc=… prefetch=1 segment=/_tree
/gestion?_rsc=… prefetch=1 segment=/_tree
/ayuda?_rsc=… prefetch=1 segment=/_tree
/tienda/ofertas?categoria=portatiles&_rsc=… prefetch=1 segment=/_tree
/antigua?_rsc=… prefetch=1 segment=/_tree
/acerca?_rsc=… prefetch=1 segment=/_tree
/contacto?_rsc=… prefetch=1 segment=/_tree
/tienda?_rsc=… prefetch=1 segment=/tienda/__PAGE__
/tienda/ofertas?categoria=portatiles&_rsc=… prefetch=1 segment=/tienda/ofertas/__PAGE__
/gestion?_rsc=… prefetch=1 segment=/gestion/__PAGE__
/ayuda?_rsc=… prefetch=1 segment=/ayuda/__PAGE__
/tienda/ofertas?_rsc=… prefetch=1 segment=/_tree
/antigua?_rsc=… prefetch=1 segment=/antigua/__PAGE__
/contacto?_rsc=… prefetch=1 segment=/contacto/__PAGE__
/acerca?_rsc=… prefetch=1 segment=/acerca/__PAGE__
Se lee así:
- Para cada
<Link>visible (los seis del menú y los de la portada), Next pide primero el árbol de la ruta (_tree): qué segmentos tiene y qué layouts usa. - Después pide solo los segmentos que le faltan. Como el layout raíz ya está en pantalla, no lo vuelve a pedir: solo pide cada página (
__PAGE__). Fíjate en que para/tiendano ha pedido el layout de la tienda, ni para/gestionel de gestión: en esta versión solo pide las páginas. - El enlace con
<a>a/acercano precarga nada; la precarga de/acercaque aparece es la del<Link>del menú.
Después, al pulsar Tienda en el menú, estas fueron todas las peticiones RSC:
/tienda/productos?_rsc=… prefetch=1 segment=/_tree
/tienda/novedades?_rsc=… prefetch=1 segment=/_tree
/tienda/productos?_rsc=… prefetch=1 segment=/tienda/productos/__PAGE__
/tienda/novedades?_rsc=… prefetch=1 segment=/tienda/novedades/__PAGE__
Ni una sola es la navegación a /tienda: todo lo necesario para mostrarla ya estaba en el navegador. Las cuatro peticiones son precargas nuevas, provocadas por los enlaces del menú lateral de la tienda que acaban de aparecer en pantalla (Productos y Novedades; Catálogo y Ofertas ya estaban precargados). Así funciona en cadena: cada página que muestras precarga a sus vecinas.
Esta es la diferencia con Next.js 15, donde la misma prueba produjo una petición por ruta, sin segmentos:
PREFETCH AL CARGAR / (Next.js 15):
/tienda prefetch=1 segment=-
/gestion prefetch=1 segment=-
/acerca prefetch=1 segment=-
...
Next.js 16 rehízo el sistema de navegación: los layouts compartidos se descargan una sola vez aunque haya muchos enlaces que los usen, y solo se pide lo que no está ya en caché. A cambio verás más peticiones, pero mucho más pequeñas.
💡 Consejo: Para verlo tú mismo: compila, arranca con
next start, abre las herramientas de desarrollo, pestaña Red, filtra porFetch/XHRy recarga. Pulsa en una petición y mira sus cabeceras de solicitud.
8.2. Rutas estáticas y dinámicas
Cuánto se precarga depende de si la ruta es estática o dinámica, algo que viste en el módulo 02:
- Ruta estática (todas las de la tienda): se precarga entera, incluidos sus datos.
- Ruta dinámica, o con partes dinámicas: se precarga solo hasta donde puede saberse sin hacer la petición real. Con un
loading.tsx, eso incluye los layouts y el propio estado de carga.
Por eso /gestion/departamentos tiene un loading.tsx:
// app/gestion/departamentos/loading.tsx
export default function Cargando() {
return <p className="animate-pulse text-slate-500">Cargando departamentos...</p>;
}
En la salida de next build, esa ruta aparece con un símbolo distinto, porque tiene una parte que se genera en cada petición. Esta es la tabla completa de la aplicación del módulo:
Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /acerca
├ ○ /antigua
├ ○ /ayuda
├ ○ /ayuda/devoluciones
├ ○ /ayuda/envios
├ ○ /catalogo-viejo
├ ○ /contacto
├ ○ /ejercicio8
├ ○ /enlaces
├ ◐ /gestion
├ ◐ /gestion/departamentos
├ ◐ /gestion/nuevo
├ ◐ /gestion/servidor
├ ○ /prueba-try
├ ○ /tienda
├ ○ /tienda/novedades
├ ○ /tienda/ofertas
└ ◐ /tienda/productos
○ (Static) prerendered as static content
◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content
Las rutas de gestión son todas ◐ por el resumen de su layout (apartado 3.5); /tienda/productos lo es por su propia petición a la API. /antigua y /catalogo-viejo salen como estáticas: la redirección se resuelve al compilar y se sirve directamente.
Para hacer visible el efecto se arrancó la API con RETARDO=3000, que añade tres segundos a cada respuesta. Al pulsar la pestaña Departamentos, la URL cambió y apareció el estado de carga sin esperar a la API; los datos llegaron segundos después:

Figura 6. Navegación a una ruta dinámica con la API lenta. El layout de gestión y el loading.tsx estaban precargados, así que la navegación es inmediata. El resumen lateral ya estaba cargado, porque el layout no se vuelve a renderizar.
Medido en el navegador: el estado de carga apareció unos 0,5 segundos después del clic, y los datos a los 3,9 segundos, que son los 3 de retardo de la API más el resto del trabajo.
Sin loading.tsx, el usuario pulsaría y no vería nada durante tres segundos: la navegación esperaría a que el servidor terminase. Es la causa más frecuente de que una aplicación Next “parezca que no responde”. El streaming y <Suspense> se estudian en el módulo 08.
💡 Consejo: Para ver si una ruta es estática o dinámica en desarrollo, mira el indicador de Next en la esquina inferior de la página: al pulsarlo te dice el tipo de la ruta actual.
8.3. Controlar la precarga con prefetch
| Valor | Efecto |
|---|---|
"auto" o null (por defecto) | Depende de si la ruta es estática o dinámica, como acabas de ver |
true | Precarga la ruta completa, también si es dinámica |
false | No precarga nunca, ni al aparecer ni al pasar el ratón |
{/* Una tabla con 500 filas: precargar todas sería un despilfarro */}
<Link href={`/gestion/empleados/${e.empId}`} prefetch={false}>
{e.nombre}
</Link>
Desactivarla tiene un coste: la ruta se pedirá en el momento del clic. Hay un término medio, precargar solo al pasar el ratón, que harás en el ejercicio 10. Y si lo necesitas desde código, router.prefetch("/ruta") hace lo mismo que un <Link> visible.
8.4. Partial Prefetching 🆕 16.3
🆕 Desde Next.js 16.3. No existe en Next.js 15. Si usas Next.js 15: la precarga por defecto de una ruta dinámica llega hasta su
loading.tsxmás cercano; añadeloading.tsxa tus rutas dinámicas, y usaprefetch={true}en los enlaces donde quieras precargar la ruta completa.
Un proyecto creado con create-next-app 16.4 trae partialPrefetching: true en next.config.ts, y al compilar lo verás en la salida: Partial Prefetching enabled. Con esta opción, la precarga por defecto de cada ruta trae su armazón (App Shell), es decir, su contenido estático y en caché, en lugar de depender de dónde haya un loading.tsx. Funciona junto con Cache Components y con Instant Navigations, y por eso se estudia en el módulo 15, después de ver la caché. Para este módulo basta con que sepas que existe y que está activada en tu proyecto.
9. El estado de las páginas al navegar 🆕 16.0
🆕 Desde Next.js 16.0. No existe en Next.js 15: al salir de una página, sus componentes se desmontan y su estado se pierde. Si usas Next.js 15: sube el estado que quieras conservar a un layout compartido, a la URL (
searchParams) o a un almacén externo, como verás en el módulo 18.
En el apartado 3 viste que los layouts conservan su estado mientras navegas dentro de ellos. ¿Y las páginas? ¿Qué pasa con un formulario a medio rellenar si el usuario se va y vuelve? ¿Y con el layout de una sección cuando sales de ella? Durante años, la respuesta de Next fue siempre la misma: se pierde. En Next.js 16, con Cache Components, la respuesta cambia, y es uno de los cambios de comportamiento más importantes de la versión.
9.1. Qué hace Next: ocultar en lugar de desmontar
Con Cache Components activado (cacheComponents: true, que trae la plantilla de Next.js 16.4 y que se estudia en el módulo 09), Next no desmonta la ruta que abandonas. La envuelve en el componente <Activity> de React y lo pasa a modo oculto (mode="hidden"):
- El DOM sigue en el documento, con
display: none. Lo que el navegador guarda en el propio DOM (el texto de un<input>no controlado, un<details>abierto, la posición de un vídeo) se conserva sin que React haga nada. - El estado de React se conserva:
useState,useReducer, contextos, todo. - Los efectos se limpian: React ejecuta las funciones de limpieza de
useEffectyuseLayoutEffect, igual que al desmontar. Así un temporizador o una suscripción no siguen funcionando en una página que no se ve. - Al volver, la ruta se muestra tal como estaba y sus efectos se vuelven a ejecutar.
<Activity> es un componente de React 19.2 que puedes usar tú mismo; lo tienes explicado en el Curso de React. Lo nuevo aquí es que el enrutador de Next lo usa por ti en cada navegación.
Figura 7. Qué pasa con las rutas que abandonas. En Next.js 16 se ocultan y se recuperan tal como estaban, con un límite de rutas guardadas.
9.2. La prueba: páginas
Se hizo esta prueba en las dos versiones, con la misma aplicación:
- En
/acerca, pulsar 5 veces el contador de la página y escribir “hola” en el campo de comentario (un<input>no controlado: React no guarda su valor). - Ir a Inicio con el menú.
- Volver a Acerca de con el menú.
Next.js 16.4.3: Vuelta a /acerca: Página acerca: 5 | input = hola
Next.js 15.5.28: Vuelta a /acerca: Página acerca: 0 | input =
En Next.js 16 todo sigue ahí: el estado de React (el contador) y el estado del DOM (el texto del campo). En Next.js 15, la página se creó de nuevo.
La consola del navegador muestra lo que le pasa a los efectos. Al salir de /acerca y al volver, en Next.js 16:
Consola al salir: [Página acerca] desmontado u ocultado
Consola al volver: [Página acerca] montado
El efecto del contador se limpió al salir y se volvió a ejecutar al volver, pero el valor del contador (estado) no se perdió. Esto es lo que diferencia ocultar de desmontar: los efectos se comportan como si se desmontara, el estado como si nada hubiera pasado.
¿Dónde está la página mientras tanto? En el DOM. Al terminar la prueba, con /acerca en pantalla, se contaron los botones de contador del documento:
Contadores en el DOM: 6 | visibles: 2
Solo dos se ven (el del layout raíz y el de la página), pero hay seis. Los otros cuatro pertenecen a rutas ocultas: el layout y el template de la tienda, entre otros. Si abres la pestaña Elementos de las herramientas de desarrollo, encontrarás esos bloques con style="display: none" dentro de <main>.
9.3. La prueba: layouts al salir de su sección
Lo mismo vale para los layouts. El carrito de la tienda vive en el estado de su layout (lo verás en el apartado 10). Se añadieron dos productos, se navegó a otra página de la tienda, se salió a Gestión y se volvió a Tienda:
Next.js 16.4.3:
Dentro de la tienda: Carrito: 2 unidades
Tras ir a Gestion y volver: Carrito: 2 unidades
Next.js 15.5.28:
Dentro de la tienda: Carrito: 2 unidades
Tras ir a Gestion y volver: Carrito: 0 unidades
Dentro de la sección, las dos versiones se comportan igual: el layout no se desmonta (apartado 3.3). La diferencia aparece al salir de ella. En Next.js 15 el layout de la tienda se desmonta al ir a gestión, y el carrito se vacía; en Next.js 16, el layout queda oculto y el carrito sobrevive.
⚠️ Importante: Que en Next.js 16 el carrito sobreviva no lo convierte en un buen sitio para guardarlo. Se pierde al recargar la página, al abrir otra pestaña y, como verás ahora, cuando la ruta se descarta por el límite. Para datos que importan (un carrito de verdad), usa el servidor, una cookie o
localStorage. El estado conservado por<Activity>es una mejora de la experiencia, no un almacenamiento.
9.4. Atrás y Adelante del navegador
Con los botones del navegador ocurre lo mismo. Se pulsó 5 veces el contador de /acerca, se fue a Inicio con el menú y se pulsó Atrás:
Next.js 16.4.3: Acerca tras Atras: Página acerca: 5
Next.js 15.5.28: Acerca tras Atras: Página acerca: 0
Es el comportamiento que los usuarios esperan de una web: al volver atrás, encontrar la página como la dejaron. Los navegadores lo hacen con las páginas tradicionales mediante su propia bfcache (back-forward cache), y Next lo reproduce dentro de una aplicación de una sola página.
9.5. El límite: tres rutas
Guardar todas las rutas visitadas consumiría cada vez más memoria. La documentación dice que Next conserva hasta tres rutas. Para ver qué significa exactamente, se puso el contador de /acerca a un valor, se visitaron N rutas distintas con el menú y se volvió a /acerca:
1 rutas en medio: antes Página acerca: 5 -> despues Página acerca: 5
2 rutas en medio: antes Página acerca: 10 -> despues Página acerca: 10
3 rutas en medio: antes Página acerca: 15 -> despues Página acerca: 0
4 rutas en medio: antes Página acerca: 5 -> despues Página acerca: 0
Con dos rutas en medio, el estado sobrevive; con tres, se pierde. Es decir, las tres rutas son la visible más las dos últimas que abandonaste. Cuando abandonas una cuarta, la más antigua se descarta de verdad (se desmonta), y al volver se monta de cero.
Esto tiene una consecuencia práctica: no diseñes nada que dependa de que el estado se conserve. Funciona como una caché: mejora la experiencia cuando acierta, y tu aplicación debe funcionar igual de bien cuando falla.
9.6. Elegir qué se conserva y qué no
Que se conserve el estado es casi siempre lo que quiere el usuario (un borrador, unos filtros, la posición de desplazamiento de una lista larga), pero no siempre. La documentación de Next propone decidirlo pieza a pieza. Estos son los casos típicos:
| Estado | ¿Conservar? | Cómo reiniciarlo si no |
|---|---|---|
| Texto de un formulario a medio escribir | Normalmente sí | Vaciarlo en el manejador del envío |
| Filtros, pestañas, acordeones abiertos | Sí: el usuario configuró su vista | |
| Menú desplegable o popover abierto | No: es una interacción pasajera | Cerrarlo en la limpieza de useLayoutEffect |
| Mensaje “Guardado con éxito” | No: confunde en una nueva visita | Limpiarlo en useLayoutEffect o derivarlo de la URL |
| Diálogo que enfoca un campo al abrirse | Depende | Derivar si está abierto de la URL (?editar=true) |
| Formulario “nuevo” que debe empezar vacío | No | bfcacheId como key (apartado 9.7) |
Cerrar un desplegable al ocultar la página. La página Acerca de tiene dos menús de opciones iguales, uno con reinicio y otro sin él:
// app/acerca/menu-opciones.tsx
"use client";
import { useLayoutEffect, useState } from "react";
export function MenuOpciones({ id, cerrarAlOcultar = false }: { id: string; cerrarAlOcultar?: boolean }) {
const [abierto, setAbierto] = useState(false);
useLayoutEffect(() => {
if (!cerrarAlOcultar) return;
// La limpieza se ejecuta al desmontar... y también cuando Activity oculta la ruta
return () => setAbierto(false);
}, [cerrarAlOcultar]);
return (
<div data-menu={id}>
<button type="button" onClick={() => setAbierto((a) => !a)} className="underline">
Opciones ({id})
</button>
{abierto && (
<ul data-desplegable className="mt-1 rounded border bg-white p-2 text-sm shadow">
<li>Imprimir</li>
<li>Compartir</li>
</ul>
)}
</div>
);
}
Se abrieron los dos, se fue a Inicio y se volvió:
Desplegable sin-reinicio visible: true | con-reinicio visible: false

Figura 8. La página *Acerca de después de salir y volver en Next.js 16. Se conservan el contador, el texto y el menú sin reinicio; el menú con useLayoutEffect se ha cerrado.*
¿Por qué useLayoutEffect y no useEffect? Los dos ejecutan su limpieza al ocultarse la ruta, pero la de useLayoutEffect se ejecuta de forma síncrona antes de que React termine de aplicar el cambio. Así, cuando la página vuelva a verse, el menú ya estará cerrado y no habrá un instante en que aparezca abierto. Y la limpieza no afecta a nada más: el contador y el comentario de la misma página siguen intactos.
9.7. Reiniciar en cada visita nueva con bfcacheId 🆕 16.3
🆕 Desde Next.js 16.3. No existe en Next.js 15. Si usas Next.js 15: no lo necesitas, porque en Next.js 15 la página siempre se monta de cero al volver a ella, tanto con un enlace como con Atrás.
A veces quieres un término medio: que el formulario de alta de un empleado empiece vacío cada vez que el usuario entra desde el menú, pero que conserve lo escrito si vuelve con el botón Atrás (porque se fue a consultar algo y regresa). Para eso existe router.bfcacheId, una cadena opaca que:
- cambia en cada navegación nueva (
pushoreplace, que es lo que hacen<Link>yrouter.push); - se mantiene con Atrás y Adelante, con
router.refresh()y con navegaciones que solo cambian la consulta o el ancla.
Usada como key de React, reinicia un subárbol exactamente cuando quieres:
// app/ui/reinicio-al-navegar.tsx
"use client";
import { Fragment } from "react";
import { useRouter } from "next/navigation";
// bfcacheId cambia en cada push/replace (clic en Link, router.push...)
// y se mantiene al usar Atrás/Adelante del navegador
export function ReinicioAlNavegar({ children }: { children: React.ReactNode }) {
const { bfcacheId } = useRouter();
return <Fragment key={bfcacheId}>{children}</Fragment>;
}
// app/gestion/nuevo/page.tsx
import { ReinicioAlNavegar } from "../../ui/reinicio-al-navegar";
import { FormularioEmpleado } from "./formulario-empleado";
export const metadata = { title: "Nuevo empleado" };
export default function NuevoEmpleado() {
return (
<ReinicioAlNavegar>
<FormularioEmpleado />
</ReinicioAlNavegar>
);
}
Probado: se escribió “Marta”, se fue al panel con la pestaña y se volvió con la pestaña Nuevo empleado; después se escribió “Luis”, se fue al panel y se volvió con el botón Atrás del navegador:
Volver con <Link>: input = ""
Volver con Atras: /gestion/nuevo input = "Luis"
El cambio de key hace que React descarte el formulario viejo y cree uno nuevo, con su estado inicial. La documentación lo presenta sobre todo como herramienta de migración, para aplicaciones que dependían de que todo se reiniciara al navegar; para código nuevo, recomienda los reinicios por pieza del apartado anterior.
9.8. Consecuencias para tu código
Este comportamiento cambia algunas costumbres. Repásalas si vienes de Next.js 15 o de otra biblioteca:
- No uses el montaje como señal de “nueva visita”. Un
useEffect(() => { registrarVisita() }, [])en una página se ejecuta otra vez al volver a ella, porque los efectos se vuelven a ejecutar al mostrarse; pero el estado inicial deuseStateno se recalcula. Si algo debe pasar en cada visita, ponlo en un efecto; si algo debe valer lo mismo en cada visita, no lo guardes en estado. - Limpia siempre tus efectos. Un
setIntervalsinclearIntervalen la limpieza seguiría ejecutándose en una página oculta. Con<Activity>la limpieza se llama al ocultar, así que un efecto bien escrito no da problemas. - Los datos del servidor no se vuelven a pedir por volver. Una página oculta que se muestra de nuevo enseña lo que tenía. Si los datos pueden haber cambiado, refréscalos con las herramientas del módulo 09 o con
router.refresh(). - Selectores y pruebas. Como las rutas ocultas siguen en el DOM, un
document.querySelector("h1")puede devolver el título de una página oculta. En las pruebas con Playwright de este módulo hubo que usar el filtro:visiblepor esa razón. Lo verás en el módulo 21. - IDs duplicados. Si dos rutas usan el mismo
iden un elemento y una está oculta, habrá dos elementos con eseiden el documento. UsauseId()de React para los identificadores de formularios.
💡 Consejo: Al depurar, recuerda que el
useEffectde una página oculta sí ejecuta su limpieza, aunque el componente no se haya desmontado. Por eso el contador del ejemplo escribe “desmontado u ocultado”: desde el efecto no se distingue un caso del otro. Para saber si una ruta está oculta o desmontada, mira la pestaña Elementos: si el bloque sigue ahí condisplay: none, está oculta.
10. Caso práctico: la estructura de EMPRESA
Ya tienes todas las piezas. Este apartado las junta en la estructura que usará la aplicación EMPRESA durante el resto del curso: una tienda pública, pensada para clientes y casi toda estática, y una zona de gestión, para empleados, con datos que cambian. Cada zona tiene su layout, y cada layout resuelve un problema distinto.
| Zona | Layout aporta | Estado que vive en el layout | Datos del servidor en el layout |
|---|---|---|---|
Tienda (/tienda/...) | Menú lateral, migas, carrito | Carrito (contexto de cliente) | Ninguno: todo el armazón es estático |
Gestión (/gestion/...) | Título, pestañas, resumen | Ninguno | Resumen de estadísticas, con <Suspense> |
10.1. La tienda: un carrito en el layout
El carrito es un contexto de React que se proporciona en el layout de la tienda. Cualquier página de la tienda puede añadir productos, y el icono del menú lateral muestra el total:
// app/tienda/carrito.tsx
"use client";
import { createContext, use, useState } from "react";
type Carrito = { unidades: number; anadir: () => void };
const ContextoCarrito = createContext<Carrito | null>(null);
// Vive en el layout de la tienda: su estado sobrevive a la navegación entre páginas
export function ProveedorCarrito({ children }: { children: React.ReactNode }) {
const [unidades, setUnidades] = useState(0);
return (
<ContextoCarrito value={{ unidades, anadir: () => setUnidades((u) => u + 1) }}>
{children}
</ContextoCarrito>
);
}
function useCarrito() {
const carrito = use(ContextoCarrito);
if (!carrito) throw new Error("useCarrito debe usarse dentro de ProveedorCarrito");
return carrito;
}
export function IconoCarrito() {
const { unidades } = useCarrito();
return (
<p data-carrito className="rounded bg-orange-100 px-2 py-1 text-sm">
Carrito: {unidades} {unidades === 1 ? "unidad" : "unidades"}
</p>
);
}
export function BotonAnadir({ nombre }: { nombre: string }) {
const { anadir } = useCarrito();
return (
<button
type="button"
onClick={anadir}
aria-label={`Añadir ${nombre} al carrito`}
className="rounded bg-indigo-700 px-2 py-1 text-sm text-white"
>
Añadir
</button>
);
}
Se usan <Contexto value> en lugar de <Contexto.Provider value>, y use(Contexto) en lugar de useContext, las formas de React 19 que viste en el Curso de React. Lo importante para este módulo es dónde está: el proveedor envuelve a children en app/tienda/layout.tsx, así que todas las páginas de la tienda quedan dentro y el estado vive mientras el layout viva.
La página de productos es un Server Component que lee la API, y solo los botones son de cliente:
// app/tienda/productos/page.tsx
import { BotonAnadir } from "../carrito";
const API = process.env.API_URL ?? "http://localhost:3000/api";
type Producto = { productoId: number; nombre: string; precio: number };
export const metadata = { title: "Productos" };
export default async function Productos() {
const respuesta = await fetch(`${API}/productos?disponibles=true&tamano=5`);
const { contenido } = (await respuesta.json()) as { contenido: Producto[] };
return (
<>
<h1 className="text-2xl font-bold">Productos</h1>
<ul className="space-y-2">
{contenido.map((p) => (
<li key={p.productoId} className="flex items-center gap-3">
<span className="flex-1">{p.nombre}</span>
<span>{p.precio.toLocaleString("es-ES")} €</span>
<BotonAnadir nombre={p.nombre} />
</li>
))}
</ul>
</>
);
}
// app/tienda/productos/loading.tsx
export default function Cargando() {
return <p className="animate-pulse text-slate-500">Cargando productos...</p>;
}

Figura 9. La tienda con el carrito en el layout. Tras añadir dos productos, el lateral muestra “Carrito: 2 unidades”, y el valor se mantiene al pasar a Ofertas o Novedades.
Tras pulsar Añadir en los dos primeros productos y pasar a Novedades, el carrito seguía con Carrito: 2 unidades. Recuerda la advertencia del apartado 9.3: en una tienda real, el carrito iría al servidor o a una cookie.
10.2. La gestión: pestañas, resumen y páginas
La zona de gestión combina tres piezas que ya conoces:
Pestanas(apartado 6.2), un componente de cliente conuseSelectedLayoutSegment.Resumen(apartado 3.5), un Server Component con datos de la API, protegido por un<Suspense>.- Cuatro páginas: panel, departamentos (con
loading.tsx), nuevo empleado (cononNavigateybfcacheId) y servidor (conrouter.refresh()).
Cada decisión tiene su motivo:
| Decisión | Motivo |
|---|---|
| Las pestañas en el layout, no en cada página | Se escriben una vez y no se vuelven a montar al cambiar de pestaña |
| Las pestañas como componente de cliente aparte | Solo ellas necesitan saber la ruta; el layout sigue siendo de servidor |
El resumen dentro de <Suspense> | Sin él, Cache Components no compila; con él, el armazón llega al instante |
loading.tsx en departamentos | La navegación es inmediata aunque la API tarde |
router.replace al guardar | El formulario enviado no queda en el historial |
bfcacheId en el alta | Cada visita desde el menú empieza con el formulario vacío |

Figura 10. La zona de gestión completa: pestañas, contenido de la página y resumen del layout.
10.3. Recorrido por la aplicación
Así se comportó la aplicación en un recorrido completo con el navegador, todo en la misma pestaña:
| Paso | Qué ocurre | Por qué |
|---|---|---|
Cargar / | 16 peticiones de precarga, sin bloquear nada | Todos los <Link> visibles se precargan (apartado 8.1) |
| Pulsar Tienda | Ninguna petición para la tienda; 4 precargas nuevas | Ya estaba precargada; aparecen enlaces nuevos |
| Pulsar Productos | Aparece “Cargando productos…” y luego la lista | loading.tsx y una página que pide datos |
| Añadir 2 productos y pasar a Novedades | El carrito sigue en 2; el template se vuelve a montar | Layout persistente, template con key |
| Ir a Gestión y volver a Tienda | El carrito sigue en 2 | <Activity> ocultó la tienda (solo en Next.js 16) |
| Gestión → Servidor → Refrescar datos | Cambia la hora; el contador no | router.refresh() |
| Nuevo empleado, escribir, Volver al panel | Diálogo de confirmación | onNavigate |
| Guardar y pulsar Atrás | Se vuelve a /gestion, no al formulario | router.replace |
11. Depurar navegaciones
Cuando algo de la navegación no se comporta como esperas (una página que tarda, un estado que se pierde o que no se pierde, una precarga que no ocurre), las herramientas del navegador te dicen qué está pasando. Esta es una guía práctica, con lo que se observó en la aplicación del módulo.
11.1. Antes de nada: ¿desarrollo o producción?
Muchas “rarezas” de la navegación son simplemente diferencias entre next dev y next start:
| Comportamiento | next dev | next start |
|---|---|---|
Precarga de <Link> | No | Sí |
| Compilación de rutas | Bajo demanda, la primera visita tarda | Todo compilado de antemano |
| Mensajes de error | Detallados, con la línea del fichero | Genéricos, para no dar pistas a un atacante |
| Indicador de Next en la página | Sí | No |
Regla práctica: los errores se investigan en desarrollo, la velocidad y la precarga en producción.
11.2. La pestaña Red
Abre las herramientas de desarrollo (F12), pestaña Red (Network), y filtra por Fetch/XHR. Cada navegación suave y cada precarga es una petición GET a la propia URL de la página, con estas señas:
- Un parámetro
_rsc=en la URL, con un valor que cambia. - La cabecera de solicitud
RSC: 1. Las precargas llevan ademásNext-Router-Prefetch: 1y, en Next.js 16,Next-Router-Segment-Prefetchcon el trozo pedido (/_tree,/tienda/__PAGE__…). - La respuesta tiene el tipo
text/x-component. No es HTML ni JSON: es el payload RSC, el formato con el que React describe el árbol de Server Components.
Esta es una respuesta real, la de la navegación a /gestion/servidor (solo sus primeras líneas):
URL /gestion/servidor?_rsc=DOKA4R-T9vj-5ot2
content-type: text/x-component
5:"$Sreact.fragment"
6:"$Sreact.suspense"
8:"/_next/static/chunks/1k1vy1c4lvn-7.js"
9:"/_next/static/chunks/2q8i6qiwae1ml.js"
a:"/_next/static/chunks/3e9n-mk1s3g0m.js"
b:I[41370,["$8","$9","$a"],"Contador"]
c:I[25087,["$8","$9","$a"],"BotonRefrescar"]
Se reconocen cosas: las líneas I[...] son referencias a Client Components (Contador, BotonRefrescar) con los ficheros JavaScript que los contienen. Los Server Components, en cambio, no aparecen como código: solo su resultado. Si quieres repasar este formato, está explicado en el módulo 23 del Curso de React.
Preguntas que la pestaña Red responde:
| Pregunta | Qué mirar |
|---|---|
| ¿Se precargó esta ruta? | Una petición con Next-Router-Prefetch: 1 y su URL, antes del clic |
| ¿La navegación fue suave? | El tipo de la petición: fetch con _rsc (suave) o document (recarga completa) |
| ¿Por qué tarda? | La columna Tiempo de la petición RSC; si es grande, el servidor está esperando datos |
| ¿Funciona sin precarga? | Desactiva la caché y activa Throttling (por ejemplo, Slow 4G) para simular una red lenta |
11.3. La pestaña Elementos y la consola
Con Next.js 16 y Cache Components, la pestaña Elementos muestra las rutas ocultas: bloques dentro de <main> con style="display: none". Es la forma más rápida de saber si una página está oculta (conservará el estado) o se ha descartado.
En la consola, un componente como EventosNavegacion (apartado 6.5) te da una traza de las rutas visitadas, y los registros en los efectos te dicen cuándo se monta y se oculta cada componente. La consola también es donde aparecen los errores de hidratación.
Con la extensión React Developer Tools puedes inspeccionar el árbol de componentes: verás tus layouts, templates y páginas, junto con los componentes internos que Next coloca entre ellos, y podrás consultar el estado de cada componente. Es la forma más directa de comprobar, por ejemplo, qué valor tiene el estado del carrito en un momento dado.
11.4. El servidor y el build
next buildte dice el tipo de cada ruta (○, ◐, ƒ). Si una ruta que esperabas estática sale ◐ o ƒ, busca qué la hace dinámica: a menudo es su layout, como pasó con la zona de gestión.curlresponde rápido a preguntas sobre redirecciones y cabeceras:curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" http://localhost:3001/antiguadio307 http://127.0.0.1:3001/tienda.- El terminal de
next devmuestra cada petición con su tiempo (GET /errores/router-viejo 500 in 773ms) y los errores de compilación con el fichero y la línea.
11.5. Automatizar la comprobación
Las pruebas de este módulo se hicieron con un pequeño script de Playwright. Es una técnica útil cuando quieres comprobar un comportamiento de navegación sin hacerlo a mano cada vez. Este es el núcleo del que midió la precarga al pasar el ratón (ejercicio 10):
import { chromium } from "@playwright/test";
const navegador = await chromium.launch();
const pagina = await navegador.newPage();
const peticiones: string[] = [];
pagina.on("request", (r) => {
const h = r.headers();
const u = new URL(r.url());
if (h["rsc"] && u.pathname.startsWith("/ayuda/")) {
peticiones.push(`${u.pathname} segment=${h["next-router-segment-prefetch"] ?? "-"}`);
}
});
await pagina.goto("http://127.0.0.1:3001/enlaces", { waitUntil: "networkidle" });
console.log("Antes de pasar el ratón:", peticiones.join(" | ") || "ninguna");
Escucha todas las peticiones de la página y se queda con las que llevan la cabecera RSC. Playwright se estudia en el módulo 21.
12. Errores frecuentes
Los mensajes de esta tabla son los que se obtuvieron al provocar cada error en el laboratorio, y las soluciones se comprobaron después.
| Síntoma o mensaje | Causa | Solución probada |
|---|---|---|
| La URL da 404 aunque la carpeta existe | Falta page.tsx, o el fichero se llama Page.tsx o index.tsx | El nombre exacto es page.tsx, en minúsculas |
The default export is not a React Component in "/ruta/page" | export function sin default | export default function ... |
Conflicting route and page at /ruta (y todas las páginas dan 500) | page.tsx y route.ts en la misma carpeta | Mover el route.ts a otra carpeta, por ejemplo app/api/... |
You're importing a module that depends on usePathname into a React Server Component module | Hook en un Server Component | 'use client' en un componente pequeño que solo haga eso |
NextRouter was not mounted | useRouter importado de next/router | Importar de next/navigation; usar usePathname y useSearchParams |
Route "/gestion": Next.js encountered uncached or runtime data during prerendering | fetch sin caché en un layout, fuera de <Suspense> | Envolver el componente con datos en <Suspense> (apartado 3.5) |
| El enlace Inicio aparece siempre activo | ruta.startsWith("/") es siempre cierto | Comparar la portada con === (estaActiva, apartado 6.1) |
| El layout no se actualiza al cambiar de página | Es su comportamiento: no se vuelve a renderizar | Mover lo que dependa de la página a page.tsx o a un componente de cliente con usePathname |
<title> duplicado o ignorado | Se escribió <head> a mano en el layout | export const metadata, con title.template en el layout |
Errores de hidratación por <html> anidado | <html> o <body> en un layout que no es raíz | Solo el layout raíz los lleva |
| La redirección no ocurre y la página devuelve 200 | redirect() dentro de un try (Capturado: NEXT_REDIRECT) | Llamar a redirect fuera del try, o unstable_rethrow |
redirect en un onClick no hace nada útil | redirect es para el render y el servidor | En eventos, router.push |
| La navegación tarda y no se ve nada | Ruta dinámica sin loading.tsx | loading.tsx o un <Suspense> |
| “La precarga no funciona” | Estás en next dev | Compilar y probar con next start |
| Navegar entre dos zonas recarga la página entera | Tienen layouts raíz distintos | Es lo esperado; si molesta, un único layout raíz |
| Al volver a una página aparece un menú abierto o un mensaje antiguo | Next.js 16 conserva el estado con <Activity> | Reiniciar ese estado en la limpieza de useLayoutEffect o derivarlo de la URL |
| Un formulario “nuevo” aparece con datos de la visita anterior | Igual: la página se ocultó, no se desmontó | bfcacheId como key (apartado 9.7) |
| Una prueba encuentra dos elementos iguales | Las rutas ocultas siguen en el DOM | Filtrar por elementos visibles (:visible en Playwright) |
💡 Consejo: Si todas las páginas empiezan a fallar a la vez en desarrollo, no busques el problema en la página que estás mirando. Un error de compilación en cualquier fichero, o un conflicto de rutas, bloquea la aplicación entera. Lee el primer error del terminal: dice el fichero y la línea.
13. Ejercicios
Todos los ejercicios se hacen sobre la aplicación de este módulo. Las soluciones se han compilado con next build y probado en el navegador.
Ejercicio 1: Página de contacto
Crea la ruta /contacto con un título, una dirección de correo y un título de pestaña propio (“Contacto | EMPRESA”). Añádela al menú principal.
Ver solución
Crea la carpeta app/contacto/ con su page.tsx. El título de la pestaña se declara con metadata en la propia página. Como el layout raíz define title.template como "%s | EMPRESA" (apartado 3.1), la página solo escribe su parte:
// app/contacto/page.tsx
import type { Metadata } from "next";
import { BotonVolver } from "../ui/boton-volver";
export const metadata: Metadata = { title: "Contacto" };
export default function Contacto() {
return (
<section className="space-y-3">
<h1 className="text-2xl font-bold">Contacto</h1>
<p>Escríbenos a info@empresa.es.</p>
<BotonVolver />
</section>
);
}
BotonVolver es la solución del ejercicio 6; si aún no lo tienes, quita esa línea. Para el menú, añade una entrada al array de Navegacion:
{ href: "/contacto", texto: "Contacto" },
No hace falta registrar la ruta en ningún sitio: la carpeta con su page.tsx es suficiente. Comprueba con next build que aparece ○ /contacto en la lista de rutas, y en el HTML servido que el título es <title>Contacto | EMPRESA</title>. Si el layout raíz no tuviera la plantilla, tendrías que escribir el título completo en cada página.
Ejercicio 2: Menú con enlace activo accesible
El menú de la cabecera debe resaltar la sección actual, también en las subrutas (en /tienda/ofertas debe resaltar Tienda), y anunciarla a los lectores de pantalla. Además, evita que una ruta futura /tiendas-fisicas active el enlace Tienda.
Ver solución
Parte del componente Navegacion del apartado 6.1 y extrae la comparación a una función:
function estaActiva(ruta: string, href: string) {
if (href === "/") return ruta === "/";
return ruta === href || ruta.startsWith(href + "/");
}
Y úsala en el map:
const activa = estaActiva(ruta, href);
Las claves de la solución:
usePathnameobliga a que el componente sea de cliente. Solo el menú lo es; el layout raíz sigue siendo de servidor.- La portada se compara con igualdad estricta.
href + "/"evita el falso positivo con prefijos parecidos:/tiendas-fisicasno empieza por/tienda/.aria-current="page"comunica la página actual a las tecnologías de asistencia, y te permite incluso dar estilo con un selector[aria-current="page"]en lugar de duplicar clases.
Ejercicio 3: Pestañas que no dependen de la URL completa
Las pestañas de la zona de gestión deben marcar la activa sin comparar URL completas, de forma que si la carpeta gestion cambia de nombre las pestañas sigan funcionando sin tocar su lógica.
Ver solución
Es el componente Pestanas del apartado 6.2. La pieza clave es useSelectedLayoutSegment(), que, usado desde un componente colocado en app/gestion/layout.tsx, devuelve el segmento inmediatamente inferior:
| URL | Valor devuelto |
|---|---|
/gestion | null |
/gestion/departamentos | "departamentos" |
/gestion/nuevo | "nuevo" |
Cada pestaña guarda el segmento que la activa (null para el panel) y se compara con p.segmento === activo. Los href siguen teniendo la ruta completa, porque <Link> la necesita; si quieres que tampoco dependan del nombre de la carpeta, puedes construirlos con una constante const BASE = "/gestion" en un único sitio.
El resultado se ve en la figura 10: la pestaña Departamentos subrayada en naranja. Para añadir una pestaña nueva, como Servidor, basta una entrada más en el array: { segmento: "servidor", href: "/gestion/servidor", texto: "Servidor" }.
Ejercicio 4: Migas de pan de la tienda
Añade al layout de la tienda unas migas de pan: “Tienda” en /tienda, “Tienda / Ofertas” en /tienda/ofertas, cada parte enlazada a su ruta.
Ver solución
useSelectedLayoutSegments() (en plural) devuelve todos los segmentos que hay por debajo del layout donde se usa. Desde la tienda, en /tienda/ofertas devuelve ["ofertas"], y en /tienda un array vacío.
// app/ui/migas.tsx
"use client";
import Link from "next/link";
import { useSelectedLayoutSegments } from "next/navigation";
const nombres: Record<string, string> = {
ofertas: "Ofertas",
novedades: "Novedades",
};
// Se usa dentro del layout de /tienda: devuelve los segmentos POR DEBAJO de él
export function Migas() {
const segmentos = useSelectedLayoutSegments();
return (
<nav aria-label="Migas de pan" className="text-sm text-slate-500">
<Link href="/tienda">Tienda</Link>
{segmentos.map((segmento, i) => {
const href = "/tienda/" + segmentos.slice(0, i + 1).join("/");
return (
<span key={href}>
{" / "}
<Link href={href}>{nombres[segmento] ?? segmento}</Link>
</span>
);
})}
</nav>
);
}
Se coloca en app/tienda/layout.tsx, encima de children. El mapa nombres traduce el segmento técnico a un texto legible; si un segmento no está en el mapa, se muestra tal cual. En la figura 2 se ve el resultado en /tienda/ofertas: “Tienda / Ofertas”.
¿Por qué en el layout y no en cada página? Porque así se escribe una vez y, como el layout no se vuelve a montar, el componente de cliente simplemente se actualiza al cambiar los segmentos.
Ejercicio 5: Animación de entrada en cada página de la tienda
Haz que el contenido de cada página de la tienda aparezca con una animación suave al navegar entre ellas, sin que se anime el menú lateral.
Ver solución
Una animación CSS se dispara cuando el elemento se crea. En un layout, el elemento se crea una sola vez, así que la animación solo se vería al entrar en la tienda. Lo que hace falta es un elemento que se cree de nuevo en cada navegación: un template.
// app/tienda/template.tsx
export default function TemplateTienda({ children }: { children: React.ReactNode }) {
return <div className="animacion-entrada">{children}</div>;
}
/* app/globals.css */
@keyframes entrada {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: none; }
}
.animacion-entrada {
animation: entrada 300ms ease-out;
}
El menú lateral está en app/tienda/layout.tsx, por encima del template, y no se anima. La consola confirma el montaje del template en cada cambio de página ([Template tienda] desmontado u ocultado y [Template tienda] montado).
Si prefieres respetar a quien tiene activada la reducción de movimiento en su sistema, envuelve la regla en @media (prefers-reduced-motion: no-preference) { ... }. Para animaciones más elaboradas entre páginas, en el módulo 15 verás View Transitions.
Ejercicio 6: Botón Volver con alternativa
Crea un componente BotonVolver que vuelva a la página anterior. Si el usuario ha llegado a la página directamente (enlace pegado en una pestaña nueva), router.back() lo sacaría de la aplicación o no haría nada: en ese caso debe ir a una ruta alternativa, por defecto la portada.
Ver solución
// app/ui/boton-volver.tsx
"use client";
import { useRouter } from "next/navigation";
export function BotonVolver({ alternativa = "/" }: { alternativa?: string }) {
const router = useRouter();
function volver() {
// Si se entró directamente (pestaña nueva), no hay a dónde volver dentro del sitio
if (window.history.length > 1) {
router.back();
} else {
router.push(alternativa);
}
}
return (
<button type="button" onClick={volver} className="underline">
← Volver
</button>
);
}
Puntos importantes:
- Es un
<button>, no un<Link>: no lleva a una URL conocida de antemano, sino a “la anterior”. window.history.lengthsolo se lee dentro del manejador del clic, nunca durante el render. Si lo leyeras al renderizar, el servidor (que no tienewindow) fallaría o produciría un HTML distinto del cliente.- La comprobación es aproximada:
history.lengthcuenta todas las entradas de la pestaña, incluidas las de otros sitios. Para una garantía total habría que llevar la cuenta de las navegaciones internas, por ejemplo conusePathnameen un contexto. Para la mayoría de los casos, esta versión basta.
Ejercicio 7: Avisar antes de abandonar un formulario
En /gestion/nuevo, si el usuario ha escrito algo y pulsa Volver al panel, pregúntale si quiere salir. Si responde que no, debe quedarse en el formulario. Después de guardar, el botón Atrás del navegador no debe devolverle al formulario.
Ver solución
La solución completa es FormularioEmpleado, del apartado 5.4. Sus tres piezas:
hayCambiosse deriva del estado (nombre.trim() !== ""). No hace falta otrouseState.onNavigateen el<Link>llama ae.preventDefault()si hay cambios y el usuario cancela elconfirm. Resultado probado: tras cancelar, la URL sigue siendo/gestion/nuevo.router.replaceal guardar sustituye la entrada del formulario en el historial. Resultado probado: tras guardar y pulsar Atrás, el usuario vuelve a/gestion.
¿Por qué onNavigate y no onClick? Con onClick, el diálogo también saltaría al abrir el enlace en otra pestaña con Ctrl+clic, donde el formulario no se pierde. onNavigate solo se ejecuta cuando la navegación va a ocurrir en la pestaña actual.
Recuerda la limitación: este aviso no cubre el botón Atrás del navegador ni el cierre de la pestaña. Para lo segundo puedes añadir un manejador de beforeunload en un useEffect mientras hayCambios sea cierto.
Ejercicio 8: Redirección desde una URL antigua
La tienda estaba antes en /catalogo-viejo y no volverá a estar ahí. Haz que esa URL lleve a /tienda de forma que los buscadores actualicen sus enlaces. Después, explica qué devuelve este código y corrígelo:
export default async function Antigua() {
try {
const activa = await comprobarTiendaActiva();
if (activa) redirect("/tienda");
} catch {
return <p>No se pudo comprobar la tienda.</p>;
}
return <p>La tienda está cerrada.</p>;
}
Ver solución
Para la primera parte, la redirección es permanente, así que se usa permanentRedirect, que responde con 308:
// app/catalogo-viejo/page.tsx
import { permanentRedirect } from "next/navigation";
export default function CatalogoViejo() {
permanentRedirect("/tienda");
}
Comprobado con curl: 308 http://127.0.0.1:3001/tienda.
En la segunda parte, cuando la tienda está activa, redirect lanza su excepción especial NEXT_REDIRECT, el catch la captura y la página muestra “No se pudo comprobar la tienda.” con un 200. El usuario nunca llega a /tienda, y además ve un mensaje de error falso. Es exactamente lo que se comprobó con /prueba-try en el apartado 7.2.
La corrección: dentro del try, solo lo que puede fallar; la redirección, fuera.
import { redirect } from "next/navigation";
export default async function Antigua() {
let activa: boolean;
try {
activa = await comprobarTiendaActiva();
} catch {
return <p>No se pudo comprobar la tienda.</p>;
}
if (activa) redirect("/tienda");
return <p>La tienda está cerrada.</p>;
}
Aquí se usa redirect (307) y no permanentRedirect, porque la decisión depende de un estado que puede cambiar.
Ejercicio 9: Enlaces con cadena de consulta
En la portada, crea un enlace a las ofertas de portátiles (/tienda/ofertas?categoria=portatiles) sin escribir la cadena de consulta a mano. Después, piensa: si el usuario navega de /tienda/ofertas?categoria=portatiles a /tienda/ofertas?categoria=moviles, ¿se vuelve a montar el template de la tienda?
Ver solución
Con un objeto URL en href:
<Link href={{ pathname: "/tienda/ofertas", query: { categoria: "portatiles" } }}>
Ofertas en portátiles
</Link>
El HTML generado es href="/tienda/ofertas?categoria=portatiles". La ventaja frente a la plantilla de texto aparece con valores que necesitan codificación, como espacios o tildes: Next los codifica por ti. Si prefieres una cadena, usa URLSearchParams:
const consulta = new URLSearchParams({ categoria: "portátiles y tablets" });
<Link href={`/tienda/ofertas?${consulta}`}>...</Link>
El enlace generado fue href="/tienda/ofertas?categoria=port%C3%A1tiles+y+tablets": la tilde se codifica en UTF-8 y los espacios como +. Si hubieras escrito la cadena a mano, con espacios y tildes sin codificar, dependerías de que el navegador la corrigiera.
Respecto a la pregunta: no. Un template solo se vuelve a montar cuando cambia el segmento que tiene debajo, y los searchParams no forman parte de ningún segmento. Si necesitas reiniciar algo al cambiar un parámetro, dale a ese componente una key que dependa del parámetro. Cómo leer searchParams en páginas y componentes lo verás en los módulos 04 y 18.
Ejercicio 10: Precargar solo al pasar el ratón
Imagina una lista de 300 empleados, cada uno con un <Link> a su ficha. Precargar 300 rutas al cargar la página es un despilfarro, pero no precargar ninguna hace lenta la navegación. Crea un componente EnlacePrecargaHover que no precargue hasta que el usuario muestre interés en el enlace (ratón encima o foco con el teclado). Compruébalo con next start y la pestaña Red.
Ver solución
La prop prefetch puede cambiar con el estado: false al principio, y null (el comportamiento por defecto) en cuanto el usuario pasa el ratón:
// app/ui/enlace-precarga-hover.tsx
"use client";
import Link from "next/link";
import { useState } from "react";
export function EnlacePrecargaHover({ href, children }: { href: string; children: React.ReactNode }) {
const [activo, setActivo] = useState(false);
return (
<Link
href={href}
// false: no precarga; null: comportamiento por defecto (precarga al estar visible)
prefetch={activo ? null : false}
onMouseEnter={() => setActivo(true)}
onFocus={() => setActivo(true)}
>
{children}
</Link>
);
}
Se usó en la página /enlaces con dos enlaces a la ayuda, y se anotaron las peticiones RSC a /ayuda/...:
Antes de pasar el raton: ninguna
Tras pasar por Envios: /ayuda/envios segment=/_tree | /ayuda/envios segment=/ayuda/envios/__PAGE__
Al pulsar: ninguna
Al cargar la página no se precargó ninguno de los dos. Al pasar el ratón por Envíos se precargó solo ese (no Devoluciones), y al pulsarlo la navegación no necesitó ninguna petición: ya tenía todo. Entre que el ratón entra en el enlace y el clic suelen pasar entre 100 y 300 milisegundos, tiempo de sobra para una precarga en una red normal.
onFocus hace que funcione también para quien navega con el tabulador. Es un detalle de accesibilidad que se olvida a menudo.
Ejercicio 11: Un menú que se cierra al salir
En la página Acerca de, añade un botón “Opciones” que abra un pequeño menú desplegable. Comprueba en Next.js 16 que, si sales con el menú abierto y vuelves con el menú principal, el desplegable sigue abierto. Corrígelo para que se cierre al ocultarse la página, pero sin perder el texto escrito en el campo de comentario ni el valor del contador.
Ver solución
Es el componente MenuOpciones del apartado 9.6. La clave es que la limpieza de useLayoutEffect se ejecuta cuando <Activity> oculta la ruta, y ahí se cierra el menú:
useLayoutEffect(() => {
return () => setAbierto(false);
}, []);
La página tiene dos instancias, una con esta limpieza y otra sin ella, para comparar. Resultado tras salir con los dos abiertos y volver:
Vuelta a /acerca: Página acerca: 5 | input = hola
Desplegable sin-reinicio visible: true | con-reinicio visible: false
El contador y el comentario se conservan; solo el menú con limpieza se ha cerrado (figura 8).
Una alternativa sin efectos es cerrar el menú en el momento del clic en cualquier enlace, con onNavigate en los <Link> del propio menú. Pero no cubre los enlaces de fuera del menú (como los del menú principal) ni el botón Atrás, así que la limpieza de useLayoutEffect es más completa. En Next.js 15 no hace falta nada de esto: la página se desmonta al salir y el menú siempre aparece cerrado al volver.
Ejercicio 12: Sección con layout y template
Crea una sección /ayuda con tres páginas (/ayuda, /ayuda/envios, /ayuda/devoluciones). Debe tener un índice lateral con un buscador que conserve lo escrito mientras el usuario navega por la sección, y un bloque “¿Te ha sido útil?” que se reinicie en cada página. Decide qué va en layout.tsx y qué en template.tsx, y justifícalo.
Ver solución
El buscador debe sobrevivir a la navegación: va en el layout. La valoración debe reiniciarse en cada página: va en el template.
// app/ayuda/layout.tsx
import Link from "next/link";
import { Buscador } from "./buscador";
export default function LayoutAyuda({ children }: LayoutProps<"/ayuda">) {
return (
<div className="flex gap-6">
<aside className="w-56 space-y-3 rounded bg-indigo-50 p-4">
<Buscador />
<ul className="space-y-1">
<li><Link href="/ayuda">Índice</Link></li>
<li><Link href="/ayuda/envios">Envíos</Link></li>
<li><Link href="/ayuda/devoluciones">Devoluciones</Link></li>
</ul>
</aside>
<article className="flex-1">{children}</article>
</div>
);
}
// app/ayuda/template.tsx
import { Valoracion } from "./valoracion";
export default function TemplateAyuda({ children }: { children: React.ReactNode }) {
return (
<>
{children}
<Valoracion />
</>
);
}
// app/ayuda/buscador.tsx
"use client";
import { useState } from "react";
export function Buscador() {
const [texto, setTexto] = useState("");
return (
<input
name="buscar"
value={texto}
onChange={(e) => setTexto(e.target.value)}
placeholder="Buscar en la ayuda"
className="w-full border px-2"
/>
);
}
// app/ayuda/valoracion.tsx
"use client";
import { useState } from "react";
export function Valoracion() {
const [respuesta, setRespuesta] = useState<"si" | "no" | null>(null);
if (respuesta) {
return <p data-valoracion>Gracias por tu respuesta.</p>;
}
return (
<p data-valoracion className="mt-6 flex gap-3">
¿Te ha sido útil?
<button type="button" onClick={() => setRespuesta("si")} className="underline">Sí</button>
<button type="button" onClick={() => setRespuesta("no")} className="underline">No</button>
</p>
);
}
Cada página es un componente mínimo con su título y su metadata, por ejemplo:
// app/ayuda/envios/page.tsx
export const metadata = { title: "Envíos" };
export default function Envios() {
return <h1 className="text-2xl font-bold">Envíos</h1>;
}
Probado: en /ayuda se escribió “envio” en el buscador, se respondió “Sí” a la valoración y se pulsó Envíos:
En /ayuda/envios: buscar = "envio" | valoracion = ¿Te ha sido útil? Sí No
El buscador conserva el texto (layout) y la valoración vuelve a preguntar (template). Si hubieras puesto la valoración en el layout, el “Gracias por tu respuesta” se arrastraría a todas las páginas de la ayuda; si hubieras puesto el buscador en el template, se vaciaría al cambiar de página.
Con Next.js 16 hay un matiz: si el usuario sale de la ayuda y vuelve en poco tiempo, <Activity> le devolverá la última página de ayuda que vio con la valoración ya respondida, porque la ruta entera se ocultó y se recupera tal cual. Es lo esperable: es la misma página que dejó.
Ejercicio 13: Un carrito que no se pierde al cambiar de página
En la tienda, cada página de productos tiene botones Añadir. El total de unidades se debe ver en el menú lateral de la tienda y mantenerse al pasar de Productos a Ofertas o Novedades. Antes de escribir código, responde: ¿dónde va el estado?, ¿qué ficheros son de cliente y cuáles de servidor? Después, comprueba qué le pasa al carrito al salir de la tienda y volver, y explica la diferencia entre Next.js 15 y 16.
Ver solución
El estado tiene que vivir en un componente que no se desmonte al navegar entre las páginas de la tienda y que envuelva tanto al menú lateral como a las páginas. Ese sitio es el layout de la tienda. Como el estado necesita useState, se crea un proveedor de contexto de cliente (ProveedorCarrito) y se coloca en el layout, que sigue siendo un Server Component. El código completo está en el apartado 10.1.
| Fichero | Tipo | Por qué |
|---|---|---|
app/tienda/layout.tsx | Servidor | Solo compone; no tiene estado ni eventos |
app/tienda/carrito.tsx | Cliente | useState, contexto y onClick |
app/tienda/productos/page.tsx | Servidor | Pide los productos a la API con await fetch |
Resultados en los dos laboratorios:
Next.js 16.4.3:
Dentro de la tienda: Carrito: 2 unidades
Tras ir a Gestion y volver: Carrito: 2 unidades
Next.js 15.5.28:
Dentro de la tienda: Carrito: 2 unidades
Tras ir a Gestion y volver: Carrito: 0 unidades
Dentro de la tienda, el layout persiste en las dos versiones. Al salir, en Next.js 15 el layout se desmonta y el estado desaparece; en Next.js 16, <Activity> lo oculta y el estado sobrevive, pero solo mientras la ruta no se descarte (apartado 9.5). Para un carrito real, ninguna de las dos es suficiente: el estado debe guardarse fuera de React, por ejemplo en una cookie o en la base de datos, como harás en el proyecto final.
Ejercicio 14: Refrescar los datos sin perder el estado
En la página /gestion/servidor, muestra la hora del servidor y un contador de cliente. Añade un botón Refrescar datos que actualice la hora sin recargar la página y sin poner el contador a cero, y que se desactive mientras espera la respuesta.
Ver solución
La solución completa está en el apartado 6.3: un Server Component HoraServidor que llama a connection() dentro de un <Suspense>, y un componente de cliente BotonRefrescar que llama a router.refresh() dentro de una transición.
Las claves:
router.refresh()vuelve a ejecutar en el servidor los Server Components de la ruta y mezcla el resultado, sin tocar el estado de los Client Components.useTransitiondarefrescando, que estruemientras llega la respuesta, para desactivar el botón y cambiar su texto.connection()marca la hora como dato de cada petición; si no, se generaría una sola vez.
Resultado real:
Hora del servidor: 12:27:55 -> Hora del servidor: 12:27:58 | Página servidor: 3
Si en lugar del botón hubieras usado window.location.reload(), la hora también cambiaría, pero el contador volvería a 0 y se descargaría de nuevo toda la página.
Ejercicio 15: Diagnóstico de un layout que no compila
Un compañero añade al layout de la zona de gestión un componente Resumen que pide datos a la API, y next build falla con Route "/gestion": Next.js encountered uncached or runtime data during prerendering. Él dice que no lo entiende, porque la página de departamentos también pide datos y tiene un loading.tsx. Explícale el error y corrígelo. Después, di qué rutas de la tabla de next build cambiarán de símbolo y por qué.
Ver solución
El loading.tsx crea un <Suspense> que envuelve a la página, pero el layout de la misma carpeta queda fuera de él (figura 3). El fetch del layout no tiene ningún <Suspense> por encima, y con Cache Components Next exige que todo dato sin caché tenga un sitio donde mostrar un estado de espera, para poder servir el resto de la página al instante.
La corrección es envolver el componente con datos en su propio <Suspense>, dentro del layout (apartado 3.5):
<Suspense fallback={<p className="text-sm text-slate-500">Calculando...</p>}>
<Resumen />
</Suspense>
Tras la corrección, todas las rutas de /gestion pasan a ◐ (Partial Prerender), también /gestion/nuevo, que no pide datos:
├ ◐ /gestion
├ ◐ /gestion/departamentos
├ ◐ /gestion/nuevo
├ ◐ /gestion/servidor
El motivo es que el layout forma parte de cada una de esas rutas: si el layout tiene una parte que se genera en cada petición, todas sus páginas la tienen. Si no quisieras ese efecto, podrías mover el resumen a la página del panel, o cachearlo con 'use cache', que verás en el módulo 09.
Ejercicio 16 (propuesto): Registro de visitas
Quieres registrar en la consola del servidor cada página que visita un usuario. Prueba dos enfoques: un efecto en un template.tsx del layout raíz y el componente EventosNavegacion del apartado 6.5. Compara qué registra cada uno al navegar entre dos páginas de la tienda, al cambiar solo la cadena de consulta, al volver con Atrás a una página oculta por <Activity>, y al pasar por /antigua. ¿Cuál elegirías para analítica y por qué?
Ejercicio 17 (propuesto): Estudio de la red
Compila la aplicación, arráncala con next start y, con la pestaña Red abierta y la caché desactivada, haz este recorrido: portada, Tienda, Productos, Gestión, Departamentos. Para cada clic, anota cuántas peticiones RSC se hacen, cuáles son precargas (Next-Router-Prefetch: 1) y cuáles navegaciones, y qué segmento pide cada una. Repite el recorrido con Throttling en Slow 4G y describe qué cambia en lo que ve el usuario.
Resumen
| Concepto | API | Idea clave |
|---|---|---|
| Rutas por carpetas | app/<segmento>/page.tsx | Cada carpeta es un segmento; solo es pública si tiene page.tsx |
| Colocación | Cualquier otro fichero en app/ | Componentes y utilidades junto a la ruta, sin publicarse |
| Página | page.tsx | Server Component por defecto, puede ser async, recibe params y searchParams como promesas |
| Layout raíz | app/layout.tsx | Obligatorio; único sitio con <html> y <body>; sin <head> manual |
| Layout anidado | layout.tsx | Envuelve su carpeta; no se vuelve a montar ni a renderizar al navegar dentro de ella |
| Template | template.tsx | Como un layout, pero con key por segmento: se monta de nuevo en cada navegación |
| Jerarquía | layout > template > error > loading > not-found > page | El error.tsx de una carpeta no cubre su propio layout |
| Enlaces | <Link href> de next/link | Navegación suave: conserva layouts, estado y window |
Opciones de Link | replace, scroll, prefetch, onNavigate | Historial, desplazamiento, precarga y cancelación |
| Tipos de transición | transitionTypes 🆕 16.2 | Elige la animación de <ViewTransition> según el enlace |
| Ruta actual | usePathname() | Camino sin consulta; para el enlace activo |
| Segmentos | useSelectedLayoutSegment(s)() | Segmentos activos por debajo del layout donde se usa |
| Navegar desde código | useRouter() de next/navigation | push, replace, back, forward, refresh, prefetch |
| Estado del enlace | useLinkStatus() de next/link | pending mientras navega ese enlace |
| Redirección temporal | redirect() | 307; lanza una excepción: fuera de try |
| Redirección permanente | permanentRedirect() | 308; para URL que no volverán |
| Precarga | prefetch 🔄 16.0 | Solo en producción; en 16 por segmentos y sin repetir layouts |
| Rutas dinámicas | loading.tsx | Permite precargar el armazón y navegar al instante |
| Partial Prefetching | partialPrefetching: true 🆕 16.3 | La precarga trae el armazón estático de cada ruta |
| Estado conservado | <Activity> con cacheComponents 🆕 16.0 | Las rutas que abandonas se ocultan, no se desmontan; medido: la visible y las dos anteriores |
| Efectos en rutas ocultas | useEffect, useLayoutEffect | Se limpian al ocultar y se vuelven a ejecutar al mostrar; el estado se conserva |
| Reinicio selectivo | Limpieza de useLayoutEffect | Cierra desplegables y mensajes pasajeros al ocultarse la página |
| Visita nueva frente a Atrás | router.bfcacheId 🆕 16.3 | Como key, reinicia con <Link> y conserva con Atrás |
| Datos en un layout | <Suspense> alrededor del componente | El loading.tsx no cubre al layout de su carpeta |
| Refrescar desde el cliente | router.refresh() | Vuelve a ejecutar los Server Components sin perder el estado del cliente |
| Escuchar navegaciones | usePathname + useEffect | Sustituye a router.events del Pages Router |
| Depurar | Pestaña Red: _rsc, RSC: 1, text/x-component | Precargas y navegaciones son peticiones RSC; las rutas ocultas se ven en Elementos |