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é
childrentambién lo es y cuándo se usadefault.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
instantpara 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 segmentoprefetch - 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 conbfcacheId; 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:
- 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.
- 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. - 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 enhttp://localhost:3000/api, el mismo puerto que Next por defecto. Arranca la API connode server.mjsy 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:
- Un slot no es un segmento de la URL. La carpeta
@pedidosno añade nada a la dirección:app/panel/@pedidos/page.tsxse muestra en/panel, igual queapp/panel/page.tsx. Yapp/panel/@pedidos/pendientes/page.tsxresponde en/panel/pendientes, no en/panel/@pedidos/pendientes. - Cada slot llega al layout como una prop con el nombre de la carpeta sin la arroba:
@estadisticases la propestadisticas. childrenes un slot implícito. Es exactamente lo mismo que un@childrenque no hace falta escribir. Todo lo que se diga de los slots vale también para él.
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 odefault.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.

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.

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.tsxes 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 losdefault.tsx(o undefault.tsxque llame anotFound()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.tsxno 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 losdefault.tsxmuestran 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.
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. Enapp/tienda/@modal/(.)producto, la carpeta interceptora está a dos carpetas deapp/tienda/producto, pero a cero segmentos:@modalno 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:
- La URL refleja el modal:
/tienda/producto/1. Se puede copiar, compartir y guardar en marcadores. - 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.
- Atrás cierra el modal en vez de llevarte a la página anterior al catálogo.
- Adelante lo vuelve a abrir.
- 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}
</>
)
}
4.3. El catálogo
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:
FichaDesdeParamsrecibe la promesaparamsy la espera. Se usa dentro de un<Suspense>; el apartado 6.4 explica por qué es importante no esperarparamsen la propia página.FichaProductopinta los datos, que vienen degetProducto(en caché).Stocklee 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.

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.

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:
- 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 ponedisplay: noney ejecuta las limpiezas de los efectos. - Un
<dialog>abierto conshowModal()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 unloading.tsxen 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
/tienday/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:

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, ynext devno 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á connext 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:
instantsolo funciona concacheComponentsy 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 = truesirve 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 = falseen 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 coninstant = falsey páginas coninstant = truedebajo: 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
instantconensureStatic(del módulo 09).ensureStaticexige que la salida del servidor sea estática.instantcomprueba 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 enlocalhost(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, ponprefetch={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.
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.
7.3. Precargar más con <Link prefetch>
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()oheaders()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: trueennext.config.tse importarunstable_ViewTransitionyunstable_addTransitionTypedereact. Si usas Next.js 15: activa esa opción, importaunstable_ViewTransition as ViewTransition, declara sus tipos (apartado 8.6) y marca la dirección constartTransitionyrouter.push, porquetransitionTypesdeLinkno 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). UnsetStatenormal 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 ponesdefault="none", manténshare: 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:
Linkno tiene la proptransitionTypes. Si usas Next.js 15: crea un enlace cliente que llame astartTransition(() => { addTransitionType('adelante'); router.push(href) }), conunstable_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>conenter/exiten 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;
}
}
- La cabecera: al darle un
view-transition-namepropio, sale de la “foto” general de la página y se anima aparte; conanimation: 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. - Los clics: mientras dura la animación, la capa
::view-transitioncaptura el puntero y los clics se pierden.pointer-events: nonelos deja pasar a la página real. - 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:

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'>incluyeavisosen 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@pedidostiene la subrutapendientesy@avisosno. getProductosestá en caché, así que elloading.tsxcasi 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:
- Abres
/panelescribiendo la URL. - Desde ahí, pulsas la pestaña Pendientes.
- Recargas la página.
- Borras
app/panel/default.tsxy ejecutasnext 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):
- Desde
/tienda, abrir en modal/tienda/carrito. El slot esapp/tienda/@modal. - Desde
/departamentos/[id], abrir en modal/empleados/[id]. El slot esapp/departamentos/[id]/@modal. - Desde cualquier página, abrir
/loginen un modal. El slot esapp/@auth. - Lo mismo que el 2, pero con la carpeta en
app/(gestion)/departamentos/[id]/@modal.
Ver solución
app/tienda/@modal/(.)carrito.@modalno es segmento: la carpeta está en el nivel de/tienda, igual quecarrito.app/departamentos/[id]/@modal/(..)(..)empleados/[id]. La carpeta está en el nivel de/departamentos/[id]; hay que subir dos segmentos ([id]ydepartamentos) 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.app/@auth/(.)login.@authestá en la raíz, al mismo nivel quelogin.- 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 esperarparamsfuera 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 unloading.tsxenapp/gestion/pedidos/[id]/para que al pulsar se vea algo enseguida. Ten en cuenta que conprefetch={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:
ViewTransitionno se exporta con ese nombre en el React que usa Next.js 15: hay que activarexperimental.viewTransitiony usarunstable_ViewTransition.@types/react19.3 no declara los nombresunstable_, y el build falla al comprobar tipos (Module '"react"' has no exported member 'unstable_ViewTransition').<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 |