Módulo 15: Rutas avanzadas y navegación instantánea

Objetivos del módulo

  • Dividir una pantalla en zonas independientes con rutas paralelas (@slot), cada una con su carga, sus errores y sus subrutas
  • Entender qué es un slot, por qué children también lo es y cuándo se usa default.tsx, que en Next.js 16 es obligatorio
  • Mostrar una ruta dentro de otra con rutas interceptadas ((.), (..), (...)) y distinguir la navegación blanda de la dura
  • Construir modales con URL propia: que se puedan compartir, que se cierren con el botón Atrás y que muestren la página completa al recargar
  • Saber qué significa que una navegación sea instantánea en Next.js 16.3+ y usar la validación de instant para encontrar lo que la bloquea
  • Medir qué cambia en la red con Partial Prefetching y controlar la precarga con <Link prefetch> y la opción de segmento prefetch
  • Animar las navegaciones con View Transitions: elementos compartidos, deslizamientos según la dirección y respeto a prefers-reduced-motion
  • Reconocer los errores típicos de estos patrones (modales que no se cierran, páginas inertes, rutas que no compilan) y arreglarlos

💡 Consejo: Este módulo junta cuatro herramientas que resuelven problemas de pantallas complejas: varias zonas que cargan por separado, ventanas encima de otra página, navegaciones que no esperan al servidor y transiciones animadas. Todas se apoyan en lo que ya sabes del módulo 03 (layouts, Link, precarga) y del módulo 09 (Cache Components y 'use cache'). En el apartado 9 del módulo 03 tienes cómo Next.js 16 conserva el estado de las páginas que abandonas con <Activity> y cómo reiniciarlo con bfcacheId; aquí no se repite, pero verás que ese comportamiento influye en los modales (apartado 5.6). Todo el código se ha compilado y ejecutado con Next.js 16.4 y la API EMPRESA; las alternativas para Next.js 15 se han probado con 15.5.


1. Pantallas que no son “una URL, una página”

Hasta ahora, cada URL mostraba una página: la carpeta app/tienda/page.tsx respondía en /tienda, y al navegar a otra URL cambiaba la página entera por debajo del layout. Ese modelo se queda corto en tres situaciones muy habituales:

  1. Un panel de mando con varias zonas: estadísticas, últimos pedidos, avisos. Cada zona lee datos distintos, tarda distinto y puede fallar por su cuenta. Si los pedidos tardan dos segundos, no quieres que las estadísticas esperen. Y si la zona de pedidos tiene pestañas (Todos, Pendientes), cambiar de pestaña no debería volver a cargar las estadísticas.
  2. Un modal con URL: en el catálogo pulsas un producto y se abre su ficha en una ventana encima del catálogo. La URL cambia a /tienda/producto/1, de modo que puedes copiarla y enviarla. Quien la abra desde el enlace debe ver la ficha como página completa, no un modal flotando sobre nada.
  3. Una navegación que se siente inmediata: el usuario pulsa y la nueva pantalla aparece al instante, con su estructura y lo que ya está en caché, mientras el resto llega por streaming.

Next.js resuelve la primera con rutas paralelas, la segunda combinando rutas paralelas con rutas interceptadas, y la tercera con Instant Navigations y Partial Prefetching (Next.js 16.3). Para que el cambio de pantalla además se vea bien, React y el navegador aportan las View Transitions.

A lo largo del módulo construirás tres zonas de la aplicación EMPRESA:

Zona URL Técnica
Panel de gestión /panel, /panel/pendientes Rutas paralelas con loading, error y subrutas por slot
Catálogo de la tienda /tienda, /tienda/producto/[id] Modal interceptado con URL propia
Departamentos /departamentos, /departamentos/[id] View Transitions con elemento compartido y dirección

Todas leen la API EMPRESA. La capa de acceso a datos es la del módulo 04, con caché donde tiene sentido:

// app/_lib/api.ts
// Capa de acceso a la API EMPRESA. Carpeta privada: no genera rutas.
import { cacheLife, cacheTag } from 'next/cache'

const API = process.env.API_URL ?? 'http://localhost:3000/api'

export type Producto = {
  productoId: number
  nombre: string
  descripcion: string
  categoria: string
  precio: number
  stock: number
  descatalogado: boolean
}

export type Departamento = {
  deptId: number
  nombre: string
  localidad: string
  presupuesto: number | null
}

export type Pedido = {
  pedidoId: number
  cliente: string
  fechaPedido: string
  estado: string
}

export type Estadisticas = {
  empleados: number
  departamentos: number
  clientes: number
  productos: number
  pedidos: number
  facturacion: number
}

async function leer<T>(ruta: string): Promise<T> {
  const res = await fetch(`${API}${ruta}`)
  if (!res.ok) throw new Error(`La API respondió ${res.status} en ${ruta}`)
  return res.json() as Promise<T>
}

// Datos que cambian poco: en caché, entran en el armazón estático.
export async function getProductos() {
  'use cache'
  cacheLife('hours')
  cacheTag('productos')
  const datos = await leer<{ contenido: Producto[] }>('/productos?tamano=100')
  return datos.contenido
}

export async function getProducto(id: number) {
  'use cache'
  cacheLife('hours')
  cacheTag('productos', `producto-${id}`)
  const res = await fetch(`${API}/productos/${id}`)
  if (res.status === 404) return null
  if (!res.ok) throw new Error(`La API respondió ${res.status}`)
  return (await res.json()) as Producto
}

export async function getDepartamentos() {
  'use cache'
  cacheLife('days')
  const datos = await leer<{ contenido: Departamento[] }>('/departamentos')
  return datos.contenido
}

export async function getDepartamento(id: number) {
  'use cache'
  cacheLife('days')
  const res = await fetch(`${API}/departamentos/${id}`)
  if (!res.ok) return null
  return (await res.json()) as Departamento
}

export async function getEstadisticas() {
  'use cache'
  cacheLife('minutes')
  return leer<Estadisticas>('/estadisticas')
}

// Sin caché: se pide en cada petición (siempre dentro de <Suspense>).
export async function getPedidos(estado?: string) {
  const filtro = estado ? `&estado=${estado}` : ''
  const datos = await leer<{ contenido: Pedido[] }>(`/pedidos?tamano=100${filtro}`)
  return datos.contenido
}

// Stock "en vivo": sin caché, para el apartado de navegación instantánea.
export async function getStockEnVivo(id: number) {
  const res = await fetch(`${API}/productos/${id}`, { cache: 'no-store' })
  const p = (await res.json()) as Producto
  return p.stock
}

export const euros = (n: number) =>
  n.toLocaleString('es-ES', { style: 'currency', currency: 'EUR' })

⚠️ Importante: Recuerda que la API EMPRESA (recursos/empresa-api.zip) arranca en http://localhost:3000/api, el mismo puerto que Next por defecto. Arranca la API con node server.mjs y Next en otro puerto (npm run dev -- -p 3001). Los listados de la API vienen paginados ({ contenido, pagina, tamano, total, paginas }) con 20 elementos por defecto; por eso las funciones piden ?tamano=100.

El layout raíz solo añade una cabecera con tres enlaces. Fíjate en la clase cabecera: la usarás en el apartado 8 para que no se mueva durante las animaciones.

// app/layout.tsx
import type { Metadata } from 'next'
import Link from 'next/link'
import './globals.css'

export const metadata: Metadata = {
  title: 'EMPRESA',
  description: 'Rutas avanzadas con Next.js',
}

export default function RootLayout({ children }: LayoutProps<'/'>) {
  return (
    <html lang="es">
      <body className="min-h-screen bg-slate-50 text-slate-800">
        <header className="cabecera flex gap-6 bg-indigo-900 px-6 py-3 text-white">
          <strong>EMPRESA</strong>
          <nav className="flex gap-4">
            <Link href="/panel">Panel</Link>
            <Link href="/tienda">Tienda</Link>
            <Link href="/departamentos">Departamentos</Link>
          </nav>
        </header>
        <main className="p-6">{children}</main>
      </body>
    </html>
  )
}

2. Rutas paralelas

2.1. La idea: un layout con varios huecos

Un layout recibe children: el hueco donde Next pone la página de la URL actual. Las rutas paralelas permiten que un layout tenga más huecos, cada uno con su propio contenido. Cada hueco se llama slot y se crea con una carpeta cuyo nombre empieza por @:

app/panel/
├── layout.tsx              ← recibe children, estadisticas y pedidos
├── page.tsx                ← children
├── default.tsx
├── @estadisticas/
│   ├── page.tsx            ← estadisticas
│   └── default.tsx
└── @pedidos/
    ├── layout.tsx
    ├── page.tsx            ← pedidos
    ├── loading.tsx
    ├── error.tsx
    └── pendientes/
        └── page.tsx        ← pedidos, en /panel/pendientes

Tres reglas definen el comportamiento:

  1. Un slot no es un segmento de la URL. La carpeta @pedidos no añade nada a la dirección: app/panel/@pedidos/page.tsx se muestra en /panel, igual que app/panel/page.tsx. Y app/panel/@pedidos/pendientes/page.tsx responde en /panel/pendientes, no en /panel/@pedidos/pendientes.
  2. Cada slot llega al layout como una prop con el nombre de la carpeta sin la arroba: @estadisticas es la prop estadisticas.
  3. children es un slot implícito. Es exactamente lo mismo que un @children que no hace falta escribir. Todo lo que se diga de los slots vale también para él.

Diagrama: a la izquierda, el árbol de carpetas app/panel con page.tsx, @estadisticas y @pedidos; a la derecha, la pantalla de /panel dividida en tres zonas: children arriba, estadisticas abajo a la izquierda y pedidos abajo a la derecha

Figura 1. Cada carpeta @slot se convierte en una prop del layout. El layout decide dónde se pinta cada una.

📘 Concepto: Un slot es una zona de un layout cuyo contenido depende de la URL de forma independiente. Para cada URL, Next busca en cada slot qué fichero le corresponde (page.tsx, una subcarpeta o default.tsx) y pasa el resultado al layout. Por eso se llaman “paralelas”: hay varias rutas resolviéndose a la vez, una por slot.

¿Para qué sirve esto si podrías importar tres componentes en una página? Por lo que viene con cada slot:

Con componentes en una página Con slots
Un único loading.tsx para toda la página loading.tsx propio en cada slot: cada zona muestra su esqueleto
Un único error.tsx: si falla una zona, cae la página error.tsx propio: si falla una zona, las demás siguen
Las pestañas de una zona son searchParams o estado Cada slot puede tener subrutas con URL propia (/panel/pendientes)
Cambiar de pestaña vuelve a pintar la página Al navegar, los slots que no cambian conservan lo que muestran
Mostrar una zona u otra según el rol es un if en la página El layout elige qué slot pinta (apartado 2.8)

2.2. El layout del panel

El layout recibe un slot por carpeta. El tipo global LayoutProps<'/panel'>, que viste en el módulo 04, ya incluye las props de los slots: Next genera los tipos a partir de las carpetas, así que si escribes estadistica (sin la s), el compilador se queja.

// app/panel/layout.tsx
// Recibe children y una prop por cada carpeta @slot del mismo nivel.
export default function LayoutPanel({
  children,
  estadisticas,
  pedidos,
}: LayoutProps<'/panel'>) {
  return (
    <div className="grid gap-4 md:grid-cols-3">
      <section className="rounded border bg-white p-4 md:col-span-3">{children}</section>
      <section className="rounded border bg-white p-4">{estadisticas}</section>
      <section className="rounded border bg-white p-4 md:col-span-2">{pedidos}</section>
    </div>
  )
}

La página normal es el slot children:

// app/panel/page.tsx: el slot implícito children
export default function ResumenPanel() {
  return (
    <>
      <h1 className="text-xl font-bold">Panel de gestión</h1>
      <p className="text-sm text-slate-600">
        Tres zonas, tres slots: cada una carga, falla y navega por su cuenta.
      </p>
    </>
  )
}

2.3. Los slots: uno en caché y otro en cada petición

Las estadísticas cambian poco: se leen con getEstadisticas(), que lleva 'use cache' y cacheLife('minutes'). Entran en el armazón estático de la página.

// app/panel/@estadisticas/page.tsx
import { getEstadisticas, euros } from '@/app/_lib/api'

export default async function Estadisticas() {
  const e = await getEstadisticas()
  return (
    <>
      <h2 className="mb-2 font-semibold">Estadísticas</h2>
      <dl className="grid grid-cols-2 gap-1 text-sm">
        <dt>Empleados</dt><dd className="text-right">{e.empleados}</dd>
        <dt>Clientes</dt><dd className="text-right">{e.clientes}</dd>
        <dt>Pedidos</dt><dd className="text-right">{e.pedidos}</dd>
        <dt>Facturación</dt><dd className="text-right">{euros(e.facturacion)}</dd>
      </dl>
    </>
  )
}

Los pedidos, en cambio, se piden en cada petición (getPedidos no tiene caché). Para que se note el streaming independiente, la página espera 1,5 segundos a propósito antes de consultar:

// app/panel/@pedidos/page.tsx: pedidos sin caché, en cada petición
import { getPedidos } from '@/app/_lib/api'
import TablaPedidos from './tabla'

export default async function Pedidos() {
  await new Promise((r) => setTimeout(r, 1500)) // simula una consulta lenta
  const pedidos = await getPedidos()
  return (
    <>
      <h2 className="mb-2 font-semibold">Últimos pedidos ({pedidos.length})</h2>
      <TablaPedidos pedidos={pedidos} />
    </>
  )
}
// app/panel/@pedidos/tabla.tsx
import type { Pedido } from '@/app/_lib/api'

export default function TablaPedidos({ pedidos }: { pedidos: Pedido[] }) {
  return (
    <table className="w-full text-sm">
      <thead>
        <tr className="text-left text-slate-500">
          <th>Nº</th><th>Cliente</th><th>Fecha</th><th>Estado</th>
        </tr>
      </thead>
      <tbody>
        {pedidos.slice(0, 6).map((p) => (
          <tr key={p.pedidoId} className="border-t">
            <td>{p.pedidoId}</td><td>{p.cliente}</td><td>{p.fechaPedido}</td><td>{p.estado}</td>
          </tr>
        ))}
      </tbody>
    </table>
  )
}

tabla.tsx está dentro de la carpeta del slot y no pasa nada: solo page.tsx y route.ts crean rutas. Es la colocación de ficheros del módulo 04.

2.4. loading.tsx y error.tsx por slot

Como los pedidos se leen sin caché, Cache Components exige un límite de <Suspense> por encima (lo viste en el módulo 09). La forma natural de ponerlo es un loading.tsx dentro del slot: solo envuelve a ese slot.

// app/panel/@pedidos/loading.tsx: solo afecta a este slot
export default function CargandoPedidos() {
  return <p className="animate-pulse text-sm text-slate-500">Cargando pedidos…</p>
}

Lo mismo con los errores. Si la API de pedidos falla, se muestra este componente en la zona de pedidos y las estadísticas siguen en pantalla:

'use client'
// app/panel/@pedidos/error.tsx: si falla la API de pedidos, el resto del panel sigue en pie
export default function ErrorPedidos({ error, retry }: { error: Error; retry: () => void }) {
  return (
    <div className="text-sm text-red-700">
      <p>No se han podido cargar los pedidos: {error.message}</p>
      <button onClick={() => retry()} className="mt-2 rounded border px-2">Reintentar</button>
    </div>
  )
}

💡 Consejo: En Next.js 16.4 el componente de error recibe retry, como viste en el módulo 14. Es la pieza que hace que un panel con slots sea robusto: cada zona se recupera por su cuenta.

El resultado, medido con un navegador real contra next start: en cuanto llega el HTML ya se ven el resumen y las estadísticas (vienen del armazón estático), y la zona de pedidos muestra Cargando pedidos…. Un segundo y medio después, la tabla sustituye al esqueleto sin tocar lo demás.

Panel de gestión recién cargado: arriba el resumen, abajo a la izquierda las estadísticas con sus cifras y a la derecha el texto Cargando pedidos

Figura 2. El panel al llegar el HTML. Las estadísticas, en caché, ya están; los pedidos, sin caché, muestran el loading.tsx de su slot.

Panel de gestión completo: resumen, estadísticas (24 empleados, 14 clientes, 24 pedidos, 61.617,46 euros de facturación) y la tabla de últimos pedidos con las pestañas Todos y Pendientes

Figura 3. El mismo panel cuando termina el streaming. Cada zona es un slot.

La salida de next build lo confirma: el panel es una página parcialmente prerenderizada (◐) que toma la caducidad más corta de lo que hay en su armazón, la de cacheLife('minutes'):

├ ◐ /panel                              1m      1h
├ ◐ /panel/pendientes                   1m      1h

2.5. Subrutas dentro de un slot: navegación blanda y dura

La zona de pedidos tiene dos pestañas: Todos (/panel) y Pendientes (/panel/pendientes). La segunda es una subcarpeta dentro del slot:

// app/panel/@pedidos/pendientes/page.tsx: URL /panel/pendientes
import { getPedidos } from '@/app/_lib/api'
import TablaPedidos from '../tabla'

export default async function PedidosPendientes() {
  const pedidos = await getPedidos('PENDIENTE')
  return (
    <>
      <h2 className="mb-2 font-semibold">Pedidos pendientes ({pedidos.length})</h2>
      <TablaPedidos pedidos={pedidos} />
    </>
  )
}

Las pestañas van en un layout del slot (un slot puede tener su propio layout.tsx, igual que cualquier segmento):

// app/panel/@pedidos/layout.tsx: un slot también puede tener layout
import PestanasPedidos from './pestanas'

export default function LayoutPedidos({ children }: { children: React.ReactNode }) {
  return (
    <>
      <PestanasPedidos />
      {children}
    </>
  )
}

Aquí surge la pregunta clave de las rutas paralelas. La URL /panel/pendientes tiene página en el slot @pedidos, pero no en children (no existe app/panel/pendientes/page.tsx) ni en @estadisticas. ¿Qué pintan esos dos slots? Depende de cómo se haya llegado a la URL:

  • Navegación blanda (soft navigation): pulsas un <Link>. Next ya tiene en pantalla lo que mostraba cada slot y lo conserva en los slots que no tienen nada para la nueva URL. Solo cambia @pedidos.
  • Navegación dura (hard navigation): recargas la página o pegas la URL en el navegador. No hay nada anterior que conservar, así que para cada slot sin página Next busca un default.tsx.

Por eso el panel necesita dos ficheros default.tsx. Como aquí lo razonable es mostrar lo mismo que en /panel, basta con reexportar la página:

// app/panel/default.tsx: children cuando la URL no tiene page propio
// (por ejemplo, al recargar /panel/pendientes)
export { default } from './page'
// app/panel/@estadisticas/default.tsx
// Al recargar /panel/pendientes, este slot no tiene subpágina: muestra lo mismo.
export { default } from './page'

Así se comprobó, con Playwright contra next start:

[panel] blanda a /panel/pendientes | children: Panel de gestión | estadisticas: 1
[panel] recarga en /panel/pendientes | children: Panel de gestión | estadisticas: 1

Las dos líneas dan el mismo resultado visible, pero por caminos distintos: en la primera, children y @estadisticas conservan lo que tenían; en la segunda, Next pinta sus default.tsx.

📘 Concepto: Durante la navegación blanda, Next guarda el estado activo de cada slot (qué subpágina muestra). Tras una carga completa ese estado no existe, y default.tsx es la respuesta a “¿qué pinto en este slot si la URL no dice nada de él?”.

2.6. default.tsx es obligatorio 🔄 16.0

🔄 Cambia en Next.js 16.0. En Next.js 15, si un slot no tiene default.tsx, el proyecto compila y la recarga de esa URL muestra la página 404. Si usas Next.js 15: crea igualmente los default.tsx (o un default.tsx que llame a notFound() si quieres conservar el 404); así el proyecto ya está listo para la 16.

En Next.js 16, todos los slots necesitan un default.tsx para cada URL que no cubran con una página. Si falta, el build falla. Esto es lo que se obtuvo al borrar app/panel/@estadisticas/default.tsx y compilar con Next.js 16.4:

⚠ Strict route matching is enabled by default. This validation indicates a bug in your
app's route structure, but you can temporarily restore loose route matching by setting
`deprecated.looseRouteMatching` to `true` in your Next.js config.

> Build error occurred
Error: Turbopack build failed with 2 errors:
./app/panel/layout.tsx
Error: Parallel route slots cannot render the same URLs
The following layouts have parallel route slots that cannot render the same URLs:
app/panel/layout.tsx
- /panel/pendientes is missing a matching page or default.tsx in @estadisticas

Every URL matched by one slot must have a matching page or default.tsx in every sibling slot.

./app/panel/@pedidos/pendientes/page.tsx
Error: Unmatched app pages
The following page files do not match any complete route:
- app/panel/@pedidos/pendientes/page.tsx

Every page must be part of at least one complete route. Add matching pages or default files for the sibling parallel route slots, or remove the unreachable pages.

El mensaje es muy claro: cada URL que cubre un slot debe tener página o default.tsx en todos sus hermanos. La salida temporal que propone, deprecated.looseRouteMatching: true, existe en next.config.ts, pero es lo que su nombre dice: una opción obsoleta para salir del paso durante una migración. El arreglo real es crear el fichero.

¿Qué poner en un default.tsx? Depende de lo que signifique el slot:

Contenido de default.tsx Cuándo Resultado al recargar
export { default } from './page' El slot debe verse igual en todas las URLs Lo mismo que en la URL base
return null El slot es opcional (un modal, un aviso) El slot no pinta nada
notFound() La URL no tiene sentido sin ese slot Página 404, como hacía Next.js 15 sin default.tsx

En Next.js 15.5 se hizo la misma prueba (un slot @stats sin default.tsx y una subruta en el slot hermano). El proyecto compila sin errores y la navegación blanda funciona, pero la recarga muestra la página 404 (This page could not be found):

[15] blanda a /panel/otra: stats visible = Estadísticas
[15] recarga /panel/otra: estado HTTP 200

El estado HTTP es 200 porque next build prerenderizó /panel/otra como página estática (○), y lo que se prerenderizó fue la página de error: el HTML contiene el texto This page could not be found y la marca noindex. Es aún peor que un 404 limpio: el usuario ve un error, y un monitor que solo mire códigos de estado no lo detecta.

Ese fallo era el problema: un fallo que solo aparecía al recargar y que nadie veía al probar haciendo clic. Next.js 16 lo convierte en un error de compilación.

⚠️ Importante: default.tsx no se usa en la navegación blanda. Si pruebas tu aplicación solo haciendo clic, nunca lo verás. Recarga siempre cada URL de una ruta con slots para comprobar que los default.tsx muestran lo que esperas.

2.7. Saber qué subpágina muestra un slot

Las pestañas necesitan saber cuál está activa. Dentro del layout de un slot, useSelectedLayoutSegment() devuelve el segmento activo por debajo de ese layout: null en /panel y 'pendientes' en /panel/pendientes.

'use client'
// app/panel/@pedidos/pestanas.tsx
import Link from 'next/link'
import { useSelectedLayoutSegment } from 'next/navigation'

export default function PestanasPedidos() {
  // Dentro del slot, el segmento activo bajo @pedidos:
  // null en /panel y 'pendientes' en /panel/pendientes
  const segmento = useSelectedLayoutSegment()
  const clase = (activa: boolean) =>
    activa ? 'border-b-2 border-indigo-700 font-semibold' : 'text-slate-500'
  return (
    <nav className="mb-3 flex gap-4 text-sm">
      <Link href="/panel" className={clase(segmento === null)}>Todos</Link>
      <Link href="/panel/pendientes" className={clase(segmento === 'pendientes')}>
        Pendientes
      </Link>
    </nav>
  )
}

Desde un componente que está fuera del slot (por ejemplo, en app/panel/layout.tsx), el hook acepta el nombre del slot como argumento, parallelRouteKey: useSelectedLayoutSegment('pedidos') devuelve el segmento activo dentro de @pedidos. Existe también la versión en plural, useSelectedLayoutSegments('pedidos'), que devuelve todos los segmentos activos como un array.

2.8. Rutas condicionales

Como el layout recibe los slots como props normales, puede elegir cuál pinta. Es el patrón típico para enseñar un panel distinto según el rol:

// app/gestion/layout.tsx (fragmento)
import { getSesion } from '@/app/_lib/sesion' // lo construirás en el módulo 17

export default async function LayoutGestion({ admin, usuario }: LayoutProps<'/gestion'>) {
  const sesion = await getSesion()
  return sesion?.rol === 'ADMIN' ? admin : usuario
}

Cada slot (@admin y @usuario) tiene sus propias páginas, loading.tsx y error.tsx, y el layout solo decide. La sesión con cookies se construye en el módulo 17; allí volverás a este patrón. Ten en cuenta que leer la sesión usa cookies(), que con Cache Components exige un <Suspense> por encima.

⚠️ Importante: Ocultar un slot no es seguridad. Si un usuario sin permiso escribe la URL de una subruta de @admin, el layout no la pinta, pero el código del servidor de esa página puede haberse ejecutado igualmente. Comprueba los permisos también en cada página y en cada Server Action, como verás en el módulo 17.


3. Rutas interceptadas

3.1. Qué es interceptar una ruta

Una ruta interceptada muestra el contenido de otra ruta dentro del layout actual, sin salir de donde estás. El ejemplo clásico: en el catálogo pulsas un producto. La URL cambia a /tienda/producto/1, pero en vez de cambiar de página, la ficha se abre encima del catálogo.

La clave es que la intercepción solo ocurre en la navegación blanda. Si recargas la página o alguien abre /tienda/producto/1 desde un enlace, no hay catálogo debajo que conservar: se muestra la página completa del producto.

Dos columnas. Izquierda, navegación blanda: el catálogo de fondo con un modal encima generado por @modal/(.)producto/[id]. Derecha, navegación dura: la página completa del producto y el slot modal vacío por su default.tsx

Figura 4. La misma URL produce dos pantallas distintas según cómo se llegue a ella.

3.2. La convención (.)

Para interceptar, se crea una carpeta con el nombre de la ruta destino precedido de un prefijo entre paréntesis, que indica cuántos segmentos subir desde el sitio donde está la carpeta, al estilo de ./ y ../ en las rutas de ficheros:

Prefijo Significado Ejemplo
(.) Mismo nivel app/tienda/(.)producto intercepta /tienda/producto
(..) Un nivel arriba app/tienda/ofertas/(..)producto intercepta /tienda/producto
(..)(..) Dos niveles arriba app/a/b/(..)(..)login intercepta /login
(...) Desde la raíz de app app/x/y/z/(...)login intercepta /login

⚠️ Importante: Los niveles se cuentan en segmentos de la URL, no en carpetas. Las carpetas @slot (y los grupos (grupo)) no son segmentos, así que no cuentan. En app/tienda/@modal/(.)producto, la carpeta interceptora está a dos carpetas de app/tienda/producto, pero a cero segmentos: @modal no existe en la URL. Por eso es (.) y no (..). Es el error más frecuente con este patrón.

Interceptar por sí solo no hace un modal: la ruta interceptada sustituiría a children y el catálogo desaparecería igualmente. Para que el catálogo siga debajo, la ruta interceptada se pone en un slot. Eso es el apartado siguiente.


4. Modales con URL: rutas paralelas + interceptadas

4.1. Lo que se quiere conseguir

Un buen modal de producto cumple cinco requisitos que con un useState son difíciles:

  1. La URL refleja el modal: /tienda/producto/1. Se puede copiar, compartir y guardar en marcadores.
  2. Recargar no pierde el contexto: con el modal abierto, F5 muestra la ficha del producto (como página completa), no el catálogo sin modal.
  3. Atrás cierra el modal en vez de llevarte a la página anterior al catálogo.
  4. Adelante lo vuelve a abrir.
  5. El catálogo sigue debajo, con su scroll y su estado.

4.2. La estructura

app/tienda/
├── layout.tsx                    ← pinta children y modal
├── page.tsx                      ← catálogo (/tienda)
├── producto/[id]/page.tsx        ← página completa (/tienda/producto/1 al recargar)
├── _componentes/
│   ├── ficha-producto.tsx        ← la ficha, compartida por las dos
│   └── modal.tsx                 ← el <dialog>
└── @modal/
    ├── default.tsx               ← null: sin modal
    ├── [...resto]/page.tsx       ← null: otras rutas de /tienda cierran el modal
    └── (.)producto/[id]/page.tsx ← la ficha dentro del modal (navegación blanda)

El layout pinta los dos slots. El <Suspense> que rodea a children se explica en el apartado 6; de momento basta con saber que da un esqueleto mientras carga la tienda.

// app/tienda/layout.tsx: children es la página normal; modal, el slot @modal
import { Suspense } from 'react'

export default function LayoutTienda({ children, modal }: LayoutProps<'/tienda'>) {
  return (
    <>
      <Suspense fallback={<p>Cargando la tienda…</p>}>{children}</Suspense>
      {modal}
    </>
  )
}

El catálogo es una lista de <Link> normales. No sabe nada del modal: enlaza a la URL de la ficha y es Next quien decide si la intercepta.

// app/tienda/page.tsx: catálogo (en caché, entra en el armazón estático)
import Link from 'next/link'
import { getProductos, euros } from '@/app/_lib/api'

export default async function Catalogo() {
  const productos = await getProductos()
  return (
    <>
      <h1 className="mb-4 text-xl font-bold">Catálogo</h1>
      <Link href="/tienda/ofertas" className="mb-4 inline-block text-indigo-700">
        Ver ofertas
      </Link>{' '}
      <Link href="/tienda/producto/3" prefetch className="destacado mb-4 ml-4 inline-block text-indigo-700">
        Producto destacado
      </Link>
      <ul className="grid grid-cols-2 gap-3 md:grid-cols-4">
        {productos.map((p) => (
          <li key={p.productoId}>
            <Link
              href={`/tienda/producto/${p.productoId}`}
              className="block rounded border bg-white p-3 hover:border-indigo-500"
            >
              <span className="block font-medium">{p.nombre}</span>
              <span className="text-sm text-slate-500">{euros(p.precio)}</span>
            </Link>
          </li>
        ))}
      </ul>
    </>
  )
}

Los enlaces Ver ofertas y Producto destacado se usan en los apartados 5.5, 6 y 7.

4.4. La ficha, compartida

La ficha del producto se usa en dos sitios (página completa y modal), así que va en un componente aparte. Tiene tres piezas:

  • FichaDesdeParams recibe la promesa params y la espera. Se usa dentro de un <Suspense>; el apartado 6.4 explica por qué es importante no esperar params en la propia página.
  • FichaProducto pinta los datos, que vienen de getProducto (en caché).
  • Stock lee el stock sin caché y llega por streaming dentro de su propio <Suspense>.
// app/tienda/_componentes/ficha-producto.tsx
// La misma ficha sirve para la página completa y para el modal.
import { Suspense } from 'react'
import { notFound } from 'next/navigation'
import { getProducto, getStockEnVivo, euros } from '@/app/_lib/api'

// Espera params DENTRO de un <Suspense> del que la llama
export async function FichaDesdeParams({
  params,
  enlaceCompleto = false,
}: {
  params: Promise<{ id: string }>
  enlaceCompleto?: boolean
}) {
  const { id } = await params
  return (
    <>
      <FichaProducto id={Number(id)} />
      {enlaceCompleto && (
        // <a> normal: carga completa del documento, sin interceptar
        <a href={`/tienda/producto/${id}`} className="mt-4 block text-sm text-indigo-700">
          Abrir la ficha completa
        </a>
      )}
    </>
  )
}

export async function FichaProducto({ id }: { id: number }) {
  const p = await getProducto(id) // en caché
  if (!p) notFound()
  return (
    <article className="space-y-2">
      <h2 className="text-lg font-bold">{p.nombre}</h2>
      <p className="text-slate-600">{p.descripcion}</p>
      <p className="text-xl">{euros(p.precio)}</p>
      <p className="text-sm">
        Categoría: {p.categoria} ·{' '}
        <Suspense fallback={<span className="text-slate-400">Consultando stock…</span>}>
          <Stock id={id} />
        </Suspense>
      </p>
    </article>
  )
}

export function FichaEsqueleto() {
  return (
    <div className="animate-pulse space-y-2">
      <div className="h-6 w-2/3 rounded bg-slate-200" />
      <div className="h-4 w-full rounded bg-slate-200" />
      <div className="h-6 w-1/4 rounded bg-slate-200" />
    </div>
  )
}

// Sin caché: llega por streaming después del armazón
async function Stock({ id }: { id: number }) {
  const stock = await getStockEnVivo(id)
  return <span>{stock > 0 ? `${stock} en almacén` : 'Agotado'}</span>
}

💡 Consejo: El enlace Abrir la ficha completa es un <a> y no un <Link> a propósito. Un <Link> hace navegación blanda, y desde el catálogo esa URL se volvería a interceptar: el usuario seguiría viendo el modal. Un <a> normal hace una carga completa del documento, que nunca se intercepta.

4.5. La página completa

Es la página que se ve al recargar o al abrir la URL desde fuera. Lleva generateStaticParams para prerenderizar las fichas en el build, y export const instant = true, que se explica en el apartado 6.

// app/tienda/producto/[id]/page.tsx: la página completa (URL compartida o recarga)
import { Suspense } from 'react'
import Link from 'next/link'
import { getProductos } from '@/app/_lib/api'
import { FichaDesdeParams, FichaEsqueleto } from '../../_componentes/ficha-producto'

export const instant = true

export async function generateStaticParams() {
  const productos = await getProductos()
  return productos.map((p) => ({ id: String(p.productoId) }))
}

// La página NO espera params: así su armazón no depende de la URL
export default function PaginaProducto({ params }: PageProps<'/tienda/producto/[id]'>) {
  return (
    <div className="max-w-xl rounded border bg-white p-6">
      <Link href="/tienda" className="text-sm text-indigo-700">← Volver al catálogo</Link>
      <p className="mt-2 text-xs uppercase text-slate-400">Página completa</p>
      <Suspense fallback={<FichaEsqueleto />}>
        <FichaDesdeParams params={params} />
      </Suspense>
    </div>
  )
}

4.6. El slot @modal

Sin modal activo, el slot no pinta nada:

// app/tienda/@modal/default.tsx: sin modal activo, el slot no pinta nada
export default function SinModal() {
  return null
}

Y esta es la ruta interceptada. Está en @modal (un slot, que no cuenta como segmento) y se llama (.)producto: intercepta /tienda/producto/[id] desde el mismo nivel, /tienda.

// app/tienda/@modal/(.)producto/[id]/page.tsx
// Intercepta /tienda/producto/[id] cuando se llega navegando desde /tienda.
import { Suspense } from 'react'
import Link from 'next/link'
import { Modal } from '@/app/tienda/_componentes/modal'
import { FichaDesdeParams, FichaEsqueleto } from '@/app/tienda/_componentes/ficha-producto'

// La ruta interceptada se prerenderiza aparte: necesita su propia lista
export { generateStaticParams } from '@/app/tienda/producto/[id]/page'

export default function ModalProducto({ params }: PageProps<'/tienda/producto/[id]'>) {
  return (
    <Modal>
      <Suspense fallback={<FichaEsqueleto />}>
        <FichaDesdeParams params={params} enlaceCompleto />
      </Suspense>
      <Link href="/tienda/ofertas" className="mt-4 block text-sm text-indigo-700">
        Ver ofertas
      </Link>
    </Modal>
  )
}

Fíjate en la línea de generateStaticParams. Next prerenderiza la ruta interceptada por separado de la página completa: en la salida de next build aparecen las dos.

├ ○ /tienda                             1h      1d
├   /tienda/(.)producto/[id]
│ ├ ◐ /tienda/(.)producto/[id]
│ ├ ◐ /tienda/(.)producto/1             1h      1d
│ ├ ◐ /tienda/(.)producto/2             1h      1d
│ └ ◐ [+18 more paths]
...
└   /tienda/producto/[id]
  ├ ◐ /tienda/producto/[id]
  ├ ◐ /tienda/producto/1                1h      1d
  ├ ◐ /tienda/producto/2                1h      1d
  └ ◐ [+18 more paths]

En la primera versión de este ejemplo, la página del modal esperaba params directamente y no tenía generateStaticParams. El build falló:

Error: Route "/tienda/(.)producto/[id]": 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

El <Suspense> del layout de la tienda envuelve a children, no a modal, así que nada protegía la espera de params en el slot. Hay dos arreglos, y la versión final usa los dos: dar a la ruta interceptada su propia lista de parámetros (reexportando la de la página completa) y esperar params dentro de un <Suspense>.

4.7. El componente Modal

El modal usa el elemento <dialog> de HTML con showModal(), que da gratis lo que más cuesta programar a mano en un modal accesible: el foco queda atrapado dentro, la tecla Esc lo cierra y el resto de la página se vuelve inerte (no recibe clics ni foco). Lo tienes explicado en el Curso de HTML y CSS.

Cerrar el modal es volver atrás en el historial con router.back(). Así la URL vuelve a /tienda y el botón Adelante del navegador lo puede reabrir.

'use client'
// app/tienda/_componentes/modal.tsx
import { useEffect, useRef } from 'react'
import { useRouter } from 'next/navigation'

export function Modal({ children }: { children: React.ReactNode }) {
  const router = useRouter()
  const dialogo = useRef<HTMLDialogElement>(null)
  // true cuando el cierre lo provoca una navegación, no el usuario
  const porNavegacion = useRef(false)

  useEffect(() => {
    const d = dialogo.current
    porNavegacion.current = false
    // showModal(): foco atrapado, Esc para cerrar y fondo inerte, gratis
    if (d && !d.open) d.showModal()
    return () => {
      // Al ocultarse (Activity) o desmontarse, saca el diálogo de la capa superior;
      // si no, la página entera se queda inerte aunque el modal no se vea.
      porNavegacion.current = true
      d?.close()
    }
  }, [])

  return (
    <dialog
      ref={dialogo}
      className="modal m-auto w-full max-w-lg rounded-lg p-6 shadow-xl"
      // Esc, la X o el fondo: cerrar = volver atrás en el historial
      onClose={() => {
        if (!porNavegacion.current) router.back()
      }}
      onClick={(e) => {
        if (e.target === dialogo.current) dialogo.current?.close()
      }}
    >
      <button
        onClick={() => dialogo.current?.close()}
        aria-label="Cerrar"
        className="float-right text-xl leading-none"
      >
        ×
      </button>
      {children}
    </dialog>
  )
}

Todas las formas de cerrar (Esc, la X y el clic en el fondo) pasan por dialog.close(), que dispara el evento close, y es ahí donde se llama a router.back(). Un solo punto de salida evita volver atrás dos veces. El clic en el fondo funciona porque, cuando pulsas fuera del contenido, el objetivo del evento es el propio <dialog> (el fondo oscuro es su ::backdrop).

La función de limpieza del efecto y la referencia porNavegacion no son decoración: arreglan un fallo real que se explica en el apartado 5.6.

El estilo del fondo va en globals.css:

dialog.modal::backdrop {
  background: rgb(15 23 42 / 0.55);
}

4.8. Comprobación

Se recorrieron con Playwright todos los requisitos del apartado 4.1, contra next start:

[modal] URL /tienda/producto/1 | dialog abierto: 1 | catálogo debajo: 1
[modal] tras Esc /tienda | dialog: 0
[modal] tras Adelante /tienda/producto/1 | dialog: 1
[modal] tras recargar /tienda/producto/1 | dialog: 0 | página completa: 1

Línea a línea: el clic en el primer producto abre el modal con la URL de la ficha y el catálogo debajo; Esc lo cierra y la URL vuelve a /tienda; el botón Adelante del navegador lo reabre; y al recargar con la URL de la ficha aparece la página completa, sin modal.

El catálogo de la tienda oscurecido de fondo y, encima, un modal con la ficha del Portátil UltraBook 14: descripción, 899,00 euros, categoría Portátiles, 25 en almacén y los enlaces Ver ofertas y Abrir la ficha completa

Figura 5. Navegación blanda: la ruta interceptada pinta la ficha en el slot @modal, encima del catálogo. La URL es /tienda/producto/1.

La página completa del producto Portátil UltraBook 14, con el enlace Volver al catálogo y la etiqueta Página completa, sin catálogo de fondo

Figura 6. La misma URL tras recargar: navegación dura, sin intercepción. @modal pinta su default.tsx (nada).


5. Errores frecuentes con modales y slots

5.1. El modal no se cierra al navegar a otra página de la sección

Síntoma: desde dentro del modal pulsas Ver ofertas (un <Link> a /tienda/ofertas). La URL cambia y la página de ofertas aparece debajo, pero el modal sigue abierto encima.

Causa: es la regla de la navegación blanda del apartado 2.5. Para /tienda/ofertas, el slot children tiene página, pero @modal no tiene nada. En navegación blanda, un slot sin coincidencia conserva lo que mostraba: el modal.

Sin el arreglo, la prueba con Playwright lo muestra:

[modal] a ofertas desde el modal /tienda/ofertas | dialog: 1 | visible: true
[modal] clic en la cabecera: BLOQUEADO

El diálogo sigue abierto y visible y, como es modal, la cabecera no responde.

Arreglo: dar a @modal una página para “cualquier otra ruta de la tienda” que no pinte nada. Una ruta catch-all (la viste en el módulo 04) dentro del slot:

// app/tienda/@modal/[...resto]/page.tsx
// Cualquier otra ruta bajo /tienda cierra el modal
export default function SinModal() {
  return null
}

La ruta interceptada (.)producto/[id] es más específica que [...resto], así que sigue ganando para las fichas.

5.2. La página entera deja de responder a los clics 🆕 16.0

🆕 Desde Next.js 16.0. No existe en Next.js 15, donde la página que abandonas se desmonta y el <dialog> desaparece del documento. Si usas Next.js 15: no necesitas la limpieza del efecto, aunque no hace ningún daño y deja el componente listo para la 16.

Con el catch-all del apartado anterior, el modal desaparece de la vista, pero apareció un fallo nuevo: la cabecera y el resto de la página dejaron de responder a los clics. Así lo detectó la prueba, con la primera versión del componente Modal (sin función de limpieza en el efecto):

[modal] a ofertas desde el modal /tienda/ofertas | dialog: 1 | visible: false
[modal] clic en la cabecera: BLOQUEADO

El diálogo seguía en el documento con el atributo open, pero invisible. La causa es la combinación de dos cosas:

  1. Como viste en el apartado 9 del módulo 03, con Cache Components Next.js 16 no desmonta lo que abandonas: lo oculta con <Activity>, que pone display: none y ejecuta las limpiezas de los efectos.
  2. Un <dialog> abierto con showModal() vive en la capa superior (top layer) del navegador y hace inerte todo lo demás. Ocultarlo con CSS no lo cierra: sigue siendo modal, y la página sigue inerte.

Arreglo: cerrar el diálogo en la función de limpieza del efecto, que React ejecuta al ocultar con <Activity> y al desmontar. Como close() dispara el evento close, una referencia (porNavegacion) distingue ese cierre del que hace el usuario, para no llamar a router.back() por error. Con el componente del apartado 4.7:

[modal] a ofertas desde el modal /tienda/ofertas | dialog: 0 | visible: false
[modal] clic en la cabecera: funciona

Al volver con Adelante, <Activity> muestra de nuevo el slot, el efecto vuelve a ejecutarse y showModal() reabre el diálogo. Es exactamente lo que medía la línea tras Adelante del apartado 4.8.

💡 Consejo: La lección general sirve para cualquier componente de Next.js 16: todo lo que un efecto “abre” fuera de React (diálogos modales, suscripciones, temporizadores, pantalla completa) debe cerrarse en su limpieza, porque la limpieza se ejecuta también cuando la página solo se oculta.

5.3. Otros errores típicos

Síntoma Causa Arreglo
El modal no se abre nunca: se ve la página completa Prefijo mal contado: (..)producto en vez de (.)producto porque se contó @modal Cuenta segmentos de URL, no carpetas (apartado 3.2)
Al recargar con el modal abierto se ve el catálogo sin modal Te falta la página completa producto/[id]/page.tsx La ruta interceptada solo cubre la navegación blanda; la dura necesita su página
Parallel route slots cannot render the same URLs Falta un default.tsx en algún slot Créalo (apartado 2.6)
El enlace Ver ficha completa vuelve a abrir el modal Es un <Link>, y se intercepta Usa un <a> normal
Error: Route "/tienda/(.)producto/[id]": ... uncached or runtime data en el build La ruta interceptada espera params fuera de <Suspense> y sin generateStaticParams generateStaticParams propio y params dentro de <Suspense> (apartado 4.6)
Cerrar el modal lleva fuera de la web Se entró directamente en la URL de la ficha y no hay historial anterior Con navegación dura se ve la página completa, no el modal: router.back() solo se usa en el modal. Si reutilizas el componente fuera, usa router.push('/tienda')
El modal aparece en la página /tienda/producto/1 además del catálogo Ruta interceptada dentro de children y no en un slot La carpeta (.)producto va dentro de @modal

6. Navegación instantánea 🆕 16.3

🆕 Desde Next.js 16.3. No existe en Next.js 15: no hay validación de navegaciones ni opción instant (una página que la exporta compila, pero se ignora sin avisar). Si usas Next.js 15: pon un loading.tsx en cada segmento al que se navega y un <Suspense> alrededor de cada dato lento, y revisa a mano, con la pestaña Red con limitación de velocidad, que cada navegación muestre algo al instante.

6.1. Qué significa “instantánea”

Next.js 16.3 define con precisión una navegación instantánea: en cuanto el usuario pulsa, el navegador empieza a pintar la página nueva con lo que es estático, lo que está en caché y los fallbacks de <Suspense>, mientras el servidor envía el resto por streaming. Lo contrario es una navegación que bloquea: el usuario pulsa y la pantalla se queda igual hasta que el servidor responde.

La definición supone cachés calientes: la primera vez que alguien pide un dato en caché, el servidor tiene que calcularlo.

Hay dos piezas, y conviene no confundirlas:

Pieza Qué es Cuándo se usa
Armazón estático (static shell) El HTML que se prerenderiza en el build: todo lo que no depende de la petición Visitas directas (carga completa del documento)
App Shell Lo que se renderiza para una ruta sin depender de la URL concreta (sin params ni searchParams) Navegaciones del cliente con Partial Prefetching (apartado 7)

6.2. Por qué una carga completa y una navegación no se comportan igual

Este es el concepto que más cuesta y el que explica casi todos los avisos de validación:

  • En una carga completa, toda la página se renderiza desde la raíz. Cualquier <Suspense> del árbol, aunque esté en el layout raíz, recoge lo que suspende por debajo.
  • En una navegación del cliente entre /tienda y /tienda/ofertas, el layout que comparten (app/tienda/layout.tsx) ya está montado y no se vuelve a renderizar. Solo cambia lo que hay por debajo. Un <Suspense> del layout compartido está por encima de lo que cambia, y React no vuelve a mostrar un fallback que ya se había resuelto durante una transición: deja la página anterior en pantalla hasta que llega la nueva. La navegación bloquea.

El layout de la tienda del apartado 4.2 tiene exactamente ese <Suspense> alrededor de children. Sirve para la carga completa, pero no para navegar dentro de la tienda.

6.3. Validación en desarrollo

Con Cache Components, Next.js 16.3+ valida en next dev cada página y avisa de lo que impediría que la navegación hacia ella fuera instantánea. No hace falta activar nada: el nivel por defecto (validationLevel: 'warning') valida todas las páginas y default.tsx, solo en desarrollo. El build no se ve afectado.

Para verlo, esta página de ofertas espera un dato sin caché directamente, sin <Suspense> propio. El <Suspense> del layout de la tienda la cubre en la carga completa, así que compila sin problemas, pero la navegación desde el catálogo bloquearía:

// app/tienda/ofertas/page.tsx (VERSIÓN QUE BLOQUEA LA NAVEGACIÓN)
import { getProductos, getStockEnVivo, euros } from '@/app/_lib/api'

export default async function Ofertas() {
  const productos = await getProductos()
  const primero = productos[0]
  // Dato sin caché esperado directamente en la página, sin <Suspense> propio
  const stock = await getStockEnVivo(primero.productoId)
  return (
    <>
      <h1 className="text-xl font-bold">Ofertas</h1>
      <p>
        {primero.nombre}: {euros(primero.precio * 0.9)} ({stock} unidades)
      </p>
    </>
  )
}

Al abrir /tienda/ofertas con next dev, la página se ve bien, pero abajo a la izquierda aparece una insignia naranja, 1 Insight, y en la consola del navegador:

Error: Route "/tienda/ofertas": Next.js encountered uncached data during prerendering or a navigation.

`fetch(...)` or `connection()` accessed outside of `<Suspense>` prevents the route from being prerendered or the navigation from being instant, leading to a slower user experience.

Ways to fix this:
  - [stream] Provide a placeholder with `<Suspense fallback={...}>` around the data access
  - [cache] Cache the data access with `"use cache"` (does not apply to `connection()`)
  - [block] Set `export const instant = false` to allow a blocking route

Al pulsar la insignia se abre el detalle, que señala la línea exacta del fetch sin caché y ofrece tres arreglos:

Panel de Next.js DevTools con la pestaña Insights 1/1: etiquetas Instant y Server, el título Next.js encountered uncached data during a navigation, el fragmento de app/_lib/api.ts con la línea del fetch con cache no-store resaltada y tres tarjetas de arreglo: Stream, Cache y Block

Figura 7. La validación de navegación instantánea en next dev (Next.js 16.4). Cada tarjeta enlaza a una explicación y tiene un botón para copiar un *prompt para un agente.*

Los tres arreglos son las tres opciones de siempre con Cache Components:

Arreglo Qué haces Cuándo
Stream Pones un <Suspense> por debajo del layout compartido, alrededor del dato El dato debe ser fresco en cada petición
Cache Cacheas el dato con 'use cache' El dato puede tener cierta antigüedad
Block Declaras export const instant = false Aceptas que esa navegación espere (por ejemplo, un paso de pago)

Aquí el stock debe ser fresco, así que el arreglo es Stream. La forma más corta es un loading.tsx en el segmento de ofertas: crea un <Suspense> en ese segmento, por debajo del layout compartido.

// app/tienda/ofertas/loading.tsx: límite de Suspense POR DEBAJO del layout compartido
export default function CargandoOfertas() {
  return <p className="animate-pulse text-slate-500">Buscando ofertas…</p>
}

Con este fichero, al volver a abrir la página en next dev el aviso desaparece: la consola no muestra ningún error y la insignia ya no está.

⚠️ Importante: La validación solo funciona en next dev, y next dev no precarga enlaces. Por eso en desarrollo las navegaciones no se sienten tan rápidas como en producción. La validación te dice lo que pasará con next start, que sí precarga.

6.4. params también bloquea

En una primera versión, la página del modal esperaba params en su primera línea (con generateStaticParams ya puesto). Compiló, pero en next dev la terminal mostró este aviso al navegar al modal:

Error: Route "/tienda/(.)producto/[id]": Next.js encountered URL data during prerendering or a navigation.

`params` or `searchParams` accessed outside of `<Suspense>` may prevent the navigation from being instant, leading to a slower user experience.

Ways to fix this:
  - [stream] Provide a placeholder with `<Suspense fallback={...}>` around the data access
  - [block] Set `export const instant = false` to allow a blocking route

Learn more: https://nextjs.org/docs/messages/instant-shell-url-data
    at ModalProducto (app\tienda\@modal\(.)producto\[id]\page.tsx:11:18)

Con Partial Prefetching (apartado 7), lo que se precarga de una ruta es su App Shell, que no depende de la URL concreta. Si la página espera params arriba del todo, no hay nada de ella en el App Shell: la navegación tiene que esperar al servidor. La solución es el patrón que ya viste en el apartado 4: la página no espera params; se los pasa como promesa a un componente hijo que los espera dentro de un <Suspense>:

// Antes: bloquea
export default async function ModalProducto({ params }: PageProps<'/tienda/producto/[id]'>) {
  const { id } = await params
  return <Modal><FichaProducto id={Number(id)} /></Modal>
}

// Después: el marco del modal y el esqueleto están en el App Shell
export default function ModalProducto({ params }: PageProps<'/tienda/producto/[id]'>) {
  return (
    <Modal>
      <Suspense fallback={<FichaEsqueleto />}>
        <FichaDesdeParams params={params} enlaceCompleto />
      </Suspense>
    </Modal>
  )
}

Ahora, al pulsar un producto, el modal se abre al instante con el esqueleto, y la ficha (en caché) llega en cuanto el servidor resuelve el id. Si quieres que llegue también la ficha al instante, el apartado 7.3 muestra cómo precargar los datos de un enlace concreto.

6.5. La opción instant

La validación se ajusta por segmento con una exportación en page.tsx o layout.tsx:

// Exige que las navegaciones a este segmento sean instantáneas
export const instant = true
Valor Efecto
true Valida el segmento al nivel configurado (por defecto, warning: avisos en desarrollo)
false Permite que la navegación a este segmento bloquee y lo saca de la validación
{ level: 'warning' } Igual que true, con el nivel explícito. Hoy 'warning' es el único nivel

Detalles que conviene saber:

  • instant solo funciona con cacheComponents y no se puede exportar desde un componente con 'use client'.
  • Con el nivel por defecto, todas las páginas se validan aunque no exporten nada. instant = true sirve para dejar constancia de una intención (“esta página debe ser instantánea”) en el propio código. Así lo hace la página completa del producto del apartado 4.5.
  • instant = false en un layout permite que la entrada a una sección bloquee mientras se exige que la navegación dentro de ella sea instantánea. Por ejemplo, un layout que comprueba la sesión con instant = false y páginas con instant = true debajo: entrar en la sección puede esperar, pero cambiar de pestaña no.
  • Si prefieres validar solo los segmentos que lo pidan, cambia el nivel global en next.config.ts:
// next.config.ts (fragmento)
const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
  experimental: {
    instantInsights: {
      validationLevel: 'manual-warning', // solo los segmentos con instant explícito
    },
  },
}

📘 Concepto: No confundas instant con ensureStatic (del módulo 09). ensureStatic exige que la salida del servidor sea estática. instant comprueba que la navegación muestre algo al momento. Una página puede ser totalmente estática y aun así bloquear, por ejemplo si un Client Component carga sus datos en el navegador antes de pintar.

6.6. Inspeccionar los estados de carga

Next.js DevTools (el botón con la N abajo a la izquierda en next dev) incluye el Navigation Inspector cuando Cache Components está activo. Con la opción Pause on navigations encendida:

  • Al recargar, la página se congela en su armazón estático, antes de que llegue nada dinámico.
  • Al pulsar un enlace, se congela en lo que se precargó del destino.

Pulsas Resume para terminar la navegación. Es la forma más rápida de ver si tus esqueletos tienen sentido y cuánto contenido real hay en el primer pintado. Para no dejar la aplicación congelada, apaga la opción al terminar.

⚠️ Importante: El inspector usa una cookie (next-instant-navigation-testing) para congelar las páginas. Las cookies son por dominio, no por puerto: si tienes varios proyectos abiertos en localhost (por ejemplo, la API EMPRESA y tu aplicación, o dos ejercicios), la cookie se comparte y puede congelar el otro proyecto. Cierra el inspector o borra la cookie al cambiar de proyecto.

Para que una navegación siga siendo instantánea cuando el proyecto crezca, el paquete @next/playwright (aparte, no viene con la plantilla) ofrece un ayudante instant() que hace las comprobaciones de una prueba E2E sobre lo que se ve antes de que llegue lo dinámico. Las pruebas con Playwright se ven en el módulo 21.


7. Partial Prefetching 🆕 16.3

🆕 Desde Next.js 16.3. No existe en Next.js 15, donde cada <Link> visible precarga su propia URL. Si usas Next.js 15: en listas largas, pon prefetch={false} en los enlaces que rara vez se pulsan para no cargar el servidor con precargas, y deja la precarga por defecto en los enlaces importantes.

7.1. El problema de precargar cada enlace

En el módulo 03 viste que <Link> precarga su destino cuando entra en pantalla. Con una lista de 20 productos, son 20 precargas de 20 URLs distintas. Con 200, 200. Y casi todas devuelven lo mismo: el marco de la ficha de producto, que solo cambia en los datos de cada uno.

Partial Prefetching cambia la unidad de precarga: en lugar de una por enlace, una por ruta. Next precarga el App Shell de /tienda/producto/[id] (todo lo que no depende del id) y lo reutiliza para todos los enlaces que apuntan a esa ruta. Lo que depende de la URL concreta se resuelve al navegar.

Comparación. Izquierda, partialPrefetching false: una caja por cada enlace (id 1, 2, 3...), una precarga de página por enlace. Derecha, partialPrefetching true: una única caja App Shell de /tienda/producto/[id] de la que salen flechas a todos los enlaces

Figura 8. Sin Partial Prefetching, una precarga por enlace. Con él, un App Shell por ruta, compartido.

Se activa en next.config.ts y necesita Cache Components (sin cacheComponents, la configuración no pasa la validación). create-next-app 16.4 ya la trae:

// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
}

export default nextConfig

Si tienes cacheComponents y no defines partialPrefetching, se comporta como false y Next avisa al arrancar. La documentación anuncia que en la próxima versión mayor las dos opciones estarán activas siempre y desaparecerán.

7.2. La medición

Para ver la diferencia de verdad se abrió el catálogo (con 20 enlaces a fichas, uno de ellos repetido en Producto destacado) en un navegador real contra next start, se registraron las peticiones de precarga (las que llevan la cabecera next-router-prefetch) y se compiló dos veces, cambiando solo partialPrefetching.

Cada precarga de Next.js 16 va por segmentos, como viste en el módulo 03: una petición _tree con el árbol de la ruta (pequeña) y una por cada segmento con contenido. Los resultados:

  partialPrefetching: false partialPrefetching: true
Peticiones de precarga en total 49 31
Peticiones _tree (una por URL enlazada) 24 24
Segmentos de contenido de las fichas 20, una por producto 1, compartido

Con false, cada producto pidió su propio segmento de página:

/tienda/producto/1 seg=/tienda/@modal/!KF9fU0xPVF9fKQ/!KC4pcHJvZHVjdG8/$d$id/__PAGE__
/tienda/producto/2 seg=/tienda/@modal/!KF9fU0xPVF9fKQ/!KC4pcHJvZHVjdG8/$d$id/__PAGE__
/tienda/producto/4 seg=/tienda/@modal/!KF9fU0xPVF9fKQ/!KC4pcHJvZHVjdG8/$d$id/__PAGE__
... (una línea por producto, 20 en total)

Con true, ese segmento se pidió una sola vez para toda la ruta. El nombre del segmento lo delata: lleva $d$id, el hueco del parámetro dinámico, no un valor concreto. Fíjate también en que la precarga va al slot @modal y a la ruta interceptada (.)producto (codificados en el nombre): Next precarga lo que se verá al pulsar desde el catálogo, que es el modal.

📘 Concepto: El App Shell de una ruta es como el fichero JavaScript de una ruta en una SPA con división de código: un artefacto por ruta, compartido por todos los enlaces que apuntan a ella, y guardado en la caché del cliente.

A veces quieres que un enlace concreto llegue completo al instante: el producto destacado de la portada, el siguiente paso de un proceso. prefetch={true} (o solo prefetch) activa la precarga por enlace: además del App Shell, resuelve los datos de esa URL (params, searchParams) y el contenido en caché que depende de ellos.

<Link href="/tienda/producto/3" prefetch>
  Producto destacado
</Link>

Úsalo con mesura: cada enlace con prefetch vuelve a ser una precarga por URL, con su coste en el servidor si la página tiene datos de la petición.

💡 Consejo: La precarga por enlace no arregla una ruta que bloquea. Primero haz que la ruta sea instantánea con su App Shell (apartado 6), y después decide qué enlaces merecen precargar más.

7.4. La opción de segmento prefetch

El enlace expresa intención (“este destino merece la pena”). El destino pone un límite de coste: con una exportación en su page.tsx o layout.tsx decide cuánto trabajo acepta que se haga por adelantado, venga de donde venga el enlace.

// app/gestion/informes/page.tsx (fragmento)
// Página pesada y poco visitada: no precargarla nunca
export const prefetch = 'force-disabled'
Valor Efecto
'partial' Activa Partial Prefetching solo en este segmento, sin la opción global. Sirve para adoptarlo poco a poco
'force-disabled' No precarga nunca los datos del segmento ni de los que cuelgan de él
'auto' El valor por defecto; no hace falta escribirlo

Solo funciona con cacheComponents y no se puede usar en un Client Component. Si en el mismo fichero necesitas la función prefetch() de next/cache (del módulo 09), impórtala con otro nombre para que no choque con la exportación: import { prefetch as prefetchStage } from 'next/cache'.

<Link prefetch={false}> sigue funcionando como siempre: desactiva la precarga de ese enlace, sea cual sea la configuración del destino.

⚠️ Importante: Las rutas que leen cookies() o headers() generan un App Shell que incluye datos de la sesión. Next lo detecta y guarda ese App Shell por sesión en el cliente, para no mostrar a un usuario lo que se precargó para otro.


8. View Transitions 🔄 16.0

🔄 Cambia en Next.js 16.0. En Next.js 15 las View Transitions de React son experimentales: hay que activar experimental.viewTransition: true en next.config.ts e importar unstable_ViewTransition y unstable_addTransitionType de react. Si usas Next.js 15: activa esa opción, importa unstable_ViewTransition as ViewTransition, declara sus tipos (apartado 8.6) y marca la dirección con startTransition y router.push, porque transitionTypes de Link no existe.

8.1. Qué son

Cuando navegas, una pantalla se sustituye por otra de golpe. Si en la lista pulsas VENTAS y en la página nueva el título es VENTAS en grande, el ojo no ve que es “lo mismo, más de cerca”: ve una cosa que desaparece y otra que aparece.

La View Transitions API del navegador resuelve esto: hace una “foto” del estado anterior, aplica el cambio, hace otra del nuevo y anima entre las dos con CSS. Los elementos con el mismo nombre en las dos fotos se transforman uno en otro (posición y tamaño). React la integra con el componente <ViewTransition>, y Next.js 16 la usa en la navegación sin configuración: React 19.3 trae <ViewTransition> y addTransitionType.

Claves para entenderla:

  • <ViewTransition> solo anima cambios que ocurren dentro de una transición de React (startTransition, <Suspense> al resolverse, useDeferredValue). Un setState normal no anima. En Next, cada navegación es una transición, así que las navegaciones animan solas.
  • Las animaciones se escriben en CSS con los pseudoelementos ::view-transition-old(...), ::view-transition-new(...) y ::view-transition-group(...).
  • Si el navegador no la soporta, la aplicación funciona igual, sin animación. Los tipos de transición y view-transition-class, que usa React, necesitan Chromium 125+ o versiones recientes de Safari y Firefox.

Lo básico de la API de React (enter, exit, update, share) lo tienes en el Curso de React. Aquí se ve lo que aporta Next: las navegaciones.

8.2. Elemento compartido: el nombre del departamento

En la lista de departamentos, cada nombre va en un <ViewTransition> con un name único. En el detalle, el título lleva el mismo name. Al navegar, el navegador transforma uno en otro.

// app/departamentos/page.tsx
import { ViewTransition } from 'react'
import Link from 'next/link'
import { getDepartamentos } from '@/app/_lib/api'
import { TransicionPagina } from './_transicion'

export default async function Departamentos() {
  const departamentos = await getDepartamentos()
  return (
    <TransicionPagina>
      <h1 className="mb-4 text-xl font-bold">Departamentos</h1>
      <ul className="grid grid-cols-2 gap-3 md:grid-cols-4">
        {departamentos.map((d) => (
          <li key={d.deptId}>
            <Link
              href={`/departamentos/${d.deptId}`}
              transitionTypes={['adelante']}
              className="block rounded border bg-white p-3 hover:border-indigo-500"
            >
              {/* Mismo name en la lista y en el detalle: el navegador lo "transforma" */}
              <ViewTransition name={`dept-${d.deptId}`} share="morph" default="none">
                <span className="inline-block font-semibold">{d.nombre}</span>
              </ViewTransition>
              <span className="block text-sm text-slate-500">{d.localidad}</span>
            </Link>
          </li>
        ))}
      </ul>
    </TransicionPagina>
  )
}
// app/departamentos/[id]/page.tsx
import { ViewTransition } from 'react'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { getDepartamento, getDepartamentos, euros } from '@/app/_lib/api'
import { TransicionPagina } from '../_transicion'

export async function generateStaticParams() {
  const departamentos = await getDepartamentos()
  return departamentos.map((d) => ({ id: String(d.deptId) }))
}

export default async function Departamento({ params }: PageProps<'/departamentos/[id]'>) {
  const { id } = await params
  const d = await getDepartamento(Number(id))
  if (!d) notFound()
  return (
    <TransicionPagina>
      <Link href="/departamentos" transitionTypes={['atras']} className="text-sm text-indigo-700">
        ← Todos los departamentos
      </Link>
      <ViewTransition name={`dept-${d.deptId}`} share="morph" default="none">
        <h1 className="mt-3 inline-block text-4xl font-bold text-indigo-900">{d.nombre}</h1>
      </ViewTransition>
      <p className="mt-2">Localidad: {d.localidad}</p>
      <p>Presupuesto: {d.presupuesto === null ? 'sin asignar' : euros(d.presupuesto)}</p>
    </TransicionPagina>
  )
}

Las props del elemento compartido:

  • name: la identidad. Debe ser único en la página en cada momento. Dos <ViewTransition> montados a la vez con el mismo nombre no pueden formar pareja.
  • share="morph": la clase CSS que se aplica a la animación de la pareja. Con ella puedes ajustar la transformación en CSS.
  • default="none": este elemento no anima en las demás transiciones de la página (por ejemplo, al refrescar datos). Si pones default="none", mantén share: sin él, la pareja deja de transformarse sin dar ningún error.
/* Morph del nombre del departamento */
::view-transition-group(.morph) {
  animation-duration: 400ms;
}

⚠️ Importante: La transformación solo ocurre si el destino se pinta en el mismo commit que la navegación, que es lo que pasa cuando la página está precargada y en caché (aquí, prerenderizada con generateStaticParams). Si el destino suspende y muestra un fallback, no hay pareja: el contenido entra con su animación de entrada cuando llega.

Por esto el modal de la tienda no usa elementos compartidos: el catálogo y el modal están montados a la vez, y el mismo nombre en los dos sitios no puede formar pareja.

8.3. Dirección: adelante y atrás 🆕 16.2

🆕 Desde Next.js 16.2. No existe en Next.js 15: Link no tiene la prop transitionTypes. Si usas Next.js 15: crea un enlace cliente que llame a startTransition(() => { addTransitionType('adelante'); router.push(href) }), con unstable_addTransitionType (apartado 8.6).

En la animación, la dirección horizontal comunica posición: avanzar desliza hacia la izquierda; volver, hacia la derecha. Para que la animación sepa en qué dirección vas, etiquetas la navegación con un tipo, con la prop transitionTypes de <Link> (también la aceptan router.push() y router.replace()):

<Link href={`/departamentos/${d.deptId}`} transitionTypes={['adelante']}>…</Link>
<Link href="/departamentos" transitionTypes={['atras']}>← Todos los departamentos</Link>

Next pasa esos tipos a addTransitionType de React dentro de la transición de la navegación. Después, cada página envuelve su contenido en un <ViewTransition> que traduce el tipo a una clase CSS. Como se repite en todas las páginas, va en un componente:

// app/departamentos/_transicion.tsx
// Envoltorio direccional: se pone en cada page.tsx, no en el layout.
import { ViewTransition } from 'react'

const direccion = { adelante: 'adelante', atras: 'atras', default: 'none' }

export function TransicionPagina({ children }: { children: React.ReactNode }) {
  return (
    <ViewTransition enter={direccion} exit={direccion} default="none">
      <div>{children}</div>
    </ViewTransition>
  )
}

enter y exit aceptan un objeto que asocia cada tipo de transición con una clase. default: 'none' hace que las transiciones sin tipo (refrescos, Suspense que se resuelve) no deslicen nada.

⚠️ Importante: El envoltorio va en cada page.tsx, no en el layout. Los layouts se conservan al navegar dentro de ellos, así que nunca “entran” ni “salen”: un <ViewTransition> con enter/exit en un layout no se dispara.

El CSS de los deslizamientos:

/* Deslizamiento direccional */
::view-transition-old(.adelante) {
  --desplazamiento: -60px;
  animation: 150ms ease-in both desvanecer reverse, 400ms ease-in-out both deslizar reverse;
}
::view-transition-new(.adelante) {
  --desplazamiento: 60px;
  animation: 210ms ease-out 150ms both desvanecer, 400ms ease-in-out both deslizar;
}
::view-transition-old(.atras) {
  --desplazamiento: 60px;
  animation: 150ms ease-in both desvanecer reverse, 400ms ease-in-out both deslizar reverse;
}
::view-transition-new(.atras) {
  --desplazamiento: -60px;
  animation: 210ms ease-out 150ms both desvanecer, 400ms ease-in-out both deslizar;
}

@keyframes desvanecer {
  from { opacity: 0; }
  to { opacity: 1; }
}
@keyframes deslizar {
  from { translate: var(--desplazamiento); }
  to { translate: 0; }
}

La salida es rápida (150 ms) y la entrada empieza cuando la salida acaba (retraso de 150 ms): lo viejo se va sin competir por la atención y lo nuevo llega con calma. Con 60 píxeles de desplazamiento basta para indicar la dirección.

8.4. La cabecera fija, los clics y el movimiento reducido

Tres reglas más completan el CSS:

/* La cabecera no se mueve durante los deslizamientos */
.cabecera {
  view-transition-name: cabecera;
}
::view-transition-group(cabecera) {
  animation: none;
  z-index: 100;
}
::view-transition-old(cabecera) {
  display: none;
}
::view-transition-new(cabecera) {
  animation: none;
}

/* Que los clics no se pierdan mientras dura la animación */
::view-transition {
  pointer-events: none;
}

@media (prefers-reduced-motion: reduce) {
  ::view-transition-old(*),
  ::view-transition-new(*),
  ::view-transition-group(*) {
    animation-duration: 0s !important;
    animation-delay: 0s !important;
  }
}
  1. La cabecera: al darle un view-transition-name propio, sale de la “foto” general de la página y se anima aparte; con animation: none, no se mueve. Es el punto fijo que permite al usuario entender que lo que se ha movido es el contenido, no la ventana entera.
  2. Los clics: mientras dura la animación, la capa ::view-transition captura el puntero y los clics se pierden. pointer-events: none los deja pasar a la página real.
  3. Movimiento reducido: los deslizamientos son lo que más molesta a las personas sensibles al movimiento. Con prefers-reduced-motion: reduce, las animaciones duran 0 segundos y el cambio es inmediato, como sin View Transitions.

8.5. Comprobación

Para comprobar que las navegaciones lanzan de verdad una View Transition y con qué tipos, la prueba sustituyó document.startViewTransition por una versión que anota sus argumentos antes de llamar a la original. Se pulsó VENTAS, luego ← Todos los departamentos, y después el botón Atrás del navegador:

[vt] startViewTransition llamadas: [ '["adelante"]', '["atras"]' ]

Las dos navegaciones con <Link> lanzaron su transición con el tipo correcto. El botón Atrás del navegador no lanzó ninguna: las navegaciones por el historial no llevan tipos, y en esta prueba ni siquiera iniciaron una transición. Cuenta con ello: si la dirección es importante, ofrece tus propios enlaces de “volver”.

Para la captura se alargaron las animaciones a 6 segundos con una hoja de estilos añadida solo para la prueba, y se hizo la foto a mitad del camino:

Captura a mitad de la transición: la lista de departamentos se desvanece mientras se desplaza a la izquierda, el detalle de INFORMÁTICA aparece semitransparente y el nombre INFORMÁTICA está a medio camino entre su tarjeta y su posición como título grande

Figura 9. Una View Transition congelada a mitad: la lista sale hacia la izquierda, el detalle entra, y el nombre del departamento se transforma de la tarjeta al título. La cabecera no se mueve.

8.6. View Transitions en Next.js 15

En Next.js 15.5 hay que activar la función experimental, lo que hace que el App Router use el canal experimental de React:

// next.config.ts (Next.js 15)
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  experimental: {
    viewTransition: true, // Next.js 15: activa <unstable_ViewTransition>
  },
}

export default nextConfig

Ese canal exporta las APIs con el prefijo unstable_. Pero los tipos de @types/react 19.3, que instala la plantilla, solo traen los nombres estables, y el build falla al comprobar tipos:

Type error: Module '"react"' has no exported member 'unstable_ViewTransition'.

Se arregla declarando los nombres antiguos en un fichero de tipos en la raíz del proyecto:

// react-15.d.ts
// Next.js 15 con experimental.viewTransition usa el canal experimental de React,
// que exporta estas APIs con el prefijo unstable_. Los tipos de @types/react 19.3
// solo traen los nombres estables: se declaran aquí los antiguos.
import 'react'

declare module 'react' {
  export const unstable_ViewTransition: typeof ViewTransition
  export function unstable_addTransitionType(type: string): void
}

Y como Link no tiene transitionTypes, la dirección se marca con un enlace propio:

'use client'
// Next.js 15: Link no tiene transitionTypes. Se marca la transición a mano.
import { startTransition, unstable_addTransitionType as addTransitionType } from 'react'
import { useRouter } from 'next/navigation'

export function EnlaceConTipo({
  href,
  tipo,
  children,
}: {
  href: string
  tipo: string
  children: React.ReactNode
}) {
  const router = useRouter()
  return (
    <a
      href={href}
      onClick={(e) => {
        e.preventDefault()
        startTransition(() => {
          addTransitionType(tipo)
          router.push(href)
        })
      }}
    >
      {children}
    </a>
  )
}

En las páginas, import { unstable_ViewTransition as ViewTransition } from 'react' y el resto del código es igual. La misma prueba de startViewTransition en Next.js 15.5:

[15] startViewTransition: [ '["adelante"]', '[]', '["atras"]' ]

Las dos navegaciones llevan su tipo. La llamada intermedia sin tipos es una transición adicional que hizo Next.js 15 tras la primera navegación; como el envoltorio direccional usa default: 'none', no desliza nada. En Next.js 16.4 la misma prueba da solo las dos llamadas con tipo.


9. Ejercicios

Ejercicio 1: Un slot de avisos

Añade al panel un tercer slot, @avisos, que muestre los productos con menos de 10 unidades en stock. Debe tener su propio esqueleto de carga y su propio error, y verse también al recargar /panel/pendientes.

Ver solución

Se necesitan cuatro ficheros en app/panel/@avisos/ y un cambio en el layout:

// app/panel/@avisos/page.tsx
import { getProductos } from '@/app/_lib/api'

export default async function Avisos() {
  const pocos = (await getProductos()).filter((p) => p.stock < 10)
  return (
    <>
      <h2 className="mb-2 font-semibold">Stock bajo ({pocos.length})</h2>
      <ul className="text-sm">
        {pocos.map((p) => (
          <li key={p.productoId}>{p.nombre}: {p.stock}</li>
        ))}
      </ul>
    </>
  )
}
// app/panel/@avisos/default.tsx
export { default } from './page'
// app/panel/@avisos/loading.tsx
export default function CargandoAvisos() {
  return <p className="animate-pulse text-sm text-slate-500">Revisando stock…</p>
}
'use client'
// app/panel/@avisos/error.tsx
export default function ErrorAvisos({ retry }: { error: Error; retry: () => void }) {
  return <button onClick={() => retry()}>No se pudo revisar el stock. Reintentar</button>
}
// app/panel/layout.tsx: añade avisos a las props y al JSX
export default function LayoutPanel({ children, estadisticas, pedidos, avisos }: LayoutProps<'/panel'>) {
  // ...
  <section className="rounded border bg-white p-4">{avisos}</section>
}

Tres comprobaciones:

  • LayoutProps<'/panel'> incluye avisos en cuanto existe la carpeta: los tipos se generan a partir de ella. Si escribes la prop antes de crear la carpeta, el compilador se queja.
  • Sin default.tsx, el build de Next.js 16 falla con /panel/pendientes is missing a matching page or default.tsx in @avisos, porque @pedidos tiene la subruta pendientes y @avisos no.
  • getProductos está en caché, así que el loading.tsx casi nunca se verá: los avisos entran en el armazón estático. Es correcto tenerlo de todos modos: si mañana el dato pasa a leerse sin caché, el slot ya está preparado.

Ejercicio 2: ¿Qué pinta cada slot?

Con el panel del apartado 2, completa la tabla. Para cada caso, di qué muestra children y qué muestra @estadisticas, y de qué fichero sale:

  1. Abres /panel escribiendo la URL.
  2. Desde ahí, pulsas la pestaña Pendientes.
  3. Recargas la página.
  4. Borras app/panel/default.tsx y ejecutas next build.
Ver solución
Caso children @estadisticas
1. /panel directo panel/page.tsx @estadisticas/page.tsx
2. Clic en Pendientes Conserva panel/page.tsx (estado activo) Conserva @estadisticas/page.tsx
3. Recarga en /panel/pendientes panel/default.tsx @estadisticas/default.tsx
4. Sin panel/default.tsx El build falla  

El caso 4 es el que suele sorprender: children es un slot más, y para /panel/pendientes no tiene página. Next.js 16 lo detecta igual que con @estadisticas y el build falla con el error del apartado 2.6, esta vez nombrando a children. En la práctica, en los casos 2 y 3 se ve lo mismo, porque los default.tsx reexportan las páginas; la diferencia es de dónde sale: memoria del cliente en el 2, default.tsx renderizado en el servidor en el 3.

Ejercicio 3: Contar segmentos

Indica qué nombre debe tener la carpeta interceptora en cada caso (o si es imposible):

  1. Desde /tienda, abrir en modal /tienda/carrito. El slot es app/tienda/@modal.
  2. Desde /departamentos/[id], abrir en modal /empleados/[id]. El slot es app/departamentos/[id]/@modal.
  3. Desde cualquier página, abrir /login en un modal. El slot es app/@auth.
  4. Lo mismo que el 2, pero con la carpeta en app/(gestion)/departamentos/[id]/@modal.
Ver solución
  1. app/tienda/@modal/(.)carrito. @modal no es segmento: la carpeta está en el nivel de /tienda, igual que carrito.
  2. app/departamentos/[id]/@modal/(..)(..)empleados/[id]. La carpeta está en el nivel de /departamentos/[id]; hay que subir dos segmentos ([id] y departamentos) para llegar a la raíz, donde está empleados. También vale (...)empleados/[id], que parte siempre de la raíz y es más fácil de leer cuando hay que subir mucho.
  3. app/@auth/(.)login. @auth está en la raíz, al mismo nivel que login.
  4. Igual que el 2: (..)(..)empleados/[id] o (...)empleados/[id]. Los grupos (gestion) tampoco son segmentos de la URL, así que no se cuentan.

Regla práctica: escribe la URL de la página donde está el slot y la URL destino, y cuenta sobre las URLs, nunca sobre las carpetas.

Ejercicio 4: La página de ofertas que bloquea

Partiendo de la versión que bloquea de app/tienda/ofertas/page.tsx (apartado 6.3), arréglala de las tres formas que propone la validación y explica qué ve el usuario en cada caso al pulsar Ver ofertas en el catálogo.

Ver solución

Stream con loading.tsx (la que se usó en el apartado 6.3): el usuario ve Buscando ofertas… al instante, y la oferta un momento después. La validación queda limpia. Se comprobó: tras crear el fichero, la consola de next dev dejó de mostrar el error.

Stream con <Suspense> en la página: lo mismo, pero con más control, porque el título puede estar fuera del <Suspense> y verse al instante:

// app/tienda/ofertas/page.tsx
import { Suspense } from 'react'
import { getProductos, getStockEnVivo, euros } from '@/app/_lib/api'

export default function Ofertas() {
  return (
    <>
      <h1 className="text-xl font-bold">Ofertas</h1>
      <Suspense fallback={<p className="text-slate-500">Buscando ofertas…</p>}>
        <Oferta />
      </Suspense>
    </>
  )
}

async function Oferta() {
  const [primero] = await getProductos()
  const stock = await getStockEnVivo(primero.productoId)
  return <p>{primero.nombre}: {euros(primero.precio * 0.9)} ({stock} unidades)</p>
}

Cache: si el stock de la oferta puede tener unos minutos de antigüedad, se usa una función con 'use cache' y cacheLife('minutes') en lugar de getStockEnVivo. Entonces toda la página entra en el armazón y en el App Shell, y el usuario ve la oferta completa al instante. Es la mejor experiencia, si el dato lo permite.

Block: export const instant = false en la página. La validación calla, y el usuario pulsa Ver ofertas y se queda en el catálogo hasta que el servidor responde. Solo tiene sentido cuando enseñar la página sin el dato sería peor que esperar.

Ejercicio 5: El modal que no se cierra

Un compañero ha hecho el modal de la tienda pero, desde el modal, pulsa Ver ofertas y el modal sigue abierto sobre la página de ofertas. Después de añadir un default.tsx que devuelve null, el problema sigue. ¿Por qué no funciona default.tsx y cuál es el arreglo?

Ver solución

default.tsx solo se usa en la navegación dura (recarga o URL pegada). Pulsar Ver ofertas es una navegación blanda: para /tienda/ofertas, el slot @modal no tiene página, y en navegación blanda un slot sin coincidencia conserva lo que mostraba, el modal.

El arreglo es darle a @modal una página que coincida con esa URL y no pinte nada. Un catch-all cubre todas las rutas de la tienda de una vez:

// app/tienda/@modal/[...resto]/page.tsx
export default function SinModal() {
  return null
}

Y en Next.js 16 hay que comprobar además que el componente del modal cierra el <dialog> en la limpieza de su efecto (apartado 5.2). Si no, el modal desaparece de la vista pero deja toda la página inerte. Se comprobaron las dos cosas: con el catch-all y sin la limpieza, la cabecera quedó bloqueada; con las dos, funciona.

Ejercicio 6: Precarga en una lista de 200 pedidos

La página de gestión de pedidos muestra 200 enlaces a /gestion/pedidos/[id]. Cada ficha de pedido lee el pedido de la base de datos sin caché. Explica cuántas peticiones de precarga de contenido genera la página con partialPrefetching: false y con true, qué se ve al pulsar un pedido en cada caso y qué harías en Next.js 15.

Ver solución
  • partialPrefetching: false: una precarga de contenido por enlace visible, hasta 200, como en la medición del apartado 7.2 (20 enlaces, 20 segmentos). Como la ficha no tiene caché, cada precarga obliga al servidor a trabajar, y la mayoría se tira porque el usuario solo abrirá uno o dos pedidos.
  • partialPrefetching: true: una sola precarga del App Shell de /gestion/pedidos/[id] para los 200 enlaces (más los _tree, pequeños). Al pulsar, se pinta al instante el App Shell (el marco y los esqueletos), y el pedido llega por streaming. Para que sea así, la ficha no debe esperar params fuera de <Suspense> (apartado 6.4).
  • Next.js 15: no hay App Shell compartido. Lo razonable es prefetch={false} en los 200 enlaces, para no lanzar 200 precargas, y un loading.tsx en app/gestion/pedidos/[id]/ para que al pulsar se vea algo enseguida. Ten en cuenta que con prefetch={false} no se precarga nada, ni siquiera ese esqueleto: aparecerá cuando llegue la primera respuesta del servidor. Es el precio de no cargar el servidor con 200 precargas.

Ejercicio 7: Animación sin mareos

Una usuaria tiene activado “Reducir movimiento” en su sistema operativo. ¿Qué ve con el CSS del apartado 8 al navegar a un departamento? Modifica la regla para que en ese caso se conserve el fundido pero desaparezca el deslizamiento.

Ver solución

Con la regla del apartado 8.4, todas las animaciones duran 0 segundos: el cambio es inmediato, como sin View Transitions. No se mueve nada, pero tampoco hay fundido ni transformación del nombre.

Los fundidos (opacidad) apenas afectan a las personas sensibles al movimiento: lo que molesta es el desplazamiento. Para quitar solo el desplazamiento, basta con anular la variable que lo controla y dejar el resto:

@media (prefers-reduced-motion: reduce) {
  ::view-transition-old(.adelante),
  ::view-transition-new(.adelante),
  ::view-transition-old(.atras),
  ::view-transition-new(.atras) {
    --desplazamiento: 0px;
  }
  ::view-transition-group(.morph) {
    animation-duration: 0s;
  }
}

Con --desplazamiento: 0px la animación deslizar va de 0 a 0 (no se mueve) y desvanecer sigue funcionando. La transformación del nombre, que sí es movimiento, se anula con duración 0 en su grupo.

Ejercicio 8: Direcciones en Next.js 15

Tu proyecto sigue en Next.js 15.5. Quieres el deslizamiento direccional de los departamentos. Escribe lo necesario y explica por qué no basta con copiar el código del apartado 8.3.

Ver solución

El código del apartado 8.3 falla en Next.js 15 por tres motivos:

  1. ViewTransition no se exporta con ese nombre en el React que usa Next.js 15: hay que activar experimental.viewTransition y usar unstable_ViewTransition.
  2. @types/react 19.3 no declara los nombres unstable_, y el build falla al comprobar tipos (Module '"react"' has no exported member 'unstable_ViewTransition').
  3. <Link transitionTypes> es de Next.js 16.2.

La solución completa es la del apartado 8.6: la opción en next.config.ts, el fichero react-15.d.ts, el componente EnlaceConTipo y, en las páginas:

import { unstable_ViewTransition as ViewTransition } from 'react'
import { EnlaceConTipo } from './enlace-con-tipo'

// En la lista:
<EnlaceConTipo href={`/departamentos/${d.deptId}`} tipo="adelante">
  <ViewTransition name={`dept-${d.deptId}`}>
    <span>{d.nombre}</span>
  </ViewTransition>
</EnlaceConTipo>

El CSS es el mismo. Se comprobó con Next.js 15.5: el proyecto compila y las navegaciones lanzan startViewTransition con los tipos adelante y atras. Al actualizar a Next.js 16, puedes sustituir EnlaceConTipo por <Link transitionTypes>, quitar react-15.d.ts y la opción experimental, y cambiar el import a ViewTransition.

Ejercicio 9 (propuesto): Login en un modal

Crea una página /login normal y haz que, desde cualquier página de la aplicación, el enlace Entrar de la cabecera la abra en un modal sobre la página actual. Pistas: el slot va en el layout raíz (app/@auth), la carpeta interceptora es (.)login, y necesitas un default.tsx y un catch-all en @auth que devuelvan null. Recarga /login para comprobar que sale la página completa.

Ejercicio 10 (propuesto): Ficha de empleado sobre el departamento

En el detalle de un departamento, lista sus empleados (/api/departamentos/:id/empleados) y haz que cada uno abra su ficha (/empleados/[id]) en un modal sobre el departamento. Usa el resultado del ejercicio 3. Ojo con el departamento 70, que no tiene empleados, y con el empleado con deptId nulo.

Ejercicio 11 (propuesto): Pestañas con fundido

Haz que, al cambiar entre las pestañas Todos y Pendientes del panel, la tabla de pedidos haga un fundido cruzado en vez de cambiar de golpe. Pista: un <ViewTransition> con key igual al segmento activo, name, share="auto", enter="auto" y default="none" alrededor de la tabla. El cambio de key hace que React trate la tabla vieja y la nueva como una pareja que sale y entra.


Resumen

Concepto API Idea clave
Ruta paralela Carpeta @slot Cada slot es una prop del layout, con su loading, error y subrutas
Slot implícito children Es un slot más: también necesita default.tsx
Estado activo Navegación blanda Un slot sin coincidencia conserva lo que mostraba
Fallback de slot default.tsx 🔄 16.0 Se usa en la navegación dura; obligatorio en 16 (el build falla sin él)
Segmento activo de un slot useSelectedLayoutSegment('slot') Para marcar pestañas
Ruta interceptada (.), (..), (..)(..), (...) Cuenta segmentos de URL; @slot y (grupo) no cuentan
Modal con URL @modal + (.)ruta + página completa Blanda: modal; dura: página completa
Cerrar modal router.back() Atrás lo cierra y Adelante lo reabre
Cerrar al salir @modal/[...resto]/page.tsx → null default.tsx no basta en navegación blanda
Modal y <Activity> Limpieza del efecto con dialog.close() Un <dialog> oculto sigue haciendo inerte la página
Navegación instantánea 🆕 16.3 Pintar al pulsar con lo estático, lo cacheado y los fallbacks
Validación export const instant 🆕 16.3 Avisos en next dev: Stream, Cache o Block
Suspense compartido Layout montado Un <Suspense> del layout compartido no cubre la navegación
params sin bloquear Pasar la promesa a un hijo en <Suspense> Para que la página tenga App Shell
Precarga por ruta partialPrefetching: true 🆕 16.3 Un App Shell por ruta: 20 enlaces, 1 segmento
Precarga por enlace <Link prefetch> Resuelve los datos de esa URL antes del clic
Límite de precarga export const prefetch = 'force-disabled' \| 'partial' El destino limita lo que se precarga
Elemento compartido <ViewTransition name share> Mismo name en origen y destino; destino en el mismo commit
Dirección <Link transitionTypes> 🆕 16.2 Tipos → clases CSS con enter/exit en cada página
View Transitions en 15 experimental.viewTransition unstable_ViewTransition, unstable_addTransitionType y declarar sus tipos
Accesibilidad prefers-reduced-motion Quitar el desplazamiento; el fundido puede quedarse