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.tsx cuando necesites justo lo contrario que un layout: montar de nuevo en cada navegación
  • Navegar con <Link> y conocer todas sus opciones: href como texto u objeto, replace, scroll, prefetch, onNavigate y transitionTypes
  • Leer y cambiar la ruta desde el cliente con useRouter, usePathname, useSelectedLayoutSegment(s) y useLinkStatus
  • Redirigir desde el servidor con redirect y permanentRedirect, y evitar la trampa del try/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 build y ejecutado con next start en 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: con next dev no 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:

  1. Cada carpeta es un segmento de la URL. La carpeta app/tienda/ofertas/ corresponde al camino /tienda/ofertas.
  2. 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.

Árbol de carpetas de app con sus ficheros page, layout y otros, y las URL públicas que generan

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/ofertas hay dos segmentos, tienda y ofertas, más el segmento raíz, que corresponde a la carpeta app/ 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.tsx y un route.ts no pueden convivir en la misma carpeta, porque los dos reclaman la misma URL. Tampoco hay forma de que un fichero cualquiera, como app/tienda/catalogo.tsx, publique la ruta /tienda/catalogo: tendrías que crear app/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 async y esperar datos directamente. No necesitas useEffect ni 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'> y LayoutProps<'/ruta'>, que se generan al ejecutar next dev, next build o next 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 de create-next-app trae lang="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 objeto metadata, 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 con Acerca de | EMPRESA. Una página sin título propio, como la portada, usa default: EMPRESA. Es un buen ejemplo de cómo un layout aporta algo a todas sus páginas sin que estas lo repitan.
  • EventosNavegacion es 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.
  • Contador es 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.

Página de ofertas de la tienda: cabecera con el enlace Tienda resaltado y contador del layout raíz a 2, menú lateral con contador del layout tienda a 3, y un marco discontinuo con el contador del template a 0

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 hace Navegacion, 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: un fetch sin 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 a await connection() (de next/server) al principio de Resumen y 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:

Cajas anidadas: Layout contiene a Template, que contiene a ErrorBoundary, que contiene a Suspense, que contiene a NotFound, que contiene a la página

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.tsx de una carpeta no captura los errores del layout.tsx de esa misma carpeta, porque el layout está fuera. Los captura el error.tsx de la carpeta superior. Lo verás en el módulo 14.
  • El template.tsx envuelve al loading.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 searchParams no lo vuelve a montar: de /tienda?pagina=1 a /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.


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 &lt;a&gt;)
          </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.

Comparación de dos columnas: con a href se pierde el estado de ambos layouts y las variables globales; con Link se conservan los layouts y solo se sustituye la página

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: sticky o fixed) y tapa el principio del contenido después de navegar, no es culpa de <Link>. Añade scroll-padding-top en CSS al elemento html con 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), onClick se ejecuta y onNavigate no.
  • Con una URL externa o un enlace con download, onNavigate no 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: onNavigate solo 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 evento beforeunload del 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 paquete next/router es 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, porque next/router existe. El fallo llega al ejecutar. Una página con 'use client' que hacía useRouter() importado de next/router respondió 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 con startsWith("/") el enlace Inicio estaría siempre activo.
  • Las subrutas deben activar a su sección: en /tienda/ofertas se resalta Tienda. Por eso no basta ruta === href.
  • Cuidado con los prefijos parecidos: startsWith("/tienda") también sería cierto para una ruta /tiendas-fisicas. Comparar con href + "/" 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.push ni a router.replace una URL que venga del usuario sin validarla. Una URL javascript:... 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

Página de pruebas de enlaces con tres elementos de lista; junto al tercero, Novedades sin precarga, aparecen unos puntos suspensivos grises que indican que la navegación está en curso

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.ts con redirects(), o en proxy.ts. Ambas opciones se ven en el módulo 13. redirect es 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 build y next start). En next dev no 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í:

  1. 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.
  2. 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 /tienda no ha pedido el layout de la tienda, ni para /gestion el de gestión: en esta versión solo pide las páginas.
  3. El enlace con <a> a /acerca no precarga nada; la precarga de /acerca que 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 por Fetch/XHR y 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:

Zona de gestión con la pestaña Departamentos activa y el texto Cargando departamentos en el lugar del contenido

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.tsx más cercano; añade loading.tsx a tus rutas dinámicas, y usa prefetch={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 useEffect y useLayoutEffect, 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.

Comparación de dos columnas: en Next.js 15 solo la ruta visible está en el DOM y al volver se monta de cero; en Next.js 16 las rutas anteriores siguen en el DOM ocultas con display none, con su estado intacto, hasta que se descarta la más antigua

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:

  1. 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).
  2. Ir a Inicio con el menú.
  3. 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

Página Acerca de tras volver: el contador conserva el valor 5, el campo de comentario conserva el texto hola, el menú sin reinicio sigue desplegado y el menú con reinicio está cerrado

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 (push o replace, que es lo que hacen <Link> y router.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 de useState no 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 setInterval sin clearInterval en 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 :visible por esa razón. Lo verás en el módulo 21.
  • IDs duplicados. Si dos rutas usan el mismo id en un elemento y una está oculta, habrá dos elementos con ese id en el documento. Usa useId() de React para los identificadores de formularios.

💡 Consejo: Al depurar, recuerda que el useEffect de 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í con display: 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>;
}

Página de productos de la tienda con cinco productos de la API EMPRESA, cada uno con su precio y un botón Añadir; en el menú lateral, el carrito indica 2 unidades

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:

  1. Pestanas (apartado 6.2), un componente de cliente con useSelectedLayoutSegment.
  2. Resumen (apartado 3.5), un Server Component con datos de la API, protegido por un <Suspense>.
  3. Cuatro páginas: panel, departamentos (con loading.tsx), nuevo empleado (con onNavigate y bfcacheId) y servidor (con router.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

Zona de gestión con las pestañas Panel, Departamentos, Nuevo empleado y Servidor; Departamentos activa con la lista de siete departamentos, y a la derecha el resumen con 24 empleados, 7 departamentos y 14 clientes

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ás Next-Router-Prefetch: 1 y, en Next.js 16, Next-Router-Segment-Prefetch con 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 build te 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.
  • curl responde rápido a preguntas sobre redirecciones y cabeceras: curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" http://localhost:3001/antigua dio 307 http://127.0.0.1:3001/tienda.
  • El terminal de next dev muestra 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:

  • usePathname obliga 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-fisicas no 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.length solo se lee dentro del manejador del clic, nunca durante el render. Si lo leyeras al renderizar, el servidor (que no tiene window) fallaría o produciría un HTML distinto del cliente.
  • La comprobación es aproximada: history.length cuenta 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 con usePathname en 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:

  1. hayCambios se deriva del estado (nombre.trim() !== ""). No hace falta otro useState.
  2. onNavigate en el <Link> llama a e.preventDefault() si hay cambios y el usuario cancela el confirm. Resultado probado: tras cancelar, la URL sigue siendo /gestion/nuevo.
  3. router.replace al 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.
  • useTransition da refrescando, que es true mientras 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