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 useEffect de una aplicación React con Vite
  • Escribir componentes asíncronos que hacen fetch a 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.tsx y <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 fetch y la función cache de 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 build y ejecutado con next start en Next.js 16.4.3, contra la API EMPRESA arrancada con RETARDO=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.

Comparación entre una SPA, donde el navegador pide los datos a la API, y Next.js, donde los pide el servidor

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 async no 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:

  • fetch a 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=1000 durante 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:

  1. 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.
  2. fetch no 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 eso pedir comprueba res.ok y lanza su propio ErrorApi con 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.
  3. 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.
  4. Cada función devuelve una promesa sin esperar. obtenerDepartamentos() no lleva await dentro: devuelve la promesa de pedir. 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 eso obtenerDepartamentos devuelve Pagina<Departamento> y el componente desestructura contenido. 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:

  • PaginaDepartamentos no es asíncrona y no espera nada. Su título se puede generar sin la API.
  • ListaDepartamentos sí lo es. Espera a la API y está envuelta en <Suspense>. Mientras no termina, en su lugar se muestra el fallback.

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 hace fetch sin <Suspense> compila como estática: los datos se piden una vez en el build y se quedan congelados. Si usas Next.js 15: añade await connection() (de next/server) antes de pedir los datos, o export 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 de fetch, cache de React y use) 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, con await 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 fetch dentro 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:0 a S:3 y 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 200 salió 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.

Esquema del streaming: un primer trozo con el armazón y los esqueletos, trozos posteriores con el HTML de cada componente, y el navegador sustituyendo cada esqueleto

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.

Panel de mando con el título y los esqueletos grises de cada bloque, antes de que lleguen los datos

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

Panel de mando completo con tarjetas de totales, masa salarial por departamento, pedidos recientes y productos agotados

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 script observar.mjs es más fiable que curl, 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:

Página de empleados mostrando el esqueleto de loading.tsx: una barra de título gris y ocho filas grises bajo el menú

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.tsx envuelve a page.tsx, pero no al layout.tsx de su misma carpeta: el layout queda por encima. Si un layout espera datos sin caché, el loading.tsx de al lado no lo cubre. Con cacheComponents el 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.

Diagrama de tiempos: en cascada, tres barras de un segundo una detrás de otra suman 3182 ms; en paralelo, las tres barras empiezan a la vez y terminan a los 1065 ms

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 pEstadisticas falla mientras esperas a pDepartamentos, Node ve un rechazo que nadie atiende todavía y puede avisar con unhandled rejection. Promise.all no 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:

  1. Hermanos en lugar de padre e hijo. Si Resumen y Pedidos está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.
  2. 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.tsx y el page.tsx empiezan 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>
  )
}

Ficha del departamento INFORMÁTICA de Valencia con presupuesto de 420.000 euros y la lista de sus seis empleados con su oficio

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:

  • PaginaDepartamento no es async. Recibe params y lo pasa sin tocarlo.
  • buscarDepartamento convierte el 404 de la API en null y 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/99 los dos pedían a la API, los dos recibían 404, y mientras la cabecera llamaba a notFound(), la plantilla lanzaba un ErrorApi que 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() y headers(): 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 llevan signal. 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/30 y su plantilla, una vez cada una, aunque las cuatro Equipo llaman a funciones que las piden. Por debajo hay fetch idé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 con POST. Si tu API exige POST para consultas, o usas una base de datos, necesitas cache de 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 cache se calcula una vez, aunque la usan dos componentes.
  • La versión sin cache se calcula tres veces: dos por los componentes Equipo que la llaman directamente, y una más desde dentro de resumenEquipo, 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.log para contar llamadas a la API. Mira el otro lado: el registro del servidor de la API o la opción logging.fetches de next.config.ts, que en desarrollo escribe en la terminal cada fetch del servidor (con fullUrl: true, la URL completa).

Reglas para usar cache bien:

  1. 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.
  2. 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.
  3. Dura una petición. Cada visitante tiene su propia memoria; no hay riesgo de mezclar datos de dos usuarios, pero tampoco ahorro entre visitas.
  4. Solo en el servidor. cache está 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>
  )
}

Buscador de empleados con el texto gar escrito, que muestra 1 de 24 y la fila García López, Ana, DIRECTOR GENERAL

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 .then que 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.
  • use suspende el componente hasta que la promesa se cumple, y el <Suspense> más cercano muestra su fallback. Si se rechaza, salta al error.tsx más cercano.

📘 Concepto: ¿Por qué no await en 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 hook use se 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 lee use en 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>
  )
}

Aviso amarillo con el texto Ese departamento no existe y un enlace para ver todos los departamentos, bajo el menú de la aplicación

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 generateStaticParams y sus datos se guardan en caché, un notFound() durante el prerender sí produce un 404 (módulo 09).

⚠️ Importante: Se probó también export const instant = false en 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/999 respondió 200 con noindex, igual que las demás. En Next.js 16.4 con cacheComponents, no cuentes con instant = false para 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 un try/catch propio: es el mismo problema que con redirect() en el módulo 03. En buscarDepartamento el try solo rodea la llamada a la API, y notFound() 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>
  )
}

Recuadro rojo con el texto No se han podido cargar los datos y Código de error 4101894172, bajo el menú

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:

  1. error.tsx es un Client Component ('use client' es obligatorio). El error se produjo en mitad del streaming: el HTML que llegó tenía el fallback “Cargando…”, y es React, ya en el navegador, quien muestra error.tsx en su lugar.
  2. 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 el digest al usuario para que pueda dártelo cuando te escriba.
  3. error.tsx sustituye 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.tsx y cómo reintentar se ven a fondo en el módulo 14.

⚠️ Importante: Recuerda el apartado 8.1: una petición con signal no se memoriza. Si pones AbortSignal.timeout en la función pedir de 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 el fetch (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 son comision y jefeId de 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).
  • Tarjetas y MasaSalarial piden lo mismo sin pasarse nada por props. Gracias a la memorización, la API recibe una sola petición a /estadisticas.
  • Los títulos h2 está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, e Intl la 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_URL sin NEXT_PUBLIC_ y import 'server-only' en lib/api.ts garantizan que ni la variable ni el módulo llegan al navegador. Si un día importas lib/api.ts desde 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:

  1. ¿Dónde se ejecuta el código? Los console.log de un Server Component aparecen en la terminal de next dev o next start, no en la consola del navegador. Si buscas en el navegador, no verás nada.
  2. ¿Llega la petición a la API? Pide la misma URL con curl desde la máquina donde corre Next. Si la API está en otra máquina o contenedor, localhost no 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.
  3. ¿Cuántas peticiones salen y cuándo? Activa el registro de fetch en desarrollo:

    // next.config.ts (fragmento)
    const nextConfig: NextConfig = {
      logging: {
        fetches: { fullUrl: true },
      },
    }
    

    En la terminal de next dev, debajo de cada página, aparecen sus fetch con la URL y la duración. Esta es la salida real de /secuencial y /paralelo con 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 /secuencial el 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 /paralelo las peticiones aparecen además en el orden en que terminaron, no en el del código. cache skip y auto no cache te 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 lleva signal u opciones distintas.

  4. ¿El código de estado es el que esperas? curl -s -o /dev/null -w "%{http_code}\n" URL. Un 200 con noindex es un notFound() dentro del streaming.
  5. ¿El streaming funciona? node observar.mjs URL. Si todos los trozos llegan a la vez al final, algo intermedio está acumulando la respuesta.
  6. ¿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-prerender da pilas de llamadas más detalladas, y next dev muestra 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.ts que 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 generateStaticParams y caché, de forma que un id inexistente 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