Módulo 08: Obtener datos en el servidor
Objetivos del módulo
- Entender por qué en Next.js los datos se piden en el servidor y qué ganas frente al
useEffectde una aplicación React con Vite - Escribir componentes asíncronos que hacen
fetcha la API EMPRESA, con una capa de acceso a datos tipada y reutilizable - Saber por qué Next.js 16 exige
<Suspense>alrededor de los datos sin caché, qué significa el error del build y qué hacía Next.js 15 en el mismo caso - Enviar una página por partes con streaming, y ver los trozos llegar de verdad por la red
- Elegir entre
loading.tsxy<Suspense>, y dónde colocar cada límite - Reconocer y eliminar las cascadas de peticiones (waterfalls) con
Promise.all, límites hermanos y precarga - Aprovechar la memorización automática de
fetchy la funcióncachede React para no repetir peticiones - Pasar promesas sin resolver a Client Components y leerlas con
use - Tratar los errores de la API: respuestas 404, tiempos límite, datos que no cumplen el tipo y el código HTTP que llega al navegador
💡 Consejo: Todo lo que ves en este módulo se ha compilado con
next buildy ejecutado connext starten Next.js 16.4.3, contra la API EMPRESA arrancada conRETARDO=1000: cada respuesta tarda un segundo. Ese retardo exagera lo que pasa en una red real y hace visibles los estados de carga y las cascadas. Las diferencias con Next.js 15 se han comprobado en Next.js 15.5.28. Las salidas y los tiempos que aparecen son reales. Usa siempre el último parche de Next.js 16.4.
📘 Concepto: Este módulo trata de obtener datos en cada petición. Guardarlos en caché para no pedirlos otra vez (
'use cache',cacheLife,revalidateTag) es el tema del módulo 09. Aquí verás la caché solo como una de las salidas posibles, para saber que existe.
1. Dónde se piden los datos
En el Curso de React pedías los datos así: el navegador recibía un HTML vacío y un fichero JavaScript; React montaba el componente, mostraba “Cargando…” y, después, un useEffect lanzaba el fetch a la API.
'use client'
import { useEffect, useState } from 'react'
type Departamento = { deptId: number; nombre: string }
export function ListaDepartamentos() {
const [departamentos, setDepartamentos] = useState<Departamento[] | null>(null)
useEffect(() => {
fetch('http://localhost:3000/api/departamentos')
.then((res) => res.json())
.then((pagina) => setDepartamentos(pagina.contenido))
}, [])
if (!departamentos) return <p>Cargando departamentos...</p>
return (
<ul>
{departamentos.map((d) => (
<li key={d.deptId}>{d.nombre}</li>
))}
</ul>
)
}
Este código funciona en Next.js, pero desaprovecha lo que el framework te da. En el App Router los componentes son Server Components por defecto (módulo 05), y un Server Component puede ser una función async. El mismo listado queda así:
type Departamento = { deptId: number; nombre: string }
export async function ListaDepartamentos() {
const res = await fetch('http://localhost:3000/api/departamentos')
const { contenido } = (await res.json()) as { contenido: Departamento[] }
return (
<ul>
{contenido.map((d) => (
<li key={d.deptId}>{d.nombre}</li>
))}
</ul>
)
}
Sin useState, sin useEffect, sin estado de carga a mano. Tampoco hay 'use client': este código nunca llega al navegador. Se ejecuta en el servidor de Next, que pide los datos, genera el HTML y lo envía ya relleno.
Figura 1. En una SPA el navegador habla con la API. Con Server Components la habla el servidor de Next y el navegador recibe la página hecha.
1.1. Qué ganas pidiendo en el servidor
| Aspecto | useEffect en el navegador | await en un Server Component |
|---|---|---|
| Cuándo empieza la petición | Cuando ha llegado el JS, React ha montado el componente y se ejecuta el efecto | En cuanto el servidor recibe la petición de la página |
| Distancia hasta la API | Desde el móvil del usuario, por Internet | Desde el servidor, normalmente en la misma red o centro de datos |
| CORS | La API debe permitir el origen de tu web | No aplica: es una petición de servidor a servidor |
| Secretos (claves, tokens de servicio) | Imposible: todo lo que usa el navegador es público | Se quedan en el servidor |
| JavaScript enviado | El componente, el fetch y el estado | Nada de ese componente: solo su HTML |
| Buscadores y vista previa en redes | Ven “Cargando…” si no ejecutan JS | Ven los datos |
| Estado de carga | Lo programas tú con useState | Lo declaras con <Suspense> o loading.tsx |
⚠️ Importante: Un componente
asyncno puede usar hooks (useState,useEffect,useContext…). Los hooks son para el navegador, donde el componente vive y se actualiza. Un Server Component se ejecuta una vez por petición, devuelve su resultado y desaparece. Si necesitas interacción, la separas en un Client Component, como viste en el módulo 05 y como harás en el apartado 9.
1.2. Qué puedes usar dentro
Un Server Component puede esperar a cualquier operación asíncrona, no solo a fetch:
fetcha una API REST, como la API EMPRESA que usarás en los módulos 00 a 15.- Consultas a una base de datos con un ORM. Lo harás con Drizzle y SQLite en el módulo 16.
- Lectura de ficheros con
node:fs/promises, llamadas a un SDK de terceros, etc.
En este módulo trabajarás con fetch, porque tiene algo que el resto no tiene: Next lo memoriza automáticamente durante el render (apartado 8). Para el resto de operaciones usarás la función cache de React.
2. Preparar el proyecto
2.1. La API y Next, en puertos distintos
La API EMPRESA (recursos/empresa-api.zip) escucha en el puerto 3000, que es también el puerto por defecto de Next. Arranca cada uno en su terminal:
# Terminal 1: la API, con un segundo de retardo en cada respuesta
cd empresa-api
RETARDO=1000 node server.mjs
# Terminal 2: Next en el 3001
npx next dev -p 3001
En Windows con PowerShell, la variable se escribe de otra forma: $env:RETARDO=1000; node server.mjs. Para las pruebas de producción usarás npx next build y npx next start -p 3001.
💡 Consejo: Trabaja con
RETARDO=1000durante todo el módulo. Con la API respondiendo en 2 ms no verías ningún estado de carga ni ninguna cascada, y los errores de este tema solo se notan cuando la red es lenta. En producción la red es lenta a veces.
2.2. La URL de la API en una variable de entorno
No escribas http://localhost:3000/api en cada componente. Ponla en .env.local, en la raíz del proyecto:
API_URL=http://localhost:3000/api
Fíjate en que la variable no empieza por NEXT_PUBLIC_. Así solo existe en el servidor y nunca se incrusta en el JavaScript del navegador. Como todas las peticiones salen del servidor, no hace falta que el navegador la conozca. Las variables de entorno se tratan a fondo en el módulo 13.
2.3. Una capa de acceso a datos
En lugar de repartir fetch por las páginas, reúne las peticiones en un módulo. Así tienes un único sitio donde cambiar la URL, añadir una cabecera de autenticación o tratar los errores. Crea lib/api.ts:
import 'server-only'
// La URL de la API se lee en el servidor: nunca llega al navegador
const API = process.env.API_URL ?? 'http://localhost:3000/api'
export type Pagina<T> = {
contenido: T[]
pagina: number
tamano: number
total: number
paginas: number
}
export type Departamento = {
deptId: number
nombre: string
localidad: string
presupuesto: number | null // INVESTIGACIÓN (70) no tiene presupuesto
}
export type Empleado = {
empId: number
nombre: string
apellidos: string
email: string
oficio: string
salario: number
comision: number | null
jefeId: number | null
deptId: number
departamento: string
}
export type Producto = {
productoId: number
nombre: string
precio: number
stock: number
categoria?: string
}
export type LineaPedido = {
numLinea: number
producto: string
cantidad: number
importe: number
}
export type Pedido = {
pedidoId: number
cliente: string
fechaPedido: string
estado: string
lineas: LineaPedido[]
total: number
}
export type Estadisticas = {
empleados: number
departamentos: number
clientes: number
productos: number
pedidos: number
facturacion: number
porDepartamento: {
deptId: number
nombre: string
empleados: number
masaSalarial: number
salarioMedio: number
}[]
}
export class ErrorApi extends Error {
constructor(
public status: number,
mensaje: string,
) {
super(mensaje)
this.name = 'ErrorApi'
}
}
// Función base: hace la petición, comprueba el código y convierte a JSON
export async function pedir<T>(ruta: string): Promise<T> {
const res = await fetch(`${API}${ruta}`)
if (!res.ok) {
const cuerpo = await res.json().catch(() => null)
throw new ErrorApi(res.status, cuerpo?.mensaje ?? `Error ${res.status} en ${ruta}`)
}
return res.json() as Promise<T>
}
export function obtenerDepartamentos() {
return pedir<Pagina<Departamento>>('/departamentos')
}
export function obtenerDepartamento(id: number) {
return pedir<Departamento>(`/departamentos/${id}`)
}
export function obtenerEmpleadosDe(deptId: number) {
return pedir<Pagina<Empleado>>(`/departamentos/${deptId}/empleados`)
}
export function obtenerEmpleado(id: number) {
return pedir<Empleado>(`/empleados/${id}`)
}
export function obtenerEmpleados(tamano = 100) {
return pedir<Pagina<Empleado>>(`/empleados?tamano=${tamano}&orden=apellidos`)
}
export function obtenerEstadisticas() {
return pedir<Estadisticas>('/estadisticas')
}
export function obtenerPedidosRecientes(cuantos = 5) {
return pedir<Pagina<Pedido>>(`/pedidos?orden=-fechaPedido&tamano=${cuantos}`)
}
export function obtenerProductos() {
return pedir<Pagina<Producto>>('/productos?tamano=100&orden=stock')
}
Hay cuatro decisiones en este fichero que conviene entender:
import 'server-only'hace que el build falle si algún Client Component importa este módulo, aunque sea por error. Lo viste en el módulo 05. Aquí protege la URL interna de la API y, más adelante, los tokens.fetchno lanza una excepción cuando la API responde 404 o 500. Solo lo hace si no hay respuesta (servidor caído, DNS, tiempo agotado). Por esopedircompruebares.oky lanza su propioErrorApicon el código. Sin esa comprobación, un 404 llegaría a tu componente como un objeto{ status: 404, mensaje: ... }y fallaría más adelante con un error confuso.- Los tipos son una promesa que haces tú.
as Promise<T>le dice a TypeScript “fíate de mí”. La API no está obligada a cumplirlo. En el apartado 10.4 verás qué pasa cuando no lo cumple, y en el módulo 11 validarás con Zod. - Cada función devuelve una promesa sin esperar.
obtenerDepartamentos()no llevaawaitdentro: devuelve la promesa depedir. Quien la llama decide cuándo esperar, y eso, como verás, es la clave para evitar cascadas.
📘 Concepto: Las respuestas de lista de la API EMPRESA tienen la forma
{ contenido, pagina, tamano, total, paginas }. Por esoobtenerDepartamentosdevuelvePagina<Departamento>y el componente desestructuracontenido. El detalle (/departamentos/30) devuelve el objeto directamente.
3. Tu primer componente asíncrono
Crea app/departamentos/page.tsx:
import { Suspense } from 'react'
import Link from 'next/link'
import { obtenerDepartamentos } from '@/lib/api'
// Componente asíncrono: espera a la API y devuelve la lista
async function ListaDepartamentos() {
const { contenido: departamentos } = await obtenerDepartamentos()
return (
<ul className="divide-y rounded border bg-white">
{departamentos.map((d) => (
<li key={d.deptId} className="flex justify-between px-4 py-2">
<Link href={`/departamentos/${d.deptId}`} className="text-indigo-700 hover:underline">
{d.nombre}
</Link>
<span className="text-slate-500">{d.localidad}</span>
</li>
))}
</ul>
)
}
export default function PaginaDepartamentos() {
return (
<section className="space-y-4">
<h1 className="text-2xl font-bold text-indigo-900">Departamentos</h1>
<Suspense fallback={<p className="text-slate-500">Cargando departamentos...</p>}>
<ListaDepartamentos />
</Suspense>
</section>
)
}
La página tiene dos partes:
PaginaDepartamentosno es asíncrona y no espera nada. Su título se puede generar sin la API.ListaDepartamentossí lo es. Espera a la API y está envuelta en<Suspense>. Mientras no termina, en su lugar se muestra elfallback.
Ese <Suspense> no es decorativo. Prueba a quitarlo.
3.1. Datos sin <Suspense>: el build falla 🔄 16.0
🔄 Cambia en Next.js 16.0. En Next.js 15 (sin
cacheComponents) una página que hacefetchsin<Suspense>compila como estática: los datos se piden una vez en el build y se quedan congelados. Si usas Next.js 15: añadeawait connection()(denext/server) antes de pedir los datos, oexport const dynamic = 'force-dynamic', para que la página se genere en cada petición.
Esta página pide los datos en el propio componente de la página, sin ningún límite de carga:
import { obtenerDepartamentos } from '@/lib/api'
// Sin Suspense ni loading.tsx: el build de Next.js 16 con cacheComponents falla
export default async function SinSuspense() {
const { contenido } = await obtenerDepartamentos()
return (
<ul>
{contenido.map((d) => (
<li key={d.deptId}>{d.nombre}</li>
))}
</ul>
)
}
Esto es lo que responde next build en Next.js 16.4 (proyecto creado con create-next-app, que activa cacheComponents: true):
Error: Route "/sin-suspense": Next.js encountered uncached or runtime data during prerendering.
`fetch(...)`, `cookies()`, `headers()`, `params`, `searchParams`, or `connection()` accessed outside of `<Suspense>` prevents the route from being prerendered, blocking the page load and leading to a slower user experience.
Ways to fix this:
- [stream] Provide a placeholder with `<Suspense fallback={...}>` around the data access
- [cache] For uncached data (`fetch`, database calls): cache the access with `"use cache"` (does not apply to `connection()`)
- [block] Set `export const instant = false` to allow a blocking route
Learn more: https://nextjs.org/docs/messages/blocking-prerender-dynamic
Error occurred prerendering page "/sin-suspense". Read more: https://nextjs.org/docs/messages/prerender-error
Export encountered an error on /sin-suspense/page: /sin-suspense, exiting the build.
⨯ Next.js build worker exited with code: 1 and signal: null
Para entender el mensaje, recuerda lo que viste en el módulo 02. Durante el build, Next intenta prerenderizar cada ruta: generar de antemano todo el HTML que pueda, para servirlo al instante. Ese HTML preparado se llama armazón estático (static shell). Cuando el prerender se encuentra un dato que solo existe en el momento de la petición (un fetch sin caché, las cookies, los params de una ruta dinámica), sube por el árbol de componentes buscando el <Suspense> más cercano. Si lo encuentra, el armazón incluye su fallback y el dato se rellena después. Si no lo encuentra, no hay nada que poner en su lugar y la página no podría responder sin esperar a la API. Next.js 16 te obliga a decidir qué quieres en ese caso. El error te da tres opciones:
| Opción | Qué haces | Qué obtienes | Dónde se ve |
|---|---|---|---|
[stream] | Envolver el dato en <Suspense> (o añadir loading.tsx) | Armazón inmediato y el dato llega después, en la misma respuesta | Este módulo |
[cache] | Marcar la función con 'use cache' | El dato se guarda y entra en el armazón | Módulo 09 |
[block] | export const instant = false | La página espera entera a la API, como el SSR clásico | Apartado 3.3 |
📘 Concepto: “Sin caché” (uncached) no es un fallo: muchos datos deben ser frescos en cada petición, como los pedidos pendientes o el saldo de un cliente. Lo que Next.js 16 no te deja es que sean frescos y bloqueen la página sin que lo hayas decidido.
3.2. Lo que hacía Next.js 15
En Next.js 15 no existía este error. Se comprobó con dos páginas en un proyecto create-next-app@15.5.28:
// app/congelada/page.tsx: Next.js 15, sin nada más
export default async function Congelada() {
const res = await fetch(`${process.env.API_URL}/estadisticas`)
const e = await res.json()
return <p>Generada con {e.pedidos} pedidos a las {new Date().toLocaleTimeString('es-ES')}</p>
}
// app/fresca/page.tsx: Next.js 15 con connection()
import { connection } from 'next/server'
export default async function Fresca() {
await connection()
const res = await fetch(`${process.env.API_URL}/estadisticas`)
const e = await res.json()
return <p>Generada con {e.pedidos} pedidos a las {new Date().toLocaleTimeString('es-ES')}</p>
}
El build de Next.js 15 las clasifica así:
Route (app) Size First Load JS
┌ ○ /_not-found 0 B 113 kB
├ ○ /congelada 0 B 113 kB
├ ƒ /fresca 0 B 113 kB
└ ƒ /paralelo 0 B 113 kB
○ (Static) prerendered as static content
ƒ (Dynamic) server-rendered on demand
Y con next start, pidiendo cada página dos veces con unos segundos de diferencia:
congelada: Generada con 24 pedidos a las 12:22:43
fresca: Generada con 24 pedidos a las 12:41:33
congelada: Generada con 24 pedidos a las 12:22:43
fresca: Generada con 24 pedidos a las 12:41:38
/congelada muestra la hora del build para siempre: si mañana hay 30 pedidos, seguirá diciendo 24 hasta que vuelvas a compilar. Es un error silencioso, muy frecuente en Next.js 15, y es justo lo que el error de Next.js 16 quiere evitar. connection() le dice a Next “a partir de aquí, esto depende de la petición” y convierte la página en dinámica (ƒ).
⚠️ Importante: Si sigues en Next.js 15, todo lo demás de este módulo (
<Suspense>,loading.tsx,Promise.all, la memorización defetch,cachede React yuse) funciona igual. La única diferencia es que nadie te avisa de que una página con datos se ha quedado estática. Revisa siempre la tabla de rutas del build: si una página con datos frescos sale con○, tienes un problema.
3.3. Renunciar al armazón: instant = false 🆕 16.3
🆕 Desde Next.js 16.3. No existe en Next.js 15 (antes de 16.3 solo había una versión experimental,
unstable_instant). Si usas Next.js 15: no hace falta; una página dinámica sin<Suspense>(por ejemplo, conawait connection()) ya espera entera a los datos antes de responder.
La tercera salida del error es declarar que esa ruta puede bloquear:
import { obtenerDepartamentos } from '@/lib/api'
// Renuncia expresa al armazón instantáneo: la página espera entera a la API
export const instant = false
export default async function Bloqueante() {
const { contenido } = await obtenerDepartamentos()
return (
<ul>
{contenido.map((d) => (
<li key={d.deptId}>{d.nombre}</li>
))}
</ul>
)
}
Compila, y la tabla del build la marca como ƒ /bloqueante. Con la API tardando un segundo, el primer byte de la respuesta llega a los 1152 ms: el usuario mira una pantalla en blanco todo ese tiempo, sin menú ni título. instant sirve para configurar las navegaciones instantáneas, que verás en el módulo 15. Úsalo con false solo cuando tenga sentido que la página no muestre nada hasta tener los datos, y prefiere <Suspense> en todo lo demás.
3.4. Leer la tabla del build
Con todas las páginas del módulo, next build en Next.js 16.4 termina así (extracto):
Route (app)
┌ ○ /
├ ○ /_not-found
├ ◐ /anidado
├ ƒ /bloqueante
├ ◐ /departamentos
├ /departamentos/[id]
│ └ ◐ /departamentos/[id]
├ ◐ /empleados
├ ◐ /panel
├ ◐ /paralelo
├ ◐ /promesas
├ ◐ /secuencial
└ ◐ /tolerante
○ (Static) prerendered as static content
◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content
ƒ (Dynamic) server-rendered on demand
Casi todas las páginas con datos salen como ◐: Next ha generado en el build el armazón (el menú, los títulos, los fallback) y rellenará los huecos en cada petición. Es el Partial Prerendering del módulo 02, y es lo que obtienes por defecto al usar <Suspense> en Next.js 16.
💡 Consejo: El build no necesita que la API esté en marcha para estas páginas: los
fetchdentro de<Suspense>no se envían durante el prerender. En el registro de la API no apareció ninguna petición durante los builds de este módulo. Sí la necesitará cuando guardes datos en caché en el armazón (módulo 09).
4. Streaming: la página llega por partes
Con <Suspense>, Next no espera a tener toda la página para empezar a responder. Envía primero el armazón, con los fallback en los huecos, y mantiene abierta la conexión. Cuando un componente termina, envía su HTML por la misma respuesta, junto con un pequeño script que lo coloca en su sitio. Eso es el streaming, y funciona gracias a la codificación HTTP por trozos (Transfer-Encoding: chunked).
4.1. Verlo por la red
Las herramientas del navegador muestran la respuesta ya montada, así que para ver los trozos conviene leerlos uno a uno. Este script de Node pide una página y muestra cada trozo con el momento en que llega:
// observar.mjs. Uso: node observar.mjs http://localhost:3001/panel
const res = await fetch(process.argv[2], { headers: { 'Accept-Encoding': 'identity' } })
console.log(`HTTP ${res.status} · transfer-encoding: ${res.headers.get('transfer-encoding')}`)
const lector = res.body.getReader()
const decodificador = new TextDecoder()
const inicio = Date.now()
let n = 0
while (true) {
const { done, value } = await lector.read()
if (done) break
const texto = decodificador.decode(value)
// Solo un resumen de cada trozo: su tamaño y qué contiene
const pistas = [...texto.matchAll(/<h1[^>]*>([^<]+)|hidden id="(S:\d+)"|Esqueleto|aria-busy|Facturaci|#50\d\d|Pack teclado/g)]
.map((m) => m[1] ?? m[2] ?? m[0])
console.log(`trozo ${n++} +${Date.now() - inicio} ms · ${value.length} bytes · ${[...new Set(pistas)].join(', ')}`)
}
El Accept-Encoding: identity pide la respuesta sin comprimir, para poder leer el texto. Aplicado al panel de mando que construirás en el apartado 11, que tiene cuatro bloques con datos, la salida real es:
HTTP 200 · transfer-encoding: chunked
trozo 0 +5 ms · 3307 bytes · Panel de mando, aria-busy
trozo 1 +26 ms · 7585 bytes · aria-busy
trozo 2 +1035 ms · 3835 bytes · Facturaci
trozo 3 +1041 ms · 3168 bytes · S:0, Facturaci, S:1
trozo 4 +1047 ms · 1236 bytes · S:2, S:3, Pack teclado
trozo 5 +1047 ms · 1781 bytes · Pack teclado
trozo 6 +1103 ms · 7344 bytes · aria-busy
trozo 7 +1106 ms · 14 bytes ·
Léelo así:
- A los 5 ms ya ha salido el título “Panel de mando” y los esqueletos (
aria-busy). Es el armazón, que estaba prerenderizado. - Durante un segundo no llega nada: el servidor está esperando a la API.
- A partir de los 1035 ms llegan los bloques con datos: la facturación, los segmentos
S:0aS:3y los productos agotados. - El trozo 6 es la carga útil RSC (RSC payload), la descripción del árbol de componentes que React necesita para hidratar la página. Incluye también la descripción de los esqueletos, por eso aparece
aria-busy. - El código
200salió en el primer trozo, a los 5 ms, antes de saber si los datos existían.
Compara con la página bloqueante del apartado anterior:
HTTP 200 · transfer-encoding: chunked
trozo 0 +1152 ms · 1785 bytes ·
trozo 1 +1216 ms · 5537 bytes ·
trozo 2 +1217 ms · 4848 bytes ·
trozo 3 +1222 ms · 14 bytes ·
Mismo segundo de espera, pero sin nada que ver mientras tanto.
Figura 2. Una sola respuesta HTTP en varios trozos. Cada <Suspense> llega por su cuenta.
4.2. Qué hace el navegador con cada trozo
Si guardas la respuesta completa (curl -s http://localhost:3001/panel > panel.html) y la buscas por dentro, verás las piezas del mecanismo:
<template id="B:0"> ← marca dónde está el fallback del primer Suspense
<div hidden id="S:0"> ← el contenido real, llegado después y oculto
$RC("B:0","S:0") ← script que cambia uno por otro
El navegador pinta el armazón según llega. Cuando llega un segmento S:n, el script $RC lo mueve al lugar del fallback B:n. Esto ocurre antes de que React se haya descargado e hidratado: el usuario ve los datos aunque el JavaScript de la página todavía no haya llegado. La hidratación viene después, con la carga útil RSC.

Figura 3. El panel a los 250 ms: el armazón con sus esqueletos, sin esperar a la API.

Figura 4. El mismo panel un segundo después, con los cuatro bloques ya rellenos.
4.3. El contrato HTTP: el código sale primero
Una respuesta HTTP empieza por la línea de estado (200 OK, 404 Not Found…) y las cabeceras. Con streaming, eso se envía con el primer trozo. Si un componente descubre después que el departamento no existe, ya no puede cambiar el 200 por un 404. Next lo resuelve como puede: pinta la página de “no encontrado” en el hueco y añade <meta name="robots" content="noindex"> para que los buscadores no la indexen. Lo verás con datos reales en el apartado 10.2.
⚠️ Importante: El streaming necesita que nada entre el servidor y el navegador acumule la respuesta. Algunos proxies inversos (Nginx lo hace por defecto, y se desactiva con la cabecera
X-Accel-Buffering: no), CDN y la compresión mal configurada esperan a tener la respuesta entera antes de reenviarla, y el usuario vuelve a ver la página de golpe. Lo verás al desplegar en el módulo 23. Para comprobarlo, el scriptobservar.mjses más fiable quecurl, que tiene su propio búfer.
📘 Concepto: Los robots de los buscadores que no ejecutan JavaScript no reciben el armazón con huecos. Next los detecta por su user agent y les entrega la página completa, esperando a los datos. Tus usuarios tienen streaming y Google tiene el contenido.
5. loading.tsx frente a <Suspense>
Ya conoces los ficheros especiales del App Router (módulo 03). loading.tsx es uno de ellos: si lo colocas junto a un page.tsx, Next envuelve la página (y todo lo que cuelgue de esa carpeta) en un <Suspense> cuyo fallback es tu componente.
app/empleados/
├── loading.tsx → fallback de toda la carpeta
├── page.tsx → /empleados
└── [id]/page.tsx → /empleados/100 (también cubierta por el loading.tsx de arriba)
app/empleados/page.tsx puede entonces ser asíncrona y esperar directamente, sin <Suspense> propio:
import Link from 'next/link'
import { obtenerEmpleados } from '@/lib/api'
// La página entera espera a la API; loading.tsx la cubre mientras tanto
export default async function PaginaEmpleados() {
const { contenido: empleados, total } = await obtenerEmpleados()
return (
<section className="space-y-4">
<h1 className="text-2xl font-bold text-indigo-900">Empleados ({total})</h1>
<table className="w-full rounded border bg-white text-sm">
<thead className="bg-indigo-50 text-left">
<tr>
<th className="px-3 py-2">Nombre</th>
<th className="px-3 py-2">Oficio</th>
<th className="px-3 py-2">Departamento</th>
<th className="px-3 py-2 text-right">Salario</th>
</tr>
</thead>
<tbody>
{empleados.slice(0, 8).map((e) => (
<tr key={e.empId} className="border-t">
<td className="px-3 py-2">
<Link href={`/empleados/${e.empId}`} className="text-indigo-700 hover:underline">
{e.apellidos}, {e.nombre}
</Link>
</td>
<td className="px-3 py-2">{e.oficio}</td>
<td className="px-3 py-2">{e.departamento}</td>
<td className="px-3 py-2 text-right">{e.salario.toLocaleString('es-ES')} €</td>
</tr>
))}
</tbody>
</table>
</section>
)
}
// app/empleados/loading.tsx: se muestra al instante mientras page.tsx espera
export default function CargandoEmpleados() {
return (
<section className="animate-pulse space-y-4" aria-busy="true">
<div className="h-8 w-56 rounded bg-slate-200" />
<div className="space-y-2 rounded border bg-white p-3">
{Array.from({ length: 8 }, (_, i) => (
<div key={i} className="h-5 rounded bg-slate-200" />
))}
</div>
</section>
)
}
Este build no falla: loading.tsx es un <Suspense> válido y la página sale como ◐ /empleados. Al pulsar Empleados en el menú, la URL cambia al momento y aparece el esqueleto, con el menú intacto porque pertenece al layout:

Figura 5. Navegación a /empleados: el esqueleto de loading.tsx aparece sin esperar a la API.
El esqueleto imita la forma de la tabla final (un título, ocho filas). Así, cuando llegan los datos, la página no da saltos. Las utilidades animate-pulse y bg-slate-200 son de Tailwind (módulo 06).
💡 Consejo:
aria-busy="true"avisa a los lectores de pantalla de que esa zona está cargando. Es un atributo, no cuesta nada, y hace que tus esqueletos sean accesibles. Además, como has visto, te sirve para encontrarlos en el HTML y en las pruebas.
5.1. Cuál elegir
loading.tsx | <Suspense> | |
|---|---|---|
| Alcance | La página entera y sus subrutas | El componente que envuelves |
| Cómo se usa | Creas un fichero | Envuelves en código |
| Granularidad | Todo o nada: un único esqueleto | Un esqueleto por bloque, cada uno llega por su cuenta |
| En la navegación | Next precarga el fallback: aparece al instante | Igual, si está en el armazón |
| Cuándo | Páginas en las que nada tiene sentido sin los datos | La mayoría: listas, paneles, fichas con partes independientes |
La documentación de Next recomienda colocar <Suspense> lo más cerca posible del dato que espera. Con un loading.tsx en lo alto, toda la página se convierte en un esqueleto aunque solo un bloque dependa de la API. En el panel del apartado 11, con un solo loading.tsx el título “Panel de mando” también esperaría un segundo; con cuatro <Suspense> sale a los 5 ms.
⚠️ Importante:
loading.tsxenvuelve apage.tsx, pero no allayout.tsxde su misma carpeta: el layout queda por encima. Si un layout espera datos sin caché, elloading.tsxde al lado no lo cubre. ConcacheComponentsel build falla y te lo dice; en Next.js 15 la navegación simplemente se queda bloqueada hasta que el layout termina. La solución es la misma en ambos: mete esa parte del layout en su propio<Suspense>.
6. Cascadas de peticiones (waterfalls)
Una cascada es una serie de peticiones que se hacen una detrás de otra cuando podrían hacerse a la vez. En un diagrama de tiempos, cada barra empieza donde acaba la anterior, como el agua que cae de escalón en escalón. Es el error de rendimiento más común al pedir datos, y en el servidor no se ve: la página funciona, solo que tarda el triple.
6.1. La cascada más típica: await en fila
Este componente necesita tres datos que no dependen entre sí:
import { Suspense } from 'react'
import { connection } from 'next/server'
import { obtenerDepartamentos, obtenerEstadisticas, obtenerPedidosRecientes } from '@/lib/api'
async function Resumen() {
await connection() // a partir de aquí, esto se ejecuta en cada petición
const inicio = performance.now()
// Tres await seguidos: cada petición espera a que termine la anterior
const departamentos = await obtenerDepartamentos()
const estadisticas = await obtenerEstadisticas()
const pedidos = await obtenerPedidosRecientes()
const ms = Math.round(performance.now() - inicio)
console.log(`[secuencial] ${ms} ms`)
return (
<dl className="grid grid-cols-2 gap-2 rounded border bg-white p-4">
<dt>Departamentos</dt><dd>{departamentos.total}</dd>
<dt>Empleados</dt><dd>{estadisticas.empleados}</dd>
<dt>Pedidos recientes</dt><dd>{pedidos.contenido.length}</dd>
<dt className="font-bold">Tiempo en el servidor</dt><dd className="font-bold">{ms} ms</dd>
</dl>
)
}
export default function PaginaSecuencial() {
return (
<section className="space-y-4">
<h1 className="text-2xl font-bold text-indigo-900">En cascada</h1>
<Suspense fallback={<p>Cargando...</p>}>
<Resumen />
</Suspense>
</section>
)
}
El await connection() del principio tiene un motivo: medir el tiempo con performance.now() es leer el reloj, y Next no permite leerlo durante el prerender (el resultado se congelaría en el armazón). connection() marca que lo que viene después pertenece a la petición. Viene de next/server y existe también en Next.js 15.
El console.log de un Server Component aparece en la terminal donde corre Next, no en la consola del navegador. Con dos peticiones a la página, next start escribió:
[secuencial] 3182 ms
[secuencial] 3178 ms
Tres segundos. Cada await detiene la función hasta que llega la respuesta, así que la segunda petición ni siquiera sale hasta que vuelve la primera.
6.2. La solución: lanzar todo y esperar una vez
La regla es: empieza todas las peticiones independientes antes de esperar a ninguna. Promise.all recibe un array de promesas y devuelve otra que se cumple cuando se cumplen todas:
async function Resumen() {
await connection()
const inicio = performance.now()
// Las tres peticiones salen a la vez; Promise.all espera a la más lenta
const [departamentos, estadisticas, pedidos] = await Promise.all([
obtenerDepartamentos(),
obtenerEstadisticas(),
obtenerPedidosRecientes(),
])
const ms = Math.round(performance.now() - inicio)
console.log(`[paralelo] ${ms} ms`)
// ... el mismo JSX que antes
}
[paralelo] 1065 ms
[paralelo] 1140 ms
Un segundo: el tiempo de la petición más lenta, no la suma de todas.
Figura 6. Las mismas tres peticiones. El trabajo es idéntico; solo cambia cuándo empieza cada una.
¿Por qué funciona? Porque obtenerDepartamentos() lanza la petición en el momento en que la llamas, no cuando haces await. El await solo espera el resultado. Este código es equivalente y lo deja más claro:
// Las tres salen aquí, una tras otra en microsegundos
const pDepartamentos = obtenerDepartamentos()
const pEstadisticas = obtenerEstadisticas()
const pPedidos = obtenerPedidosRecientes()
// Y aquí se espera
const departamentos = await pDepartamentos
const estadisticas = await pEstadisticas
const pedidos = await pPedidos
Si repasas la promesa en el Curso de JavaScript, verás que no hay nada propio de Next aquí: es JavaScript asíncrono puro. Next solo hace que el coste se note más, porque el usuario espera mientras el servidor espera.
⚠️ Importante: Este patrón con variables intermedias tiene una trampa: si
pEstadisticasfalla mientras esperas apDepartamentos, Node ve un rechazo que nadie atiende todavía y puede avisar con unhandled rejection.Promise.allno tiene ese problema porque atiende las tres promesas desde el principio. Úsalo siempre que puedas.
6.3. Cuando una puede fallar: Promise.allSettled
Promise.all es “todo o nada”: si una promesa se rechaza, el conjunto se rechaza y pierdes también las que han ido bien. Cuando los datos son independientes y prefieres mostrar lo que haya, usa Promise.allSettled, que espera a todas y te dice cómo ha acabado cada una:
import { Suspense } from 'react'
import { obtenerDepartamento } from '@/lib/api'
async function Departamentos() {
// El 99 no existe: con Promise.all fallaría todo; con allSettled solo ese
const ids = [10, 99, 30]
const resultados = await Promise.allSettled(ids.map((id) => obtenerDepartamento(id)))
return (
<ul className="divide-y rounded border bg-white">
{resultados.map((r, i) => (
<li key={ids[i]} className="px-4 py-2">
{r.status === 'fulfilled'
? `${r.value.deptId} · ${r.value.nombre}`
: `${ids[i]} · no disponible (${(r.reason as Error).message})`}
</li>
))}
</ul>
)
}
export default function PaginaTolerante() {
return (
<section className="space-y-4">
<h1 className="text-2xl font-bold text-indigo-900">Tolerante a fallos</h1>
<Suspense fallback={<p>Cargando...</p>}>
<Departamentos />
</Suspense>
</section>
)
}
La lista que se genera:
10 · DIRECCIÓN
99 · no disponible (Departamento 99 no encontrado)
30 · INFORMÁTICA
El mensaje viene de la API, a través del ErrorApi que lanza pedir. Cada resultado es { status: 'fulfilled', value } o { status: 'rejected', reason }, y TypeScript estrecha el tipo al comprobar status.
6.4. La cascada escondida: componentes anidados
Las cascadas no siempre están en una misma función. Mira este caso:
import { Suspense } from 'react'
import { connection } from 'next/server'
import { obtenerEstadisticas, obtenerPedidosRecientes } from '@/lib/api'
// El hijo no se renderiza hasta que el padre termina su await
async function Pedidos({ inicio }: { inicio: number }) {
const { contenido } = await obtenerPedidosRecientes(3)
const ms = Math.round(performance.now() - inicio)
console.log(`[anidado] hijo listo a los ${ms} ms`)
return <p>{contenido.length} pedidos recientes · listo a los {ms} ms</p>
}
async function Resumen() {
await connection()
const inicio = performance.now()
const e = await obtenerEstadisticas()
return (
<div className="space-y-2 rounded border bg-white p-4">
<p>{e.pedidos} pedidos en total</p>
<Pedidos inicio={inicio} />
</div>
)
}
[anidado] hijo listo a los 2152 ms
[anidado] hijo listo a los 2118 ms
Pedidos no depende de las estadísticas, pero React no puede ejecutarlo hasta que Resumen devuelve su JSX, y Resumen no lo devuelve hasta que termina su await. Dos segundos en lugar de uno. Es la misma cascada de antes, repartida entre dos componentes, y por eso cuesta más verla.
Tienes dos formas de deshacerla:
- Hermanos en lugar de padre e hijo. Si
ResumenyPedidosestán uno al lado del otro, cada uno en su<Suspense>, React los ejecuta a la vez. Es lo que hace el panel del apartado 11. - Empezar la petición del hijo en el padre, antes del
await, y pasarle la promesa o usar la precarga del apartado 6.6.
📘 Concepto: Next renderiza en paralelo los segmentos de una ruta: el
layout.tsxy elpage.tsxempiezan a la vez, y cada uno puede pedir sus datos sin esperar al otro. Las cascadas aparecen dentro de un segmento, cuando un componente espera antes de renderizar a sus hijos.
6.5. Cascadas inevitables: cuando un dato depende de otro
A veces la cascada es lógica, no un descuido. Para mostrar el jefe de un empleado, necesitas primero el empleado, porque ahí está su jefeId. No hay Promise.all que valga: la segunda petición necesita el resultado de la primera.
Lo que sí puedes hacer es que esa dependencia no bloquee lo demás. Muestra lo que ya tienes y envuelve la parte dependiente en su propio <Suspense>:
async function Jefe({ jefeId }: { jefeId: number }) {
const jefe = await obtenerEmpleado(jefeId)
return <p>Jefe: {jefe.nombre} {jefe.apellidos} ({jefe.oficio})</p>
}
async function Ficha({ id }: { id: number }) {
const e = await obtenerEmpleado(id)
return (
<article>
<h1>{e.nombre} {e.apellidos}</h1>
<p>{e.oficio} en {e.departamento}</p>
<Suspense fallback={<p>Cargando jefe...</p>}>
<Jefe jefeId={e.jefeId!} />
</Suspense>
</article>
)
}
A los 1000 ms el usuario ya ve la ficha del empleado; el jefe aparece a los 2000 ms. El tiempo total no cambia, pero el usuario tiene algo útil a la mitad. Lo completarás en el ejercicio 4.
💡 Consejo: Si una cascada inevitable se repite mucho y la primera petición es lenta, hay dos arreglos de fondo: pedirle a la API un endpoint que devuelva todo junto (es lo que hace
/api/pedidos/:id, que incluye las líneas y el total), o guardar en caché el primer dato si cambia poco (módulo 09).
6.6. Precargar: empezar antes de esperar
Hay casos intermedios. En la ficha de un departamento necesitas el departamento (para saber si existe y mostrar su nombre) y su plantilla. La plantilla no depende del departamento: solo del id, que ya tienes. Pero no quieres mostrarla si el departamento no existe. La solución es lanzar la petición de la plantilla sin esperarla, comprobar el departamento, y dejar que el componente que pinta la plantilla la recoja después:
async function Contenido({ params }: { params: Promise<{ id: string }> }) {
const deptId = Number((await params).id)
if (!Number.isInteger(deptId)) notFound()
// Precarga: la plantilla empieza a pedirse ya, en paralelo con el departamento.
// El catch evita un rechazo sin atender si el departamento no existe.
obtenerEmpleadosDe(deptId).catch(() => {})
const d = await buscarDepartamento(deptId)
if (!d) notFound()
return (
<>
<header>...</header>
<Suspense fallback={<p>Cargando plantilla...</p>}>
<Plantilla id={deptId} />
</Suspense>
</>
)
}
¿Y no se pide dos veces la plantilla, una en la precarga y otra en Plantilla? No: Next memoriza los fetch idénticos durante el render (apartado 8), así que la segunda llamada recibe la respuesta de la primera. En el registro de peticiones de la API (en el apartado 8.1 verás cómo obtenerlo), las dos peticiones de /departamentos/30 llegaron con 13 ms de diferencia:
[API 11:25:26.725] GET /api/departamentos/30/empleados
[API 11:25:26.738] GET /api/departamentos/30
El código completo de esta página está en el apartado 7.
7. Empujar la espera hacia abajo
Las rutas dinámicas reciben params como promesa (módulo 04). Esperarla en la página parece lo natural:
export default async function PaginaDepartamento({ params }: PageProps<'/departamentos/[id]'>) {
const { id } = await params
const deptId = Number(id)
// ... dos componentes con sus Suspense
}
Pero con cacheComponents el build falla con el mismo error del apartado 3.1:
Error: Route "/departamentos/[id]": Next.js encountered uncached or runtime data during prerendering.
params es un dato de la petición: en el build no se sabe qué id pedirá el usuario. Si la página lo espera arriba del todo, nada de la página puede entrar en el armazón. La solución es la que recomienda la documentación de Next: no esperes en la página; pasa la promesa hacia abajo y espera dentro del <Suspense>. Esta es la versión completa de app/departamentos/[id]/page.tsx:
import { Suspense } from 'react'
import { notFound } from 'next/navigation'
import {
ErrorApi,
obtenerDepartamento,
obtenerEmpleadosDe,
type Departamento,
} from '@/lib/api'
async function buscarDepartamento(id: number): Promise<Departamento | null> {
try {
return await obtenerDepartamento(id)
} catch (e) {
if (e instanceof ErrorApi && e.status === 404) return null
throw e
}
}
async function Plantilla({ id }: { id: number }) {
const { contenido: empleados } = await obtenerEmpleadosDe(id)
if (empleados.length === 0) return <p className="text-slate-500">Sin empleados.</p>
return (
<ul className="divide-y rounded border bg-white">
{empleados.map((e) => (
<li key={e.empId} className="flex justify-between px-4 py-2">
<span>{e.nombre} {e.apellidos}</span>
<span className="text-slate-500">{e.oficio}</span>
</li>
))}
</ul>
)
}
// Recibe la promesa de params y la resuelve dentro del Suspense
async function Contenido({ params }: { params: Promise<{ id: string }> }) {
const deptId = Number((await params).id)
if (!Number.isInteger(deptId)) notFound()
// Precarga: la plantilla empieza a pedirse ya, en paralelo con el departamento.
// El catch evita un rechazo sin atender si el departamento no existe.
obtenerEmpleadosDe(deptId).catch(() => {})
const d = await buscarDepartamento(deptId)
if (!d) notFound()
return (
<>
<header>
<h1 className="text-2xl font-bold text-indigo-900">{d.nombre}</h1>
<p className="text-slate-500">
{d.localidad} ·{' '}
{d.presupuesto === null
? 'Sin presupuesto asignado'
: `Presupuesto ${d.presupuesto.toLocaleString('es-ES')} €`}
</p>
</header>
<Suspense fallback={<p>Cargando plantilla...</p>}>
<Plantilla id={deptId} />
</Suspense>
</>
)
}
// La página no es async: no espera nada y forma parte del armazón
export default function PaginaDepartamento({ params }: PageProps<'/departamentos/[id]'>) {
return (
<section className="space-y-4">
<Suspense fallback={<p>Cargando departamento...</p>}>
<Contenido params={params} />
</Suspense>
</section>
)
}

Figura 7. /departamentos/30: la cabecera y la plantilla, pedidas en paralelo gracias a la precarga.
Con esta estructura, la página sale como ◐ /departamentos/[id] y responde al instante con el menú y “Cargando departamento…”. Fíjate en tres detalles:
PaginaDepartamentono esasync. Recibeparamsy lo pasa sin tocarlo.buscarDepartamentoconvierte el 404 de la API ennully deja pasar cualquier otro error. Distinguir “no existe” de “algo ha ido mal” es importante: lo primero es una página de “no encontrado”; lo segundo, una página de error.- La primera versión de esta página tenía la cabecera y la plantilla como dos componentes hermanos, cada uno en su
<Suspense>. Funcionaba con los departamentos que existen, pero con/departamentos/99los dos pedían a la API, los dos recibían 404, y mientras la cabecera llamaba anotFound(), la plantilla lanzaba unErrorApique aparecía en la terminal como error del servidor. Comprobar primero que el departamento existe y precargar la plantilla resuelve las dos cosas.
💡 Consejo: La misma idea vale para
searchParams,cookies()yheaders(): son promesas. Pásalas sin esperar hasta el componente que las necesita, y espéralas dentro de su<Suspense>. Cuanto más abajo esperes, más página entra en el armazón.
8. No repetir peticiones: memorización y cache
En una aplicación real, varios componentes necesitan el mismo dato: la cabecera, la ficha y el pie muestran el nombre del usuario; el panel usa las estadísticas en dos bloques. Pasarlo de padre a hijo por props (prop drilling) acopla los componentes. Lo cómodo es que cada componente pida lo que necesita. La pregunta es si eso multiplica las peticiones.
8.1. Memorización automática de fetch
Next memoriza las llamadas a fetch con método GET y la misma URL y las mismas opciones durante un mismo render del servidor: la primera sale a la red y las siguientes reciben su resultado. Para comprobarlo hace falta ver qué recibe la API, y server.mjs no escribe nada por cada petición. Este envoltorio, api-con-registro.mjs, sustituye http.createServer antes de cargar la API, de modo que cada petición se anota con su hora sin tocar el código original:
// Uso: RETARDO=1000 node api-con-registro.mjs ./server.mjs
import http from 'node:http'
import { syncBuiltinESMExports } from 'node:module'
import { pathToFileURL } from 'node:url'
const original = http.createServer
http.createServer = (manejador) =>
original(async (req, res) => {
const t = new Date().toISOString().slice(11, 23)
console.log(`[API ${t}] ${req.method} ${req.url}`)
return manejador(req, res)
})
syncBuiltinESMExports() // para que `import { createServer }` vea la versión nueva
await import(pathToFileURL(process.argv[2]).href)
Con la API arrancada así, se renderizó esta página:
import { Suspense, cache } from 'react'
import { obtenerDepartamento, obtenerEmpleado, obtenerEmpleadosDe } from '@/lib/api'
// 1. fetch normal: tres componentes, misma URL
async function NombreEmpleado({ etiqueta }: { etiqueta: string }) {
const e = await obtenerEmpleado(100)
return <li>{etiqueta}: {e.nombre} {e.apellidos}</li>
}
// 2. fetch con signal: Next no lo memoriza
async function NombreConSenal({ etiqueta }: { etiqueta: string }) {
const res = await fetch(`${process.env.API_URL}/empleados/101`, {
signal: AbortSignal.timeout(5000),
})
const e = await res.json()
return <li>{etiqueta}: {e.nombre} {e.apellidos}</li>
}
// 3. Una función propia, con y sin cache de React
async function resumenEquipoSinCache(deptId: number) {
console.log(`[resumen sin cache] calculando ${deptId}`)
const [d, plantilla] = await Promise.all([obtenerDepartamento(deptId), obtenerEmpleadosDe(deptId)])
const masa = plantilla.contenido.reduce((suma, e) => suma + e.salario, 0)
return { nombre: d.nombre, personas: plantilla.total, masa }
}
const resumenEquipo = cache(async (deptId: number) => {
console.log(`[resumen con cache] calculando ${deptId}`)
return resumenEquipoSinCache(deptId)
})
async function Equipo({ conCache }: { conCache: boolean }) {
const r = conCache ? await resumenEquipo(30) : await resumenEquipoSinCache(30)
return <li>{r.nombre}: {r.personas} personas, {r.masa} €</li>
}
export default function PaginaMemo() {
return (
<section className="space-y-4">
<h1 className="text-2xl font-bold text-indigo-900">Memorización</h1>
<Suspense fallback={<p>Cargando...</p>}>
<ul className="list-disc pl-6">
<NombreEmpleado etiqueta="Cabecera" />
<NombreEmpleado etiqueta="Ficha" />
<NombreEmpleado etiqueta="Pie" />
<NombreConSenal etiqueta="Con signal A" />
<NombreConSenal etiqueta="Con signal B" />
<Equipo conCache />
<Equipo conCache />
<Equipo conCache={false} />
<Equipo conCache={false} />
</ul>
</Suspense>
</section>
)
}
El registro de la API para una petición a la página:
[API 11:24:56.140] GET /api/empleados/100
[API 11:24:56.142] GET /api/empleados/101
[API 11:24:56.142] GET /api/empleados/101
[API 11:24:56.143] GET /api/departamentos/30
[API 11:24:56.144] GET /api/departamentos/30/empleados
/empleados/100, una vez, aunque tres componentes lo piden. Memorizado./empleados/101, dos veces: las dos llamadas llevansignal. Una señal de cancelación es un objeto distinto en cada llamada, así que Next no puede considerarlas iguales. La documentación lo indica como la forma de excluir una petición de la memorización./departamentos/30y su plantilla, una vez cada una, aunque las cuatroEquipollaman a funciones que las piden. Por debajo hayfetchidénticos, y se memorizan.
Lo que la memorización no es:
Memorización de fetch | Caché ('use cache', módulo 09) | |
|---|---|---|
| Dura | Un render: lo que tarda en generarse la respuesta | Entre peticiones, minutos u horas |
| Se comparte entre usuarios | No | Sí, salvo 'use cache: private' |
| Hay que activarla | No, es automática | Sí, con la directiva |
| Sirve para | No repetir peticiones dentro de una página | No repetir peticiones entre visitas |
| Datos que ves | Siempre frescos | Pueden tener cierta antigüedad |
⚠️ Importante: La memorización solo funciona dentro del árbol de componentes. En un Route Handler (
route.ts, módulo 12) no se aplica, y tampoco conPOST. Si tu API exigePOSTpara consultas, o usas una base de datos, necesitascachede React.
8.2. cache de React para todo lo demás
cache (de react, no de Next) envuelve una función y memoriza su resultado por argumentos durante la petición en curso. Es la herramienta para lo que no es un fetch: una consulta a la base de datos, un cálculo caro, o una función que combina varias peticiones, como resumenEquipo.
En la terminal de next start, para esa misma petición:
[resumen con cache] calculando 30
[resumen sin cache] calculando 30
[resumen sin cache] calculando 30
[resumen sin cache] calculando 30
[resumen con cache] calculando 30
[resumen sin cache] calculando 30
[resumen sin cache] calculando 30
[resumen sin cache] calculando 30
Hay que leerlo con cuidado, porque aparece un detalle de Next.js 16. Los mensajes salen en dos bloques iguales: en las pruebas de este módulo, Next.js 16.4 con cacheComponents recorrió el árbol dos veces en cada petición, y cualquier console.log de un Server Component aparecía por duplicado. Lo que importa está dentro de cada bloque:
- La versión con
cachese calcula una vez, aunque la usan dos componentes. - La versión sin
cachese calcula tres veces: dos por los componentesEquipoque la llaman directamente, y una más desde dentro deresumenEquipo, que la usa para calcular.
Y pese a los dos bloques, el registro de la API muestra una sola petición por URL: la memorización de fetch se mantuvo durante toda la petición.
💡 Consejo: No uses
console.logpara contar llamadas a la API. Mira el otro lado: el registro del servidor de la API o la opciónlogging.fetchesdenext.config.ts, que en desarrollo escribe en la terminal cadafetchdel servidor (confullUrl: true, la URL completa).
Reglas para usar cache bien:
- Créala fuera de los componentes, a nivel de módulo, y expórtala. Una función envuelta dentro de un componente se crea de nuevo en cada render y no memoriza nada.
- Los argumentos se comparan por identidad, como en
Object.is. Con números y cadenas funciona como esperas; con objetos,{ id: 30 }y otro{ id: 30 }son llamadas distintas. Pasa valores primitivos. - Dura una petición. Cada visitante tiene su propia memoria; no hay riesgo de mezclar datos de dos usuarios, pero tampoco ahorro entre visitas.
- Solo en el servidor.
cacheestá pensada para Server Components; en un Client Component no te sirve para compartir datos entre renders.
Un uso típico es una función que construye una estructura a partir de la API y que varios componentes consultan. Lo harás en el ejercicio 6.
9. Pasar promesas a Client Components con use
Un buscador que filtra mientras escribes necesita estado (useState), así que es un Client Component. Pero los datos pueden seguir pidiéndose en el servidor. La técnica: la página lanza la petición sin await y pasa la promesa como prop. El Client Component la lee con el hook use de React, dentro de un <Suspense>.
// app/promesas/page.tsx (Server Component)
import { Suspense } from 'react'
import { obtenerEmpleados } from '@/lib/api'
import { BuscadorEmpleados } from './buscador'
export default function PaginaPromesas() {
// Sin await: la petición empieza aquí y la promesa viaja al cliente
const empleados = obtenerEmpleados().then((p) =>
p.contenido.map((e) => ({ id: e.empId, nombre: `${e.apellidos}, ${e.nombre}`, oficio: e.oficio })),
)
return (
<section className="space-y-4">
<h1 className="text-2xl font-bold text-indigo-900">Buscar empleados</h1>
<Suspense fallback={<p>Cargando empleados...</p>}>
<BuscadorEmpleados empleados={empleados} />
</Suspense>
</section>
)
}
// app/promesas/buscador.tsx (Client Component)
'use client'
import { use, useState } from 'react'
type Fila = { id: number; nombre: string; oficio: string }
export function BuscadorEmpleados({ empleados }: { empleados: Promise<Fila[]> }) {
const lista = use(empleados) // suspende hasta que llegan los datos
const [texto, setTexto] = useState('')
const filtrados = lista.filter((e) => e.nombre.toLowerCase().includes(texto.toLowerCase()))
return (
<div className="space-y-3">
<input
value={texto}
onChange={(ev) => setTexto(ev.target.value)}
placeholder="Filtrar por apellido o nombre"
className="w-full rounded border px-3 py-2"
/>
<p className="text-sm text-slate-500">{filtrados.length} de {lista.length}</p>
<ul className="divide-y rounded border bg-white text-sm">
{filtrados.slice(0, 6).map((e) => (
<li key={e.id} className="flex justify-between px-4 py-2">
<span>{e.nombre}</span>
<span className="text-slate-500">{e.oficio}</span>
</li>
))}
</ul>
</div>
)
}

Figura 8. El filtro funciona en el navegador sin volver a pedir nada: los 24 empleados llegaron con la página.
Qué ocurre por dentro:
- La petición a la API sale del servidor, en cuanto se renderiza la página. El navegador nunca ve la URL de la API.
- La promesa se serializa en la carga útil RSC. Cuando se cumple, su valor viaja por el mismo streaming que el HTML. Como todo lo que cruza a un Client Component, el valor debe ser serializable (módulo 05): objetos, arrays, cadenas, números, fechas… no funciones ni clases.
- El
.thenque reduce cada empleado a{ id, nombre, oficio }no es un adorno: todo lo que pasas al cliente se envía. Si pasaras el objeto completo, el salario y el correo de cada empleado viajarían en el HTML aunque no los mostraras. Recorta en el servidor. usesuspende el componente hasta que la promesa se cumple, y el<Suspense>más cercano muestra sufallback. Si se rechaza, salta alerror.tsxmás cercano.
📘 Concepto: ¿Por qué no
awaiten la página y pasar el array ya resuelto? Se puede, y es más sencillo. Pasar la promesa tiene una ventaja: la página no espera, y el resto de su contenido sale en el armazón. Es otra forma de “empujar la espera hacia abajo”, esta vez hasta un Client Component. El hookusese explica a fondo en el Curso de React.
⚠️ Importante: No crees la promesa dentro del Client Component (
use(fetch(...))). En cada render crearías una promesa nueva, React volvería a suspender, y entrarías en un bucle. Las promesas que leeuseen el cliente deben venir de fuera: del servidor, como aquí, o de una biblioteca que las guarde, como TanStack Query (módulo 18).
10. Cuando la API falla
Pedir datos a otro servidor significa aceptar que a veces no contestará, contestará tarde o contestará algo inesperado. Cada caso se trata de una forma distinta.
10.1. Tres tipos de fallo
| Fallo | Ejemplo | Qué hace fetch | Cómo lo tratas |
|---|---|---|---|
| El recurso no existe | GET /api/departamentos/99 → 404 | Devuelve una respuesta con ok: false | notFound() y un not-found.tsx |
| La API falla o no responde | 500, servidor caído, tiempo agotado | 500: ok: false. Caído o agotado: lanza una excepción | Dejar que salte a error.tsx |
| Los datos no son los esperados | Un campo null que tu tipo no admite | Nada: el JSON es válido | Ajustar el tipo o validar (módulo 11) |
10.2. Recursos que no existen: notFound()
notFound(), de next/navigation, interrumpe el render y muestra el not-found.tsx más cercano. Puedes crear uno para la sección de departamentos, en app/departamentos/[id]/not-found.tsx:
import Link from 'next/link'
export default function DepartamentoNoEncontrado() {
return (
<div className="space-y-3 rounded border border-amber-300 bg-amber-50 p-6">
<h1 className="text-xl font-bold text-amber-900">Ese departamento no existe</h1>
<p>Puede que lo hayan borrado o que el enlace esté mal escrito.</p>
<Link href="/departamentos" className="text-indigo-700 underline">
Ver todos los departamentos
</Link>
</div>
)
}

Figura 9. /departamentos/99: el not-found.tsx de la sección, dentro del layout.
Ahora mira lo que recibe el navegador. Con curl, pidiendo tres URL de la ficha de departamento:
curl -s -o r.html -w "%{http_code}\n" http://localhost:3001/departamentos/99
grep -o '<meta name="robots"[^>]*>' r.html
departamentos/70 200 <h1 class="text-2xl font-bold text-indigo-900">INVESTIGACIÓN
departamentos/99 200 <meta name="robots" content="noindex"/>
departamentos/abc 200 <meta name="robots" content="noindex"/>
(Para la primera se buscó el h1 en lugar de la etiqueta robots, que no aparece).
El departamento 99 no existe y la respuesta es 200, no 404. Es el contrato HTTP del apartado 4.3: el armazón (con su 200) salió antes de que Contenido preguntara a la API. Next añade la etiqueta noindex para que los buscadores no guarden esa página, y el usuario ve el mensaje correcto. Para una persona, el resultado es el mismo.
¿Y si necesitas un 404 de verdad, por ejemplo porque otro programa consulta tus páginas y mira el código? Tienes que decidir antes de que empiece el streaming:
- En el
proxy, que se ejecuta antes de renderizar (módulo 13). - En el prerender: si las fichas se generan en el build con
generateStaticParamsy sus datos se guardan en caché, unnotFound()durante el prerender sí produce un 404 (módulo 09).
⚠️ Importante: Se probó también
export const instant = falseen una ruta dinámica (/ficha/[id]), esperando que, al bloquear, el 404 llegara a tiempo. No fue así: la ruta salió como◐en el build y/ficha/999respondió200connoindex, igual que las demás. En Next.js 16.4 concacheComponents, no cuentes coninstant = falsepara obtener códigos de estado en rutas dinámicas.
💡 Consejo: Llama a
notFound()en el componente que espera el dato, no en una promesa que nadie espera. Funciona lanzando una excepción especial: si la lanzas dentro de un.then()suelto, nadie la recoge y no se muestra nada. Y cuidado con envolverla en untry/catchpropio: es el mismo problema que conredirect()en el módulo 03. EnbuscarDepartamentoeltrysolo rodea la llamada a la API, ynotFound()se llama fuera.
10.3. La API falla o tarda demasiado: error.tsx
Si un componente lanza una excepción que nadie captura, Next muestra el error.tsx más cercano. Un fetch sin respuesta puede quedarse esperando mucho tiempo; AbortSignal.timeout(ms) lo corta:
// app/lento/page.tsx
import { Suspense } from 'react'
async function DatosLentos() {
// La API tarda 1 s por el RETARDO; damos solo 300 ms
const res = await fetch(`${process.env.API_URL}/estadisticas`, {
signal: AbortSignal.timeout(300),
})
const datos = await res.json()
return <p>{datos.empleados} empleados</p>
}
export default function PaginaLenta() {
return (
<section className="space-y-4">
<h1 className="text-2xl font-bold text-indigo-900">Con tiempo límite</h1>
<Suspense fallback={<p>Cargando...</p>}>
<DatosLentos />
</Suspense>
</section>
)
}
// app/lento/error.tsx
'use client'
export default function ErrorLento({ error }: { error: Error & { digest?: string } }) {
return (
<div role="alert" className="rounded border border-red-300 bg-red-50 p-4 text-red-800">
<p className="font-semibold">No se han podido cargar los datos.</p>
<p className="text-sm">Código de error: {error.digest}</p>
</div>
)
}

Figura 10. El tiempo límite se agota y aparece error.tsx. El título de la página también ha desaparecido.
En la terminal de Next queda el error completo, con el mismo digest:
⨯ [Error [TimeoutError]: The operation was aborted due to timeout] {
digest: '4101894172',
code: 23,
...
}
Tres cosas que observar:
error.tsxes un Client Component ('use client'es obligatorio). El error se produjo en mitad del streaming: el HTML que llegó tenía elfallback“Cargando…”, y es React, ya en el navegador, quien muestraerror.tsxen su lugar.- El mensaje real no llega al navegador. En producción, Next sustituye el mensaje de los errores del servidor por un
digest, un código que identifica la entrada en el registro del servidor. Así no se filtran detalles internos (rutas, consultas SQL, URLs internas). Muestra eldigestal usuario para que pueda dártelo cuando te escriba. error.tsxsustituye a todo su segmento, no solo al componente que falló: por eso ha desaparecido el título “Con tiempo límite”. Si quieres que el resto de la página siga visible, captura el error más abajo, como harás en el ejercicio 5. Los límites de error,global-error.tsxy cómo reintentar se ven a fondo en el módulo 14.
⚠️ Importante: Recuerda el apartado 8.1: una petición con
signalno se memoriza. Si ponesAbortSignal.timeouten la funciónpedirde tu capa de datos, perderás la memorización en toda la aplicación. Úsalo solo en las peticiones que lo necesiten, o limita el tiempo de otra forma, sin tocar elfetch(ejercicio 5).
10.4. Datos que no cumplen el tipo
La primera versión de la ficha de departamento tenía este tipo:
export type Departamento = {
deptId: number
nombre: string
localidad: string
presupuesto: number
}
TypeScript compiló sin problemas. Los departamentos del 10 al 60 funcionaban. Pero /departamentos/70 mostraba el error de la aplicación, y en la terminal aparecía:
⨯ TypeError: Cannot read properties of null (reading 'toLocaleString')
La respuesta real de la API:
curl -s http://localhost:3000/api/departamentos/70
{"deptId":70,"nombre":"INVESTIGACIÓN","localidad":"Bilbao","presupuesto":null}
El departamento de investigación no tiene presupuesto asignado, y null.toLocaleString() lanza un error. TypeScript no podía saberlo: los tipos de lib/api.ts los escribiste tú, y res.json() devuelve lo que llegue. La corrección fue doble: el tipo pasó a presupuesto: number | null, y entonces TypeScript obligó a tratar el caso null en el JSX (“Sin presupuesto asignado”).
💡 Consejo: Cuando escribas los tipos de una API, no los copies de un único ejemplo: mira todos los registros, o la documentación de la API, buscando campos que puedan faltar o ser
null. En la API EMPRESA también lo soncomisionyjefeIdde los empleados. Para no depender de tu cuidado, valida las respuestas con Zod, que comprueba en tiempo de ejecución que los datos cumplen el esquema (módulo 11).
11. Caso práctico: el panel de mando de EMPRESA
El panel que has visto en las figuras 3 y 4 reúne todo el módulo: varios bloques independientes, cada uno en su <Suspense>, una petición compartida entre dos bloques y un armazón que sale al instante. Los componentes van en app/panel/componentes.tsx:
import { obtenerEstadisticas, obtenerPedidosRecientes, obtenerProductos } from '@/lib/api'
const euros = new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' })
export async function Tarjetas() {
const e = await obtenerEstadisticas()
const datos = [
['Empleados', e.empleados],
['Clientes', e.clientes],
['Pedidos', e.pedidos],
['Facturación', euros.format(e.facturacion)],
] as const
return (
<div className="grid grid-cols-4 gap-3">
{datos.map(([titulo, valor]) => (
<div key={titulo} className="rounded border bg-white p-4">
<p className="text-xs uppercase text-slate-500">{titulo}</p>
<p className="text-xl font-bold text-indigo-900">{valor}</p>
</div>
))}
</div>
)
}
// Pide la misma URL que Tarjetas: Next la memoriza y solo sale una petición
export async function MasaSalarial() {
const { porDepartamento } = await obtenerEstadisticas()
const maximo = Math.max(...porDepartamento.map((d) => d.masaSalarial))
return (
<ul className="space-y-1 rounded border bg-white p-4 text-sm">
{porDepartamento.map((d) => (
<li key={d.deptId} className="flex items-center gap-2">
<span className="w-28">{d.nombre}</span>
<span
className="h-3 rounded bg-indigo-500"
style={{ width: `${(d.masaSalarial / maximo) * 60}%` }}
/>
<span className="text-slate-500">{euros.format(d.masaSalarial)}</span>
</li>
))}
</ul>
)
}
export async function PedidosRecientes() {
const { contenido: pedidos } = await obtenerPedidosRecientes(5)
return (
<ul className="divide-y rounded border bg-white text-sm">
{pedidos.map((p) => (
<li key={p.pedidoId} className="flex justify-between px-4 py-2">
<span>#{p.pedidoId} · {p.cliente}</span>
<span className="text-slate-500">{p.estado} · {euros.format(p.total)}</span>
</li>
))}
</ul>
)
}
export async function SinStock() {
const { contenido } = await obtenerProductos()
const agotados = contenido.filter((p) => p.stock === 0)
return (
<ul className="rounded border bg-white p-4 text-sm">
{agotados.map((p) => (
<li key={p.productoId}>{p.nombre}</li>
))}
</ul>
)
}
export function Esqueleto({ filas = 3 }: { filas?: number }) {
return (
<div className="animate-pulse space-y-2 rounded border bg-white p-4" aria-busy="true">
{Array.from({ length: filas }, (_, i) => (
<div key={i} className="h-4 rounded bg-slate-200" />
))}
</div>
)
}
Y la página, app/panel/page.tsx, solo coloca las piezas:
import { Suspense } from 'react'
import { Esqueleto, MasaSalarial, PedidosRecientes, SinStock, Tarjetas } from './componentes'
export default function Panel() {
return (
<section className="space-y-6">
{/* Fuera de Suspense: forma parte del armazón estático */}
<h1 className="text-2xl font-bold text-indigo-900">Panel de mando</h1>
<Suspense fallback={<Esqueleto filas={2} />}>
<Tarjetas />
</Suspense>
<div className="grid grid-cols-2 gap-6">
<div>
<h2 className="mb-2 font-semibold">Masa salarial por departamento</h2>
<Suspense fallback={<Esqueleto filas={7} />}>
<MasaSalarial />
</Suspense>
</div>
<div>
<h2 className="mb-2 font-semibold">Pedidos recientes</h2>
<Suspense fallback={<Esqueleto filas={5} />}>
<PedidosRecientes />
</Suspense>
<h2 className="mb-2 mt-6 font-semibold">Productos agotados</h2>
<Suspense fallback={<Esqueleto filas={2} />}>
<SinStock />
</Suspense>
</div>
</div>
</section>
)
}
Repasa las decisiones de diseño:
- Cuatro límites hermanos, no anidados. React ejecuta los cuatro componentes a la vez. Tres URL distintas (
/estadisticas,/pedidos,/productos) salen en paralelo, y el panel completo tarda lo que la más lenta: los bloques llegaron entre los 1035 y los 1047 ms (apartado 4.1). TarjetasyMasaSalarialpiden lo mismo sin pasarse nada por props. Gracias a la memorización, la API recibe una sola petición a/estadisticas.- Los títulos
h2están fuera de los<Suspense>, en el armazón. El usuario sabe desde el primer momento qué va a aparecer y dónde. - Cada esqueleto tiene el número de filas aproximado del contenido real (siete departamentos, cinco pedidos), así la página no salta al rellenarse.
- El formato de moneda se hace en el servidor con
Intl.NumberFormat('es-ES'), sin enviar al navegador ni un byte de lógica. Fíjate en la figura 4: “6500,00 €” sin punto de miles y “15.000,00 €” con él. No es un error: la norma española no agrupa los números de cuatro cifras, eIntlla respeta.
¿Habría sido mejor un único Promise.all en la página? Habría hecho las mismas peticiones en el mismo tiempo, pero el usuario no vería nada del contenido hasta tener los cuatro bloques. Con límites separados, si mañana los pedidos tardan cinco segundos, las tarjetas y la masa salarial seguirán apareciendo al segundo.
💡 Consejo: Agrupa en el mismo
<Suspense>lo que tiene sentido que aparezca junto, y separa lo que no. Muchos bloques que aparecen cada uno en un momento distinto dan sensación de página nerviosa. Una buena regla: un límite por cada zona visual que el usuario identifique como una unidad.
12. Seguridad: lo que se queda en el servidor
Pedir los datos en el servidor no solo es más rápido: también es más seguro, siempre que no rompas la frontera.
- Las URL internas y las claves no se mueven del servidor.
API_URLsinNEXT_PUBLIC_yimport 'server-only'enlib/api.tsgarantizan que ni la variable ni el módulo llegan al navegador. Si un día importaslib/api.tsdesde un Client Component, el build falla en lugar de publicar tu código. - Todo lo que pasas a un Client Component se publica, aunque no lo pintes (apartado 9). Recorta los objetos en el servidor y pasa solo los campos necesarios. En el módulo 22 verás taint, que hace que React se niegue a enviar al cliente un objeto marcado.
- Una petición autenticada desde el servidor lleva el token en una cabecera que el usuario no ve. Con la API EMPRESA, las lecturas son públicas, pero un endpoint protegido se pediría así:
export async function pedirConToken<T>(ruta: string, token: string): Promise<T> {
const res = await fetch(`${API}${ruta}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new ErrorApi(res.status, `Error ${res.status} en ${ruta}`)
return res.json() as Promise<T>
}
De dónde sale el token (la sesión del usuario, guardada en una cookie) es tema del módulo 17.
⚠️ Importante: El Server Component no comprueba permisos por ti. Si una página muestra datos privados, cada acceso a esos datos debe comprobar quién los pide, en la capa de datos. Que un enlace no aparezca en el menú no impide que alguien escriba la URL a mano.
13. Depurar la obtención de datos
Cuando algo no carga o tarda de más, sigue este orden:
- ¿Dónde se ejecuta el código? Los
console.logde un Server Component aparecen en la terminal denext devonext start, no en la consola del navegador. Si buscas en el navegador, no verás nada. - ¿Llega la petición a la API? Pide la misma URL con
curldesde la máquina donde corre Next. Si la API está en otra máquina o contenedor,localhostno es el mismo sitio para el navegador y para el servidor de Next: lo verás al desplegar con Docker en el módulo 23. -
¿Cuántas peticiones salen y cuándo? Activa el registro de
fetchen desarrollo:// next.config.ts (fragmento) const nextConfig: NextConfig = { logging: { fetches: { fullUrl: true }, }, }En la terminal de
next dev, debajo de cada página, aparecen susfetchcon la URL y la duración. Esta es la salida real de/secuencialy/paralelocon la API a un segundo:[secuencial] 3241 ms GET /secuencial 200 in 4.8s (next.js: 535ms, application-code: 4.3s) │ GET http://localhost:3000/api/departamentos 200 in 1030ms (cache skip) │ │ Cache skipped reason: (auto no cache) │ GET http://localhost:3000/api/estadisticas 200 in 1029ms (cache skip) │ │ Cache skipped reason: (auto no cache) │ GET http://localhost:3000/api/pedidos?orden=-fechaPedido&tamano=5 200 in 1032ms (cache skip) │ │ Cache skipped reason: (auto no cache) [paralelo] 1158 ms GET /paralelo 200 in 1653ms (next.js: 33ms, application-code: 1620ms) │ GET http://localhost:3000/api/pedidos?orden=-fechaPedido&tamano=5 200 in 1036ms (cache skip) │ │ Cache skipped reason: (auto no cache) │ GET http://localhost:3000/api/departamentos 200 in 1124ms (cache skip) │ │ Cache skipped reason: (auto no cache) │ GET http://localhost:3000/api/estadisticas 200 in 1141ms (cache skip) │ │ Cache skipped reason: (auto no cache)La pista está en comparar: en
/secuencialel código de la aplicación tardó 4,3 s con tres peticiones de un segundo (la suma), y en/paralelo, 1,6 s (la más lenta más el trabajo de desarrollo). En/paralelolas peticiones aparecen además en el orden en que terminaron, no en el del código.cache skipyauto no cachete dicen que no hay caché persistente: es lo esperado sin'use cache'(módulo 09). Los tiempos en desarrollo son algo mayores que en producción. Si ves la misma URL varias veces en una página, mira si llevasignalu opciones distintas. - ¿El código de estado es el que esperas?
curl -s -o /dev/null -w "%{http_code}\n" URL. Un200connoindexes unnotFound()dentro del streaming. - ¿El streaming funciona?
node observar.mjs URL. Si todos los trozos llegan a la vez al final, algo intermedio está acumulando la respuesta. - ¿Falla solo en el build? Lee el nombre de la ruta en el error y busca qué dato de esa página está fuera de un
<Suspense>.next build --debug-prerenderda pilas de llamadas más detalladas, ynext devmuestra el mismo error en el navegador con la línea exacta.
💡 Consejo: La pestaña Red de las herramientas del navegador no muestra las peticiones a la API: no las hace el navegador. Muestra la petición del documento HTML. Selecciónala y abre Timing: un Waiting for server response (TTFB) corto y un Content Download largo son la firma del streaming.
14. Errores frecuentes
| Síntoma | Causa | Solución |
|---|---|---|
next build falla con uncached or runtime data during prerendering | Un fetch, params, searchParams o cookies() se espera fuera de cualquier <Suspense> | Envuelve el componente en <Suspense>, añade loading.tsx, o pasa la promesa hacia abajo (apartados 3.1 y 7) |
| En Next.js 15 la página muestra siempre los mismos datos | Se prerenderizó en el build (○ en la tabla de rutas) | await connection() o export const dynamic = 'force-dynamic' |
| La página tarda la suma de todas las peticiones | await en fila de peticiones independientes | Promise.all, o componentes hermanos con su propio <Suspense> |
| Un componente hijo tarda el doble | Cascada entre padre e hijo: el hijo no empieza hasta que el padre termina | Sube la petición al padre antes del await, precarga o haz los componentes hermanos |
| Un error en un dato tumba todo el bloque | Promise.all rechaza en cuanto falla una | Promise.allSettled y tratar cada resultado |
| La API recibe la misma petición varias veces por página | El fetch lleva signal, opciones distintas o no es GET; o no es un fetch | Quita el signal del caso general; usa cache de React para lo demás |
notFound() devuelve 200 | El streaming ya había enviado el código | Es lo esperado (con noindex). Para un 404 real, decide en el proxy o en el prerender |
| Un 404 de la API aparece como error del servidor | res.ok no se comprueba, o dos componentes piden el recurso inexistente a la vez | Comprueba res.ok en la capa de datos; comprueba la existencia una vez y precarga lo demás |
Cannot read properties of null | El tipo dice number y la API devuelve null | Ajusta el tipo y trata el caso; valida con Zod |
| El título de la página desaparece con el error | error.tsx sustituye a todo su segmento | Captura el error más abajo o acepta el reemplazo; ver módulo 14 |
console.log no aparece | Lo buscas en el navegador | Mira la terminal de Next |
Bucle infinito al usar use(fetch(...)) en un Client Component | Se crea una promesa nueva en cada render | Crea la promesa en el servidor y pásala como prop |
Date.now() o performance.now() dan error en el prerender | Leer el reloj fuera de la petición congelaría el valor | Léelo después de await connection() o de otro dato de la petición |
15. Ejercicios
Para todos los ejercicios, ten la API EMPRESA en marcha con RETARDO=1000 y la capa lib/api.ts del apartado 2.3. Todas las soluciones se han compilado con next build y probado con next start en Next.js 16.4.
Ejercicio 1: Lista de clientes
Crea /ej/clientes con la lista de clientes de la API (/api/clientes), ordenada por nombre, mostrando el nombre (enlazado a /ej/clientes/[id]) y la ciudad, y el total de clientes encima. La página debe compilar con cacheComponents y mostrar el título al instante.
Ver solución
// app/ej/clientes/page.tsx
import { Suspense } from 'react'
import Link from 'next/link'
import { pedir, type Pagina } from '@/lib/api'
type Cliente = { clienteId: number; nombre: string; ciudad: string; limiteCredito: number }
async function ListaClientes() {
const { contenido: clientes, total } = await pedir<Pagina<Cliente>>('/clientes?orden=nombre&tamano=100')
return (
<>
<p className="text-sm text-slate-500">{total} clientes</p>
<ul className="divide-y rounded border bg-white">
{clientes.map((c) => (
<li key={c.clienteId} className="flex justify-between px-4 py-2">
<Link href={`/ej/clientes/${c.clienteId}`} className="text-indigo-700 hover:underline">
{c.nombre}
</Link>
<span className="text-slate-500">{c.ciudad}</span>
</li>
))}
</ul>
</>
)
}
export default function PaginaClientes() {
return (
<section className="space-y-4">
<h1 className="text-2xl font-bold text-indigo-900">Clientes</h1>
<Suspense fallback={<p>Cargando clientes...</p>}>
<ListaClientes />
</Suspense>
</section>
)
}
La página sale como ◐ /ej/clientes. El total (14) va dentro del <Suspense> porque depende de la API; el h1, fuera. tamano=100 evita quedarte con la primera página de resultados: la API pagina por defecto. La función pedir es genérica, así que no hace falta añadir nada a lib/api.ts para un recurso nuevo; si lo usaras en más sitios, merecería su propia función obtenerClientes().
Ejercicio 2: Ficha de cliente sin cascada
Crea /ej/clientes/[id] con el nombre y la ciudad del cliente (/api/clientes/:id), el número de pedidos y el importe total de sus pedidos (/api/clientes/:id/pedidos). Las dos peticiones solo dependen del id: haz que salgan a la vez y muestra en la página cuánto han tardado.
Ver solución
// app/ej/clientes/[id]/page.tsx
import { Suspense } from 'react'
import { connection } from 'next/server'
import { pedir, type Pagina, type Pedido } from '@/lib/api'
type Cliente = { clienteId: number; nombre: string; ciudad: string; limiteCredito: number }
async function FichaCliente({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
await connection()
const inicio = performance.now()
// Las dos peticiones solo dependen del id: salen a la vez
const [cliente, pedidos] = await Promise.all([
pedir<Cliente>(`/clientes/${id}`),
pedir<Pagina<Pedido>>(`/clientes/${id}/pedidos`),
])
const ms = Math.round(performance.now() - inicio)
console.log(`[ej2] cliente ${id} en ${ms} ms`)
const total = pedidos.contenido.reduce((suma, p) => suma + p.total, 0)
return (
<article className="space-y-2 rounded border bg-white p-6">
<h1 className="text-2xl font-bold text-indigo-900">{cliente.nombre}</h1>
<p>{cliente.ciudad} · {pedidos.total} pedidos · {total.toFixed(2)} €</p>
<p className="text-xs text-slate-500">Datos obtenidos en {ms} ms</p>
</article>
)
}
export default function PaginaCliente({ params }: PageProps<'/ej/clientes/[id]'>) {
return (
<Suspense fallback={<p>Cargando cliente...</p>}>
<FichaCliente params={params} />
</Suspense>
)
}
Para /ej/clientes/1001, la página muestra “Valencia · 3 pedidos · 4704.15 €” y la terminal:
[ej2] cliente 1001 en 1026 ms
Un segundo, no dos. Fíjate en que la página no espera params: lo pasa a FichaCliente, que lo resuelve dentro del <Suspense> (apartado 7). El await connection() solo hace falta para poder medir con performance.now(); sin la medición, puedes quitarlo.
Ejercicio 3: Catálogo con loading.tsx
Crea /ej/productos como una página async que espera directamente la lista de productos y la muestra en una cuadrícula de tres columnas con nombre, precio en euros y stock. No uses <Suspense> en la página: el estado de carga debe venir de un loading.tsx con un esqueleto de la misma forma que la cuadrícula.
Ver solución
// app/ej/productos/page.tsx
import { obtenerProductos } from '@/lib/api'
const euros = new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' })
export default async function PaginaProductos() {
const { contenido } = await obtenerProductos()
return (
<section className="space-y-4">
<h1 className="text-2xl font-bold text-indigo-900">Productos</h1>
<div className="grid grid-cols-3 gap-3">
{contenido.map((p) => (
<div key={p.productoId} className="rounded border bg-white p-3">
<p className="font-semibold">{p.nombre}</p>
<p className="text-sm text-slate-500">{euros.format(p.precio)} · stock {p.stock}</p>
</div>
))}
</div>
</section>
)
}
// app/ej/productos/loading.tsx
export default function CargandoProductos() {
return (
<section className="animate-pulse space-y-4" aria-busy="true">
<div className="h-8 w-40 rounded bg-slate-200" />
<div className="grid grid-cols-3 gap-3">
{Array.from({ length: 9 }, (_, i) => (
<div key={i} className="h-16 rounded border bg-white p-3">
<div className="mb-2 h-4 w-3/4 rounded bg-slate-200" />
<div className="h-3 w-1/2 rounded bg-slate-200" />
</div>
))}
</div>
</section>
)
}
El build acepta la página (◐ /ej/productos) porque loading.tsx actúa como <Suspense> de toda la carpeta. El precio del esqueleto: el h1 “Productos” también espera a la API, porque está dentro de la página. Si quisieras el título al instante, tendrías que moverlo fuera de la parte asíncrona con un <Suspense> propio, que es justo lo que loading.tsx no permite.
Ejercicio 4: Ficha con jefe
Crea /ej/jefe/[id] con la ficha de un empleado (nombre, oficio, departamento) y, debajo, su jefe (nombre, apellidos y oficio). El jefe solo se conoce después de cargar el empleado (jefeId). Haz que la ficha del empleado aparezca sin esperar al jefe, y trata el caso de Ana García (100), que no tiene jefe.
Ver solución
// app/ej/jefe/[id]/page.tsx
import { Suspense } from 'react'
import { obtenerEmpleado } from '@/lib/api'
// Depende del jefeId del empleado: esta cascada es inevitable,
// pero con su propio Suspense no retrasa el resto de la ficha
async function Jefe({ jefeId }: { jefeId: number }) {
const jefe = await obtenerEmpleado(jefeId)
return <p>Jefe: {jefe.nombre} {jefe.apellidos} ({jefe.oficio})</p>
}
async function Ficha({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const e = await obtenerEmpleado(Number(id))
return (
<article className="space-y-2 rounded border bg-white p-6">
<h1 className="text-2xl font-bold text-indigo-900">{e.nombre} {e.apellidos}</h1>
<p>{e.oficio} en {e.departamento}</p>
{e.jefeId === null ? (
<p>No tiene jefe: dirige la empresa.</p>
) : (
<Suspense fallback={<p>Cargando jefe...</p>}>
<Jefe jefeId={e.jefeId} />
</Suspense>
)}
</article>
)
}
export default function PaginaJefe({ params }: PageProps<'/ej/jefe/[id]'>) {
return (
<Suspense fallback={<p>Cargando empleado...</p>}>
<Ficha params={params} />
</Suspense>
)
}
Resultados reales:
/ej/jefe/120 → ANALISTA en INFORMÁTICA
Jefe: Marta Sánchez Gil (DIRECTOR)
/ej/jefe/100 → No tiene jefe: dirige la empresa.
La comprobación e.jefeId === null además estrecha el tipo: dentro de la rama else, TypeScript sabe que jefeId es number y no hace falta el ! que usaba el ejemplo del apartado 6.5. El <Suspense> interior es el que hace que la ficha aparezca al primer segundo y el jefe al segundo.
Ejercicio 5: Dato opcional con tiempo límite
En /ej/limite, muestra el número de pedidos recientes (imprescindible) y la facturación total (opcional). Si la facturación tarda más de 300 ms o falla, la página debe mostrar “no disponible ahora mismo” en lugar de un error. No uses AbortSignal, para no perder la memorización de fetch. Escribe una función reutilizable conLimite(promesa, ms, reserva).
Ver solución
// lib/con-limite.ts
// Espera a la promesa, pero como mucho `ms` milisegundos.
// Si tarda más, devuelve el valor de reserva. No usa AbortSignal,
// así que la petición sigue memorizada por Next.
export async function conLimite<T>(promesa: Promise<T>, ms: number, reserva: T): Promise<T> {
let temporizador: ReturnType<typeof setTimeout> | undefined
const limite = new Promise<T>((resolver) => {
temporizador = setTimeout(() => resolver(reserva), ms)
})
try {
return await Promise.race([promesa.catch(() => reserva), limite])
} finally {
clearTimeout(temporizador)
}
}
// app/ej/limite/page.tsx
import { Suspense } from 'react'
import { obtenerEstadisticas, obtenerPedidosRecientes } from '@/lib/api'
import { conLimite } from '@/lib/con-limite'
async function Resumen() {
// Pedidos es imprescindible; las estadísticas, opcionales y con 300 ms de margen
const [pedidos, estadisticas] = await Promise.all([
obtenerPedidosRecientes(3),
conLimite(obtenerEstadisticas().then((e) => e.facturacion), 300, null),
])
return (
<div className="space-y-2 rounded border bg-white p-4">
<p>{pedidos.contenido.length} pedidos recientes</p>
<p>Facturación: {estadisticas === null ? 'no disponible ahora mismo' : `${estadisticas} €`}</p>
</div>
)
}
export default function PaginaLimite() {
return (
<Suspense fallback={<p>Cargando...</p>}>
<Resumen />
</Suspense>
)
}
Con la API a 1000 ms de retardo, el resultado es:
3 pedidos recientes
Facturación: no disponible ahora mismo
Promise.race se queda con la primera promesa que termine: la petición o el temporizador. El .catch(() => reserva) convierte también los errores en el valor de reserva. El clearTimeout del finally evita dejar temporizadores vivos cuando la petición gana. Ten en cuenta la diferencia con AbortSignal.timeout: aquí la petición no se cancela, sigue en curso y su respuesta se descarta. Es el precio de conservar la memorización.
Ejercicio 6: Mapa de categorías compartido
La API devuelve las categorías como una lista plana con categoriaPadreId. Escribe en lib/categorias.ts una función obtenerRutasCategorias() que construya un Map de categoriaId a un texto como “Informática › Portátiles”. Úsala desde dos componentes de /ej/categorias: uno que muestre cuántas categorías hay y otro con los cuatro productos más caros y su ruta de categoría. El mapa debe construirse una sola vez por render.
Ver solución
// lib/categorias.ts
import 'server-only'
import { cache } from 'react'
import { pedir, type Pagina } from './api'
type Categoria = { categoriaId: number; nombre: string; categoriaPadreId: number | null }
// Construye un mapa id -> "Padre › Hija" una sola vez por petición
export const obtenerRutasCategorias = cache(async () => {
console.log('[categorias] construyendo el mapa')
const { contenido } = await pedir<Pagina<Categoria>>('/categorias?tamano=100')
const porId = new Map(contenido.map((c) => [c.categoriaId, c]))
const rutas = new Map<number, string>()
for (const c of contenido) {
const padre = c.categoriaPadreId === null ? undefined : porId.get(c.categoriaPadreId)
rutas.set(c.categoriaId, padre ? `${padre.nombre} › ${c.nombre}` : c.nombre)
}
return rutas
})
// app/ej/categorias/page.tsx
import { Suspense } from 'react'
import { pedir, type Pagina } from '@/lib/api'
import { obtenerRutasCategorias } from '@/lib/categorias'
type Producto = { productoId: number; nombre: string; categoriaId: number }
async function Destacados() {
const [rutas, { contenido }] = await Promise.all([
obtenerRutasCategorias(),
pedir<Pagina<Producto>>('/productos?tamano=4&orden=-precio'),
])
return (
<ul className="list-disc pl-6">
{contenido.map((p) => (
<li key={p.productoId}>{p.nombre} · {rutas.get(p.categoriaId)}</li>
))}
</ul>
)
}
async function NumeroCategorias() {
const rutas = await obtenerRutasCategorias()
return <p>{rutas.size} categorías en el catálogo</p>
}
export default function PaginaCategorias() {
return (
<section className="space-y-4">
<h1 className="text-2xl font-bold text-indigo-900">Catálogo</h1>
<Suspense fallback={<p>Cargando...</p>}>
<NumeroCategorias />
<Destacados />
</Suspense>
</section>
)
}
Resultado:
11 categorías en el catálogo
Torre Workstation · Informática › Ordenadores sobremesa
Portátil Gaming 16 · Informática › Portátiles
Smartphone Gama Alta · Electrónica › Telefonía
Portátil UltraBook 14 · Informática › Portátiles
El mensaje “construyendo el mapa” apareció 2 veces en la terminal por petición (una por cada pasada del árbol, como en el apartado 8.2), no 4, y la API recibió una petición a /categorias. Sin cache, la memorización de fetch seguiría evitando la segunda petición, pero el bucle que construye el mapa se ejecutaría en cada componente. cache memoriza el resultado elaborado, no solo la respuesta HTTP.
Ejercicio 7: Pestañas de pedidos por estado
Crea /ej/estados con pestañas para cada estado de pedido (PENDIENTE, ENVIADO…), cada una con su número de pedidos, y la lista de pedidos del estado activo. Cambiar de pestaña no debe hacer ninguna petición. Pide los datos en el servidor, pasa una promesa al componente de pestañas, y envía solo los campos que se muestran.
Ver solución
// app/ej/estados/page.tsx
import { Suspense } from 'react'
import { pedir, type Pagina, type Pedido } from '@/lib/api'
import { PestanasEstado } from './pestanas'
export default function PaginaEstados() {
// Sin await: la promesa viaja al Client Component
const pedidos = pedir<Pagina<Pedido>>('/pedidos?tamano=100&orden=-fechaPedido').then((p) =>
p.contenido.map(({ pedidoId, cliente, estado, total }) => ({ pedidoId, cliente, estado, total })),
)
return (
<section className="space-y-4">
<h1 className="text-2xl font-bold text-indigo-900">Pedidos por estado</h1>
<Suspense fallback={<p>Cargando pedidos...</p>}>
<PestanasEstado pedidos={pedidos} />
</Suspense>
</section>
)
}
// app/ej/estados/pestanas.tsx
'use client'
import { use, useState } from 'react'
type Resumen = { pedidoId: number; cliente: string; estado: string; total: number }
export function PestanasEstado({ pedidos }: { pedidos: Promise<Resumen[]> }) {
const lista = use(pedidos)
const estados = [...new Set(lista.map((p) => p.estado))].sort()
const [activo, setActivo] = useState(estados[0])
const visibles = lista.filter((p) => p.estado === activo)
return (
<div className="space-y-3">
<div role="tablist" className="flex gap-2">
{estados.map((e) => (
<button
key={e}
role="tab"
aria-selected={e === activo}
onClick={() => setActivo(e)}
className={`rounded px-3 py-1 text-sm ${e === activo ? 'bg-indigo-700 text-white' : 'bg-slate-200'}`}
>
{e} ({lista.filter((p) => p.estado === e).length})
</button>
))}
</div>
<ul className="divide-y rounded border bg-white text-sm">
{visibles.map((p) => (
<li key={p.pedidoId} className="flex justify-between px-4 py-2">
<span>#{p.pedidoId} · {p.cliente}</span>
<span>{p.total.toFixed(2)} €</span>
</li>
))}
</ul>
</div>
)
}
Las pestañas generadas con los datos de la API:
CANCELADO (1) ENTREGADO (18) ENVIADO (2) PENDIENTE (3)
El .then del servidor descarta las líneas de cada pedido, la fecha, el clienteId y el empleadoId: con 24 pedidos y varias líneas cada uno, es buena parte de la carga útil que no viaja. Los estados no están escritos a mano: salen de los datos, así que si mañana aparece un estado nuevo, tendrá su pestaña. Fíjate en que useState(estados[0]) funciona porque use ya ha resuelto la promesa cuando se ejecuta esa línea.
Ejercicio 8: Comprobar códigos de estado
Con la ficha de departamento del apartado 7 y su not-found.tsx, comprueba con curl el código de estado y la etiqueta robots de /departamentos/30, /departamentos/99 y /departamentos/abc. Explica el resultado y di qué tendrías que cambiar para que /departamentos/99 respondiera 404.
Ver solución
for r in 30 99 abc; do
curl -s -o r.html -w "/departamentos/$r %{http_code} " http://localhost:3001/departamentos/$r
grep -o '<meta name="robots"[^>]*>' r.html || echo "(sin robots)"
done
/departamentos/30 200 (sin robots)
/departamentos/99 200 <meta name="robots" content="noindex"/>
/departamentos/abc 200 <meta name="robots" content="noindex"/>
Las tres responden 200. En las dos últimas, notFound() se llamó dentro del <Suspense>, cuando el armazón (y con él la línea HTTP/1.1 200 OK) ya había salido. Next compensa con noindex, y el usuario ve el not-found.tsx de la figura 9. /departamentos/abc ni siquiera llega a la API: Number.isInteger(NaN) es false y notFound() se llama antes.
Para un 404 de verdad, la decisión tiene que tomarse antes de empezar a responder:
- Un
proxy.tsque consulte si el departamento existe y responda 404 (módulo 13). Tiene un coste: una petición más a la API en cada visita. - Prerenderizar las fichas en el build con
generateStaticParamsy caché, de forma que unidinexistente se conozca de antemano (módulo 09).
Para una aplicación interna de gestión como esta, el 200 con noindex es perfectamente aceptable.
Ejercicio 9 (propuesto): Paginación con searchParams
Convierte /empleados en una lista paginada de 5 en 5 con enlaces “Anterior” y “Siguiente” que cambian ?pagina= en la URL. Lee searchParams como promesa y espéralo dentro del <Suspense> (no en la página), pasa pagina y tamano a la API, y desactiva los enlaces en la primera y en la última página usando paginas de la respuesta. Comprueba con next build que la página sigue saliendo como ◐.
Ejercicio 10 (propuesto): Cazar cascadas con el registro de fetch
Activa logging.fetches.fullUrl en next.config.ts, arranca next dev y visita /secuencial, /paralelo y /anidado. Compara en la terminal la duración de cada fetch con el tiempo total de application-code de cada página y explica, con esos datos, cuál de las tres páginas tiene cascada y por qué. Después, reescribe /anidado para que tarde un segundo sin cambiar lo que muestra.
Ejercicio 11 (propuesto): El panel en Next.js 15
Crea un proyecto con npx create-next-app@15 --yes y copia en él lib/api.ts y el panel del apartado 11. Compila con next build y mira la tabla de rutas: ¿qué símbolo tiene /panel? Observa la respuesta con observar.mjs. Después, quita los cuatro <Suspense> y vuelve a compilar: ¿qué ha cambiado en la tabla y qué datos muestra ahora el panel tras crear un pedido nuevo en la API? Arréglalo con connection().
Resumen
| Concepto | API | Idea clave |
|---|---|---|
| Componente asíncrono | async function Componente() + await | Pide los datos en el servidor; su código no llega al navegador |
| Capa de datos | lib/api.ts con import 'server-only' | Un único sitio para la URL, los tipos y los errores |
| Comprobar la respuesta | if (!res.ok) throw ... | fetch no lanza con 404 ni 500 |
| Datos sin caché | <Suspense fallback={...}> | Obligatorio con cacheComponents 🔄 16.0; en Next.js 15, cuidado con las páginas congeladas |
| Datos frescos en Next.js 15 | await connection() | Hace la página dinámica (ƒ) |
| Página que bloquea | export const instant = false 🆕 16.3 | Sin armazón: el usuario espera entero |
| Streaming | <Suspense> | Armazón al instante y cada bloque cuando llega, en una sola respuesta |
| Carga de toda la página | loading.tsx | Un <Suspense> automático para la página y sus subrutas |
| Peticiones independientes | Promise.all([...]) | El tiempo es el de la más lenta, no la suma |
| Fallos parciales | Promise.allSettled([...]) | Cada resultado es fulfilled o rejected |
| Cascadas entre componentes | Hermanos con su <Suspense> | Un hijo no empieza hasta que su padre termina |
| Precarga | Llamar sin await + memorización | Empezar la petición antes de necesitarla |
| Datos de la petición | Pasar params como promesa | Esperar dentro del <Suspense>, lo más abajo posible |
| Memorización | fetch GET con la misma URL y opciones | Una petición por render; signal la desactiva |
| Funciones propias | cache(fn) de react | Memoriza por argumentos durante la petición |
| Promesas al cliente | Prop Promise<T> + use(promesa) | El servidor pide, el cliente lee; recorta los datos antes |
| No encontrado | notFound() + not-found.tsx | Dentro del streaming: 200 con noindex |
| Errores | error.tsx ('use client') | Sustituye al segmento; el navegador solo ve el digest |
| Tiempo límite | AbortSignal.timeout(ms) o Promise.race | El primero cancela pero no memoriza; el segundo al revés |
| Tipos de la API | campo: T \| null | Los tipos no se comprueban en ejecución: valida con Zod |
| Depuración | logging.fetches, observar.mjs, curl -w | Los console.log del servidor salen en la terminal |