Módulo 02: Modelos de renderizado
Objetivos del módulo
- Responder para cada página a la pregunta que lo ordena todo: ¿cuándo y dónde se genera el HTML?
- Distinguir CSR, SSG, ISR, SSR, streaming y Partial Prerendering (PPR), y saber qué gana y qué paga cada uno
- Saber qué decide Next.js por ti según las APIs que usa tu código, sin ninguna opción de “modo de renderizado”
- Comprobarlo con tus propios ojos: leer el HTML que llega, las cabeceras de la respuesta, los trozos del streaming y las peticiones de red
- Entender que los React Server Components no son “otro modo más”, sino una pieza que se combina con todos los demás
- Leer la tabla de rutas de
next buildy saber qué significan○,◐,ƒy● - Interpretar el error de prerender de Next.js 16 y elegir entre sus tres salidas: stream, cache o block
- Saber qué tienes que escribir distinto si tu proyecto sigue en Next.js 15
🔄 Cambia en Next.js 16.0. Con
cacheComponents(activado porcreate-next-app16.4), PPR es el único modelo de las páginas del App Router: cada ruta se clasifica como○,◐oƒ, y el build falla si una página no puede producir un armazón estático sin decirlo. En Next.js 15 cada ruta es estática o dinámica entera, y PPR solo existe en las versiones canary. Si usas Next.js 15: los apartados 3 a 7 te sirven con el código alternativo que se da en cada uno; en lugar de PPR usarás SSR con<Suspense>(apartado 8), y en la tabla de rutas verás○,●yƒ.
1. La pregunta que lo ordena todo
Cuando escribes una aplicación con React y Vite, el servidor entrega un HTML casi vacío y un fichero JavaScript, y es el navegador quien construye la página. Eso es solo una de las formas posibles de llevar una página al usuario. Next.js existe, en gran parte, para darte las demás.
Todas se distinguen por la respuesta a una sola pregunta: ¿cuándo y dónde se genera el HTML con los datos? Hay tres momentos posibles:
- Al compilar (
next build): una vez, antes de que nadie visite la página. - En el servidor, cuando llega la petición: una vez por visita.
- En el navegador: el HTML llega vacío y lo construye JavaScript.
Figura 1. Lo que distingue a cada modelo es en qué columna se hace el trabajo de generar el HTML con datos.
| Sigla | Nombre | Cuándo se genera el HTML con datos | Lo que gana | Lo que paga |
|---|---|---|---|---|
| CSR | Client-Side Rendering | En el navegador | Servidor trivial; interfaz muy interactiva | Pantalla vacía al principio; los buscadores y los usuarios sin JS no ven nada; cadena HTML → JS → datos |
| SSG | Static Site Generation | Al compilar | Respuesta inmediata, se puede servir desde una CDN | Los datos envejecen hasta el siguiente build |
| ISR | Incremental Static Regeneration | Al compilar, y otra vez cuando caduca | Lo rápido de SSG con datos que se renuevan solos | Algún visitante ve la versión anterior |
| SSR | Server-Side Rendering | En el servidor, en cada petición | Datos siempre actuales y propios de cada usuario | Cada visita cuesta CPU; el usuario espera a la consulta más lenta |
| Streaming | SSR por partes | En el servidor, en cada petición, por trozos | Lo rápido llega antes; lo lento no bloquea | Hay que decidir qué mostrar mientras tanto |
| PPR | Partial Prerendering | Una parte al compilar y el resto en cada petición | Armazón inmediato y datos actuales en la misma página | Más conceptos; necesita un servidor que sepa “reanudar” |
Tres ideas que conviene fijar desde ya:
- No son exclusivos. Una misma aplicación tendrá una portada estática, un catálogo que se regenera cada hora y un panel privado que se genera en cada visita. Con PPR, esa mezcla ocurre incluso dentro de una sola página.
- En el App Router no eliges un “modo” con una opción. Next.js deduce el modelo de cada ruta a partir de lo que hace tu código: si lee cookies, si guarda datos en caché, si envuelve algo en
<Suspense>… Por eso este módulo te enseña a observar el resultado. - Siempre hay hidratación. Salvo que la página no tenga nada interactivo, el navegador acaba recibiendo JavaScript para enganchar los eventos al HTML que ya existe. Lo que cambia es cuánto HTML útil llega antes de que eso pase. La hidratación y sus errores típicos se explican con detalle en el módulo 23 del Curso de React.
📘 Concepto: Prerenderizar es renderizar antes de que llegue la petición. El resultado se guarda y se entrega igual a todo el mundo. Por eso un prerender no puede depender de nada propio de una persona concreta, como sus cookies o la URL exacta que ha pedido. Cuando tu código necesita algo así, esa parte se tiene que renderizar en el momento de la petición.
2. El laboratorio: la misma lista, seis veces
Para comparar los modelos de verdad, este módulo usa un proyecto con seis páginas que muestran los mismos datos (productos de la API EMPRESA) de formas distintas. Todo lo que verás (HTML, cabeceras, tiempos, capturas) sale de ejecutar este proyecto con Next.js 16.4 (instala siempre el último parche 16.4.x; los parches corrigen fallos y vulnerabilidades, pero no cambian las APIs). Para las comparaciones con Next.js 15 se ha usado la 15.5.28, la última de esa rama.
app/
├── layout.tsx ← menú con enlaces a las seis páginas
├── page.tsx ← portada
├── lib/api.ts ← URL de la API, tipos y funciones de ayuda
├── ui/TablaProductos.tsx ← componente que pinta la lista
├── csr/page.tsx
├── ssg/page.tsx
├── isr/page.tsx
├── ssr/page.tsx
├── ppr/page.tsx
└── rsc/page.tsx y rsc/Contador.tsx
Preparación
Arranca la API EMPRESA (descárgala de recursos/empresa-api.zip) en su puerto de siempre:
node server.mjs
# La API queda en http://localhost:3000/api
Como la API ocupa el puerto 3000, que también es el que Next usa por defecto, el proyecto Next lo arrancarás en el 3001. Crea el proyecto como en el módulo 01 y añade un fichero .env.local con las direcciones de la API (las variables de entorno se ven en el módulo 13):
API_URL=http://localhost:3000/api
NEXT_PUBLIC_API_URL=http://localhost:3000/api
API_URL la usa el código que se ejecuta en el servidor. NEXT_PUBLIC_API_URL la usa la página CSR, que pide los datos desde el navegador; el prefijo NEXT_PUBLIC_ hace que Next la copie dentro del JavaScript que se envía al cliente.
⚠️ Importante: Para estudiar el renderizado no uses
npm run dev. En desarrollo Next compila cada página cuando la pides, no hace prefetch y no te enseña la tabla de rutas. Todo lo de este módulo se mide con la versión de producción:npm run build npm start -- -p 3001Y como el build prerenderiza páginas que piden datos, la API tiene que estar en marcha mientras compilas.
Las piezas comunes
// app/lib/api.ts
export const API = process.env.API_URL ?? 'http://localhost:3000/api'
export type Producto = {
productoId: number
nombre: string
precio: number
stock: number
categoria: string
}
export type Lista<T> = { contenido: T[]; total: number }
export async function leerProductos(tamano = 5): Promise<Producto[]> {
const respuesta = await fetch(`${API}/productos?tamano=${tamano}`)
if (!respuesta.ok) throw new Error(`La API respondió ${respuesta.status}`)
const datos: Lista<Producto> = await respuesta.json()
return datos.contenido
}
export function horaActual() {
return new Date().toLocaleTimeString('es-ES', { timeZone: 'Europe/Madrid' })
}
horaActual() es la herramienta más sencilla para ver el renderizado: cada página escribe a qué hora se generó su HTML. Si recargas y la hora no cambia, ese HTML no se ha vuelto a generar.
// app/ui/TablaProductos.tsx
import type { Producto } from '../lib/api'
export function TablaProductos({ productos }: { productos: Producto[] }) {
return (
<ul className="divide-y divide-slate-200 rounded border border-slate-200 bg-white">
{productos.map((p) => (
<li key={p.productoId} className="flex justify-between px-4 py-2">
<span>{p.nombre}</span>
<span className="font-mono">{p.precio} €</span>
</li>
))}
</ul>
)
}
// app/layout.tsx
import type { Metadata } from 'next'
import Link from 'next/link'
import './globals.css'
export const metadata: Metadata = {
title: 'Modelos de renderizado',
description: 'Laboratorio del módulo 02',
}
const rutas = ['csr', 'ssg', 'isr', 'ssr', 'ppr', 'rsc']
export default function RootLayout({ children }: LayoutProps<'/'>) {
return (
<html lang="es">
<body className="min-h-screen bg-slate-50 text-slate-800">
<nav className="flex gap-4 border-b border-slate-200 bg-white px-6 py-3 text-sm">
<Link href="/" className="font-semibold text-indigo-700">EMPRESA</Link>
{rutas.map((r) => (
<Link key={r} href={`/${r}`} className="text-slate-600 hover:text-indigo-700">
{r.toUpperCase()}
</Link>
))}
</nav>
<main className="mx-auto max-w-3xl p-6">{children}</main>
</body>
</html>
)
}
Las clases son de Tailwind, que trae la plantilla (se ve en el módulo 06); Link y los layouts se explican en el módulo 03. Aquí solo importan como decorado.
Herramientas para observar
Vas a mirar cada página de cuatro maneras. Ninguna es exclusiva de Next: sirven para cualquier web.
| Qué quieres ver | Cómo |
|---|---|
| El HTML tal como llega del servidor | curl http://localhost:3001/ssg, o “Ver código fuente” en el navegador (Ctrl+U). No uses el panel Elements de las DevTools: enseña el DOM después de que JavaScript lo haya cambiado |
| Las cabeceras de la respuesta | curl -D - -o /dev/null http://localhost:3001/ssg, o la pestaña Network → la petición → Headers |
| Qué ve alguien sin JavaScript (un buscador sencillo, por ejemplo) | DevTools → Ctrl+Shift+P → “Disable JavaScript” y recarga |
| Cuándo llega cada trozo del HTML | El script medir.mjs, a continuación |
Este pequeño script lee la respuesta trozo a trozo, igual que el navegador, y anota cuándo llega cada uno y qué contiene. Guárdalo fuera de app/ y ejecútalo con Node 20.9 o superior:
// medir.mjs. Uso: node medir.mjs ppr
const [ruta] = process.argv.slice(2)
const t0 = performance.now()
const res = await fetch(`http://localhost:3001/${ruta}`)
console.log(`HTTP ${res.status} content-type: ${res.headers.get('content-type')}`)
const lector = res.body.getReader()
const dec = new TextDecoder()
let total = ''
let n = 0
for (;;) {
const { done, value } = await lector.read()
if (done) break
const texto = dec.decode(value, { stream: true })
total += texto
n++
const ms = Math.round(performance.now() - t0)
const marcas = []
if (texto.includes('Portátil')) marcas.push('productos')
if (texto.includes('Calculando stock')) marcas.push('fallback')
if (texto.includes('unidades en stock')) marcas.push('stock real')
if (texto.includes('Cargando productos')) marcas.push('Cargando...')
console.log(`trozo ${n}: ${String(ms).padStart(5)} ms ${String(value.length).padStart(6)} bytes ${marcas.join(', ')}`)
}
const scripts = [...total.matchAll(/<script[^>]*src="([^"]+)"/g)].map((m) => m[1])
console.log(`Total: ${total.length} caracteres, ${scripts.length} <script src>`)
💡 Consejo: En Windows,
curlexiste tanto en PowerShell como en Git Bash. En PowerShell antiguo,curlpuede ser un alias deInvoke-WebRequest; escribecurl.exepara asegurarte de usar el de verdad.
3. CSR: el navegador hace todo el trabajo
Es el modelo que ya conoces de React con Vite. La página es un Client Component (lleva 'use client' arriba; el detalle está en el módulo 05) que pide los datos en un efecto:
// app/csr/page.tsx
'use client'
import { useEffect, useState } from 'react'
import { TablaProductos } from '../ui/TablaProductos'
import type { Lista, Producto } from '../lib/api'
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000/api'
export default function PaginaCSR() {
const [productos, setProductos] = useState<Producto[] | null>(null)
// Los efectos solo se ejecutan en el navegador, después de pintar
useEffect(() => {
fetch(`${API}/productos?tamano=5`)
.then((r) => r.json())
.then((datos: Lista<Producto>) => setProductos(datos.contenido))
}, [])
return (
<>
<h1 className="mb-4 text-2xl font-bold">CSR: el navegador pide los datos</h1>
{productos ? <TablaProductos productos={productos} /> : <p id="cargando">Cargando productos...</p>}
</>
)
}
Lo que llega en el HTML
Esto es lo que contiene el <main> de la respuesta real, sacado con curl:
<main class="mx-auto max-w-3xl p-6"><!--&--><!--&--><h1 class="mb-4 text-2xl font-bold">CSR: el navegador
pide los datos</h1><p id="cargando">Cargando productos...</p><!--$--><!--/$--><!--/&--><!--/&--></main>
<script id="_R_">(globalThis["TURBOPACK"] || (globalThis["TURBOPACK"] = [])).push({"otherChunks":[...
Ni rastro de los productos. Y fíjate en algo que sorprende a casi todo el mundo: el HTML sí trae el título y el texto “Cargando productos…“. Un Client Component de Next también se renderiza en el servidor para producir el HTML inicial. Lo único que no se ejecuta en el servidor es el efecto, y como los datos se piden en el efecto, no están.
Los comentarios <!--&--> y <!--$--> son marcas que deja React para orientarse al hidratar. No los toques ni te preocupes por ellos.

Figura 2. La página CSR con JavaScript desactivado. Es lo que llega en el HTML, y es todo lo que verá quien no ejecute JavaScript.

Figura 3. La misma página cuando JavaScript se ha descargado, se ha ejecutado, ha pedido los datos a la API y ha vuelto a pintar.
Las peticiones
Estas son las peticiones que hace el navegador al cargar /csr (salida de un script de Playwright que registra la red; se han quitado las de prefetch, que se explican en el apartado 9):
document 200 /csr
stylesheet 200 /_next/static/chunks/0_7jxu49wxubi.css
script 200 /_next/static/chunks/1x6obpmlu1_c2.js
script 200 /_next/static/chunks/06bs-nf19b2js.js
script 200 /_next/static/chunks/3oh00mkbkq8u6.js
script 200 /_next/static/chunks/turbopack-3-ry682tkyiqk.js
script 200 /_next/static/chunks/3_tj62vpzvttr.js
script 200 /_next/static/chunks/3l_qmp-61z4yc.js
script 200 /_next/static/chunks/2ki52vdnbo2ft.js
fetch 200 127.0.0.1:3402/api/productos?tamano=5
La petición a la API (fetch ... /api/productos) sale del navegador y solo después de que los scripts se hayan descargado y ejecutado. Esa es la cadena típica del CSR: HTML → JavaScript → datos → pintar. Cada flecha es un viaje por la red, y en un móvil con mala cobertura cada viaje se nota. (En el laboratorio la API estaba en el puerto 3402; en tu equipo verás localhost:3000).
Que la petición salga del navegador tiene dos consecuencias más:
- La API tiene que admitir CORS, porque la página (
localhost:3001) y la API (localhost:3000) son orígenes distintos. La API EMPRESA responde conAccess-Control-Allow-Origin: *; muchas APIs reales no. - Todo lo que use el navegador es público: la URL de la API, y cualquier clave que pongas en una variable
NEXT_PUBLIC_, viajan dentro del JavaScript.
¿Y qué dice el build?
├ ○ /csr
El símbolo ○ significa “prerenderizada entera como HTML estático”. Es coherente: el HTML de esta página (título más “Cargando productos…”) es igual para todo el mundo, así que Next lo genera una vez en el build. La respuesta lo confirma:
x-nextjs-cache: HIT
Cache-Control: s-maxage=31536000
s-maxage=31536000 (un año) le dice a una CDN que puede guardar esa respuesta todo ese tiempo. Es un HTML estático… que no contiene los datos.
📘 Concepto: En Next, “CSR” no es un modo de la página, sino una forma de pedir datos. La página siempre se prerenderiza o se renderiza en el servidor; lo que ocurre en el cliente es la parte que tú dejas para un efecto (o para una biblioteca como TanStack Query, que verás en el módulo 18).
¿Cuándo tiene sentido? Para datos que dependen de algo que solo existe en el navegador (la posición del usuario, el tamaño de la ventana, localStorage), para datos que cambian cada segundo mientras el usuario mira la página, o para zonas privadas detrás de un login donde los buscadores no importan. Para un catálogo público, casi nunca.
4. SSG: el HTML se genera una vez, al compilar
Ahora la página es un Server Component (no lleva 'use client'), y pide los datos directamente, sin efectos:
// app/ssg/page.tsx
import { leerProductos, horaActual } from '../lib/api'
import { TablaProductos } from '../ui/TablaProductos'
export default async function PaginaSSG() {
'use cache'
const productos = await leerProductos()
return (
<>
<h1 className="mb-4 text-2xl font-bold">SSG: HTML generado en el build</h1>
<TablaProductos productos={productos} />
<p className="mt-4 text-sm text-slate-500">Generada a las {horaActual()}</p>
</>
)
}
Dos novedades respecto a lo que conoces de React:
- El componente es
asyncy espera los datos conawait. Solo los Server Components pueden ser asíncronos así. 'use cache'le dice a Next que el resultado de esta función (aquí, toda la página) se puede guardar y reutilizar. Sin esa línea, Next no sabría si puede congelar los datos de la API en el build; con ella, sí. La directiva y sus opciones son el tema del módulo 09; aquí basta con saber que “en caché” significa “se puede prerenderizar”.
Lo que llega en el HTML

Figura 4. La página SSG con JavaScript desactivado. Los productos ya vienen en el HTML.
Con JavaScript desactivado se ve la página completa, porque el HTML ya trae los productos:
<ul class="divide-y divide-slate-200 rounded border border-slate-200 bg-white"><li class="flex justify-between
px-4 py-2"><span>Portátil UltraBook 14</span><span class="font-mono">899<!-- --> €</span></li>...
<p class="mt-4 text-sm text-slate-500">Generada a las <!-- -->11:44:53</p>
La hora es la del build. Recarga tantas veces como quieras: seguirá diciendo 11:44:53, porque el servidor no vuelve a ejecutar el componente. Entrega un fichero que ya existe.
Dónde está ese fichero
Después de next build, mira dentro de .next/server/app/:
ssg.html ← el HTML prerenderizado
ssg.rsc ← el mismo contenido en formato RSC, para navegar con Link (apartado 9)
ssg.meta ← cabeceras y datos que Next necesita para servirlo
ssg.segments/ ← trozos para el prefetch
En la red, la respuesta es inmediata (284 ms en la primera medida, con el servidor recién arrancado; menos en las siguientes) y llega en un solo trozo:
HTTP 200 content-type: text/html; charset=utf-8
trozo 1: 284 ms 10453 bytes productos
Total: 10423 caracteres, 7 <script src>
Y en las peticiones del navegador no aparece ninguna llamada a la API: los datos se pidieron una sola vez, durante el build, desde el servidor.
La sorpresa: “estática” no quiere decir “para siempre”
La tabla del build dice esto:
Route (app) Revalidate Expire
├ ○ /ssg 15m 1y
Es ○ (estática), pero tiene una columna Revalidate de 15 minutos. Ocurre porque 'use cache' sin más indicaciones usa el perfil default, que regenera el contenido como mucho cada 15 minutos. Las cabeceras lo confirman:
x-nextjs-cache: HIT
Cache-Control: s-maxage=900, stale-while-revalidate=31535100
s-maxage=900 son esos 15 minutos. En otras palabras, con Cache Components la frontera entre SSG e ISR es solo una cuestión de cuánto dura la caché. Si quieres un SSG “de verdad”, que no se regenere salvo que lo pidas, usa un perfil largo como cacheLife('max') (lo comprobarás en el ejercicio 3).
💡 Consejo: La portada
/y la página/csrtambién son○, sin ningún'use cache'. Una página que no lee datos externos ni nada de la petición se prerenderiza sola. No tienes que pedirlo.
SSG en Next.js 15
En Next.js 15 no existe 'use cache' en las versiones estables. La página se prerenderiza si no usa ninguna API dinámica, y el fetch se guarda si se lo pides:
// Next.js 15: app/ssg/page.tsx
import { API, horaActual, type Lista, type Producto } from '../lib/api'
export default async function PaginaSSG() {
// En Next.js 15, force-cache guarda la respuesta en la Data Cache
const respuesta = await fetch(`${API}/productos?tamano=5`, { cache: 'force-cache' })
const datos: Lista<Producto> = await respuesta.json()
return (
<>
<h1>SSG en Next.js 15</h1>
<ul>{datos.contenido.map((p) => <li key={p.productoId}>{p.nombre}</li>)}</ul>
<p>Generada a las {horaActual()}</p>
</>
)
}
Compilado con Next.js 15.5.28, sale ○ /ssg sin columna de revalidación, y la respuesta lleva Cache-Control: s-maxage=31536000: estática de verdad hasta el próximo build.
5. ISR: estática, pero se renueva sola
ISR resuelve el problema de SSG (los datos envejecen) sin perder su ventaja (la respuesta es un fichero ya hecho). La idea: la página se prerenderiza, y cuando caduca, Next la vuelve a generar en segundo plano con la primera visita que llega después.
// app/isr/page.tsx
import { cacheLife } from 'next/cache'
import { leerProductos, horaActual } from '../lib/api'
import { TablaProductos } from '../ui/TablaProductos'
export default async function PaginaISR() {
'use cache'
// Fresca 30 s en el navegador, se regenera en el servidor cada 20 s, caduca a la hora
cacheLife({ stale: 30, revalidate: 20, expire: 3600 })
const productos = await leerProductos()
return (
<>
<h1 className="mb-4 text-2xl font-bold">ISR: estática, pero se regenera</h1>
<TablaProductos productos={productos} />
<p className="mt-4 text-sm text-slate-500">Generada a las {horaActual()}</p>
</>
)
}
cacheLife fija tres tiempos (en segundos):
| Opción | Qué controla |
|---|---|
stale | Cuánto tiempo puede el navegador reutilizar lo que ya tiene sin preguntar al servidor |
revalidate | Cada cuánto el servidor vuelve a generar el contenido, en segundo plano |
expire | A partir de cuándo la copia es tan vieja que no se sirve: se espera a una nueva |
En el build:
Route (app) Revalidate Expire
├ ○ /isr 20s 1h
Lo que vio el laboratorio
Este script pide /isr varias veces y anota la hora de generación y la cabecera x-nextjs-cache:
// isr.mjs
const espera = (ms) => new Promise((r) => setTimeout(r, ms))
const t0 = Date.now()
async function pedir() {
const res = await fetch('http://localhost:3001/isr')
const html = await res.text()
const hora = html.match(/Generada a las (?:<!-- -->)?([\d:]+)/)?.[1]
const s = String(Math.round((Date.now() - t0) / 1000)).padStart(3)
console.log(`t=${s} s x-nextjs-cache: ${res.headers.get('x-nextjs-cache').padEnd(6)} Generada a las ${hora}`)
}
await pedir()
await espera(3000)
await pedir()
await espera(19000)
await pedir() // ya han pasado 20 s: recibe la copia vieja y dispara la regeneración
await espera(1500)
await pedir() // ya está la nueva
await pedir()
t= 0 s x-nextjs-cache: STALE Generada a las 11:45:41
t= 3 s x-nextjs-cache: HIT Generada a las 11:51:32
t= 22 s x-nextjs-cache: STALE Generada a las 11:51:32
t= 24 s x-nextjs-cache: HIT Generada a las 11:51:54
t= 24 s x-nextjs-cache: HIT Generada a las 11:51:54
Figura 5. La primera petición recibe la copia caducada (STALE) y provoca una regeneración; las siguientes reciben la nueva (HIT). A los 22 s se repite el ciclo.
Lee la tabla con calma, porque resume ISR entero:
STALEsignifica “te doy la copia que tengo, aunque ha caducado”. Nadie espera a la regeneración: ni siquiera quien la provoca.- Mientras tanto, en segundo plano, Next ejecuta otra vez el componente (vuelve a llamar a la API) y guarda el resultado.
- La siguiente petición ya recibe
HITcon la hora nueva.
Este patrón tiene nombre: stale-while-revalidate. Aparece literalmente en la cabecera que envía Next para que una CDN se comporte igual:
Cache-Control: s-maxage=20, stale-while-revalidate=3580
20 es revalidate; 3580 es lo que falta hasta expire (3600 − 20).
⚠️ Importante: La regeneración por tiempo no es la única. Desde una Server Action puedes decir “los productos han cambiado, regenera ya” con
updateTagorevalidateTag. Eso es ISR bajo demanda, y se ve en el módulo 09. Y para páginas con parámetros (/productos/[id]), ISR genera en el build las que conoces y el resto con la primera visita: lo verás en el módulo 04.
ISR en Next.js 15
En Next.js 15 la regeneración se declara para toda la ruta con una exportación de segmento:
// Next.js 15: app/isr/page.tsx
import { leerProductos, horaActual } from '../lib/api'
// Next.js 15: la página entera se regenera como mucho cada 20 segundos
export const revalidate = 20
export default async function PaginaISR() {
const productos = await leerProductos()
return (
<>
<h1>ISR en Next.js 15</h1>
<ul>{productos.map((p) => <li key={p.productoId}>{p.nombre}</li>)}</ul>
<p>Generada a las {horaActual()}</p>
</>
)
}
Resultado real con 15.5.28: ○ /isr 20s 1y y Cache-Control: s-maxage=20, stale-while-revalidate=31535980. El comportamiento es el mismo; cambia dónde se escribe. En Next.js 16 con cacheComponents, export const revalidate ya no se usa en las páginas: la duración va dentro de la función con cacheLife.
6. SSR: el HTML se genera en cada petición
Hay datos que no puedes congelar: el stock en este instante, el carrito de este usuario, lo que depende de sus cookies. Para eso el servidor tiene que generar el HTML cuando llega la petición.
El error que te encontrarás primero 🔄 16.0
🔄 Cambia en Next.js 16.0. Con
cacheComponents, una página que espera datos sin caché o datos de la petición fuera de<Suspense>hace fallarnext build. En Next.js 15 el build no se queja nunca: conconnection(),cookies()oheaders()la ruta pasa a ser dinámica (ƒ) en silencio, y con unfetchsin caché y nada más, se prerenderiza con el dato congelado. Si usas Next.js 15: no hay error que atender; usaconnection()oexport const dynamic = 'force-dynamic'para que la ruta sea SSR, y revisa la tabla del build para no congelar datos sin querer.
Este es el primer intento natural:
// app/roto/page.tsx
import { connection } from 'next/server'
import { leerProductos } from '../lib/api'
export default async function PaginaRota() {
await connection()
const productos = await leerProductos()
return <p>{productos.length} productos</p>
}
connection() (de next/server) es la forma de decir “lo que viene después debe ejecutarse en el momento de la petición, no en el build”. Y este es el resultado real de next build:
Error: Route "/roto": 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
...
Export encountered an error on /roto/page: /roto, exiting the build.
El mensaje es muy didáctico. Next.js 16 quiere que toda página tenga un armazón estático que se pueda enviar al instante, y te ofrece tres salidas:
| Salida | Qué haces | Qué obtienes | Apartado |
|---|---|---|---|
| stream | Envuelves la parte lenta en <Suspense> | Armazón inmediato + la parte lenta después (PPR) | 8 |
| cache | Guardas el resultado con 'use cache' | Página estática o ISR | 4 y 5 |
| block | Declaras export const instant = false | SSR clásico: nada hasta que todo esté listo | Este |
Ninguna es “la correcta” en abstracto. El error solo te obliga a decidir, en lugar de que la página se vuelva lenta sin que nadie se dé cuenta.
SSR clásico con instant = false 🆕 16.3
🆕 Desde Next.js 16.3. La exportación
instantno existe en Next.js 15. Si usas Next.js 15: no hace falta nada: la llamada aconnection()(o acookies()/headers()) ya convierte la ruta en dinámica, sin error de build.
// app/ssr/page.tsx
import { connection } from 'next/server'
import { leerProductos, horaActual } from '../lib/api'
import { TablaProductos } from '../ui/TablaProductos'
// Esta ruta puede bloquear: no queremos armazón, sino la página entera en cada petición
export const instant = false
export default async function PaginaSSR() {
await connection() // A partir de aquí, todo ocurre en el momento de la petición
const productos = await leerProductos()
return (
<>
<h1 className="mb-4 text-2xl font-bold">SSR: HTML generado en cada petición</h1>
<TablaProductos productos={productos} />
<p className="mt-4 text-sm text-slate-500">Generada a las {horaActual()}</p>
</>
)
}
instant = false le dice a Next: “sé que esta ruta bloquea, y lo acepto”. Su nombre viene de las Instant Navigations de Next.js 16.3, que verás en el módulo 15. En el build aparece así:
└ ƒ /ssr
ƒ (Dynamic) server-rendered on demand
Lo que se ve en la red
Dos peticiones seguidas, con tres segundos entre ellas, devuelven horas distintas:
Generada a las <!-- -->11:57:06
Generada a las <!-- -->11:57:09
Y las cabeceras dicen que no se puede guardar en ningún sitio:
Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
Transfer-Encoding: chunked
El HTML llega en varios trozos, pero el primero ya trae los productos:
trozo 1: 279 ms 2895 bytes productos
trozo 2: 293 ms 7277 bytes productos
trozo 3: 319 ms 5502 bytes
trozo 4: 321 ms 14 bytes
¿Y qué guardó el build para esta ruta? En .next/server/app/ hay un ssr.html… de 0 bytes. Es lo que la tabla llama “el build aceptó un armazón vacío”: la ruta pasa por el mismo mecanismo de prerender y reanudación que las demás (por eso su respuesta también lleva x-nextjs-postponed: 1), pero no hay nada que enviar antes de tener los datos.
Aquí la API responde en milisegundos, así que SSR parece tan rápido como SSG. Ahora imagina que la consulta tarda dos segundos: el usuario mira una pantalla en blanco durante dos segundos, porque el servidor no envía nada útil hasta tener los datos. Ese es el precio del SSR clásico, y lo que resuelve el siguiente apartado.
SSR en Next.js 15 y la opción dynamic
En Next.js 15 la página /ssr es la misma sin la línea de instant: connection() basta para que el build la marque como ƒ /ssr. También es muy común encontrar esta otra forma, que fuerza toda la ruta a renderizarse en cada petición:
// Next.js 15
export const dynamic = 'force-dynamic'
En Next.js 16 con cacheComponents, esa línea rompe la compilación. Este es el error real:
Error: Turbopack build failed with 1 error:
./app/err/dinamico/page.tsx:3:14
Error: Route segment config "dynamic" is not compatible with `nextConfig.cacheComponents`. Please remove it.
> 3 | export const dynamic = 'force-dynamic'
| ^^^^^^^
Lo mismo pasa con la opción de segmento runtime. Cuando migres un proyecto de 15 a 16 y actives cacheComponents, sustituye las opciones de segmento del modelo anterior: force-dynamic por connection() (más instant = false o un Suspense), y revalidate por cacheLife. La migración completa está en el módulo 24.
7. Streaming: enviar lo que ya está y el resto después
El streaming es SSR por partes. El servidor envía primero todo lo que puede generar enseguida y deja huecos para lo lento, con un contenido provisional. Cuando lo lento termina, envía el trozo que falta por la misma conexión, sin otra petición.
La herramienta para marcar un hueco es <Suspense>, de React (repásala en el Curso de React):
<Suspense fallback={<p>Calculando stock en tiempo real...</p>}>
<StockEnVivo />
</Suspense>
Mientras StockEnVivo no ha terminado, se muestra el fallback. El componente puede ser asíncrono y tardar lo que necesite.
Cómo es el HTML de un hueco
Esto es lo más interesante del módulo, y casi nadie lo mira. Con el servidor en marcha, ejecuta curl -N http://localhost:3001/ppr y verás que la salida se queda parada a la mitad unos dos segundos. Esto es lo que llega primero, en el sitio del hueco:
<!--$?--><template id="B:0"></template><p id="esperando" class="mt-4 rounded bg-amber-100 p-3">Calculando
stock en tiempo real...</p><!--/$-->
<!--$?-->marca el comienzo de un límite de Suspense pendiente.<template id="B:0">es una señal vacía con un identificador. LaBes de boundary.- Después va el fallback, que el navegador pinta normalmente.
Dos segundos después, al final del documento, llega esto:
<div hidden id="S:0"><p id="stock" class="mt-4 rounded bg-emerald-100 p-3">75<!-- --> unidades en stock
a las <!-- -->11:46:32</p></div><script>$RB=[];$RV=function(a){...}...$RC("B:0","S:0")</script>
- Un
<div hidden id="S:0">con el contenido real, oculto. LaSes de segment. - Un pequeño script en línea que define
$RCy la llama:$RC("B:0","S:0")significa “coge el contenido deS:0y ponlo en el sitio deB:0, quitando el fallback”.
Ese script no depende de React ni de ningún fichero externo: funciona antes de que se haya descargado el JavaScript de la aplicación. Así, el contenido aparece aunque la hidratación aún no haya empezado.
📘 Concepto: El HTML se puede enviar por trozos gracias a la codificación
Transfer-Encoding: chunkedde HTTP/1.1 (o a los frames de HTTP/2). El navegador no espera al final del documento para pintar: va mostrando lo que recibe. El streaming no es un truco de Next, es cómo funciona la web desde los años 90; lo nuevo es que React sabe colocar el contenido que llega tarde en su sitio.
Los tiempos reales
HTTP 200 content-type: text/html; charset=utf-8
trozo 1: 283 ms 2796 bytes productos, fallback
trozo 2: 284 ms 6925 bytes productos, fallback
trozo 3: 285 ms 6661 bytes productos, fallback
trozo 4: 2271 ms 2 bytes
trozo 5: 2271 ms 193 bytes stock real
trozo 6: 2291 ms 73 bytes
trozo 7: 2298 ms 998 bytes stock real
trozo 8: 2305 ms 14 bytes
Total: 17620 caracteres, 8 <script src>
A los 283 ms el usuario ya ve la página con los productos y el aviso amarillo. A los 2271 ms llega el stock. Y durante esos dos segundos el navegador no está parado: en la lista de peticiones de /ppr, los ficheros JavaScript terminan de descargarse antes que el propio documento, porque sus etiquetas <script> venían en el primer trozo:
stylesheet 200 /_next/static/chunks/0_7jxu49wxubi.css
script 200 /_next/static/chunks/1x6obpmlu1_c2.js
...
script 200 /_next/static/chunks/082_sr3s_lqsj.js
document 200 /ppr
Figura 6. Cuándo llega cada trozo en cuatro rutas del laboratorio. Con streaming, el usuario ve algo útil a los ~290 ms aunque la respuesta tarde 2,3 s en completarse.
Lo que el streaming te obliga a pensar
- El código de estado se envía al principio. Cuando el primer trozo sale con
200 OK, ya no se puede cambiar a404ni a una redirección HTTP. Si dentro de un Suspense descubres que el producto no existe, Next responde 200 y añade una etiquetanoindexpara que los buscadores no indexen la página. Por eso las comprobaciones que deciden el código de estado conviene hacerlas fuera de los huecos. - El fallback debe ocupar lo mismo que el contenido, o la página dará un salto cuando llegue (esto penaliza el CLS, una de las métricas del módulo 20).
- No pongas Suspense en todo. Cada hueco es un trozo más de HTML y un parpadeo más. Agrupa lo que tiene sentido mostrar junto.
loading.tsxes una forma abreviada de poner un Suspense alrededor de toda una página. Lo verás en el módulo 08.
8. Partial Prerendering: armazón estático y huecos dinámicos 🆕 16.0
🆕 Desde Next.js 16.0. PPR estable llega con
cacheComponents. En Next.js 15 solo existe comoexperimental.ppr, y en la versión estable 15.5.28 activarlo lanzaThe experimental feature "experimental.ppr" can only be enabled when using the latest canary version of Next.js.Si usas Next.js 15: usa SSR con streaming (la misma página con<Suspense>); el usuario ve el fallback casi igual de pronto, pero el armazón se genera en cada petición en lugar de servirse ya hecho.
Junta los dos apartados anteriores y tienes PPR: la parte estática se prerenderiza en el build (como SSG) y los huecos se rellenan en cada petición por streaming (como SSR con Suspense). Todo en la misma página y en la misma respuesta.
// app/ppr/page.tsx
import { Suspense } from 'react'
import { connection } from 'next/server'
import { leerProductos, horaActual } from '../lib/api'
import { TablaProductos } from '../ui/TablaProductos'
export default function PaginaPPR() {
return (
<>
{/* Estático: entra en el armazón */}
<h1 className="mb-4 text-2xl font-bold">PPR: armazón estático y huecos dinámicos</h1>
{/* En caché: también entra en el armazón */}
<Catalogo />
{/* Dinámico: el armazón lleva el fallback y el contenido llega después */}
<Suspense fallback={<p id="esperando" className="mt-4 rounded bg-amber-100 p-3">Calculando stock en tiempo real...</p>}>
<StockEnVivo />
</Suspense>
</>
)
}
async function Catalogo() {
'use cache'
const productos = await leerProductos(3)
return <TablaProductos productos={productos} />
}
async function StockEnVivo() {
await connection()
await new Promise((r) => setTimeout(r, 2000)) // Simula una consulta lenta
const productos = await leerProductos(3)
const unidades = productos.reduce((suma, p) => suma + p.stock, 0)
return (
<p id="stock" className="mt-4 rounded bg-emerald-100 p-3">
{unidades} unidades en stock a las {horaActual()}
</p>
)
}
Observa que la página no es async y no usa nada dinámico. Lo dinámico está metido dentro de StockEnVivo, que vive dentro de un Suspense. Esa es la regla de oro de Cache Components: cuanto más abajo en el árbol esté lo dinámico, más página cabe en el armazón.
En el build:
├ ◐ /ppr 15m 1y
◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content
Qué entra en el armazón
Durante el build, Next renderiza el árbol y decide pieza por pieza:
| Pieza | Ejemplo en /ppr | ¿Entra en el armazón? |
|---|---|---|
| JSX sin datos | El <h1> | Sí |
Componente con 'use cache' | Catalogo | Sí, salvo que su caché sea muy corta (menos de 5 min de expire, o revalidate: 0) |
Componente dentro de <Suspense> que usa datos de la petición o sin caché | StockEnVivo | No: entra su fallback |
| Componente que usa datos de la petición fuera de Suspense | (ninguno) | Error de build, como el de /roto |

Figura 7. Lo que el navegador pinta a los ~190 ms: el armazón estático, con los productos (en caché) y el fallback del hueco.

Figura 8. A los ~2,4 s llega el trozo del hueco y sustituye al fallback, sin recargar ni hacer otra petición.
Cómo lo hace el servidor
Después del build, en .next/server/app/ está ppr.html (el armazón) y ppr.meta. El primero mide 2796 bytes, exactamente lo mismo que el primer trozo que midió medir.mjs: ese trozo es el fichero, enviado sin renderizar nada. Dentro de este último hay un campo llamativo:
{
"status": 200,
"headers": {
"x-nextjs-stale-time": "300",
"x-nextjs-prerender": "1",
"x-next-cache-tags": "_N_T_/layout,_N_T_/ppr/layout,_N_T_/ppr/page,_N_T_/ppr"
},
"postponed": "2255:[1,{\"nextSegmentId\":1, ... \"Suspense Fallback\" ...
postponed es el estado que React guardó al detener el render en el hueco: dónde se quedó y qué faltaba. Cuando llega una petición, Next envía el armazón tal cual y, a la vez, reanuda el render desde ese punto para generar solo lo que falta. Por eso la respuesta lleva la cabecera x-nextjs-postponed: 1.
Fíjate también en el Cache-Control de /ppr:
Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
La respuesta completa no se puede guardar, porque el hueco es distinto en cada petición. Lo que sí es igual para todos es el armazón, y una plataforma preparada (o una CDN con soporte para PPR) puede servirlo desde cerca del usuario y pedir al servidor solo la reanudación. Con next start en tu propio servidor, un único proceso hace las dos cosas.
PPR frente a SSR con streaming en Next.js 15
Esta es la página equivalente para Next.js 15, comprobada con 15.5.28:
// Next.js 15: app/streaming/page.tsx
import { Suspense } from 'react'
import { connection } from 'next/server'
import { leerProductos, horaActual } from '../lib/api'
export default function PaginaStreaming() {
return (
<>
<h1>Streaming en Next.js 15</h1>
<Suspense fallback={<p id="esperando">Calculando stock en tiempo real...</p>}>
<StockEnVivo />
</Suspense>
</>
)
}
async function StockEnVivo() {
await connection()
await new Promise((r) => setTimeout(r, 2000))
const productos = await leerProductos(3)
const unidades = productos.reduce((suma, p) => suma + p.stock, 0)
return <p id="stock">{unidades} unidades en stock a las {horaActual()}</p>
}
El build la marca como ƒ /streaming, y la medición es casi igual que la de PPR:
trozo 1: 298 ms 1159 bytes fallback
...
trozo 6: 2322 ms 145 bytes stock real
¿Dónde está la diferencia, si los números se parecen tanto? En quién genera el armazón. En Next.js 15 el servidor ejecuta en cada petición el layout, el título y el catálogo antes de enviar el primer trozo; en Next.js 16 con PPR ese trozo ya existe en disco desde el build. Con una página sencilla y un servidor local apenas se nota. Con un layout que consulta tres cosas, con muchos usuarios o con el armazón servido desde una CDN, sí.
9. React Server Components: no es un modo más
Hasta aquí has visto cuándo se genera el HTML. Los React Server Components (RSC) responden a otra pregunta: qué componentes se ejecutan dónde. Por eso se combinan con todos los modelos anteriores: tu página SSG, ISR, SSR o PPR está hecha de Server Components con algún Client Component dentro.
| SSR (y SSG, ISR, PPR) | Server Components | |
|---|---|---|
| Pregunta que responde | ¿Cuándo se genera el HTML inicial? | ¿Qué componentes se ejecutan solo en el servidor? |
| ¿El código del componente llega al navegador? | Sí: se vuelve a ejecutar para hidratar | No, nunca |
| ¿Qué recibe el navegador? | HTML | HTML para la primera carga y un payload RSC para el resto |
¿Puede usar useState o onClick? | Sí | No (eso es de los Client Components) |
Un ejemplo con los dos tipos
// app/rsc/page.tsx
import { leerProductos } from '../lib/api'
import { Contador } from './Contador'
// Server Component: su código no viaja al navegador
export default async function PaginaRSC() {
'use cache'
const productos = await leerProductos(3)
const total = productos.reduce((suma, p) => suma + p.precio, 0)
return (
<>
<h1 className="mb-4 text-2xl font-bold">RSC: servidor y cliente en una página</h1>
<p>Suma de precios calculada en el servidor: {total} €</p>
<Contador inicial={productos.length} />
</>
)
}
// app/rsc/Contador.tsx
'use client'
import { useState } from 'react'
// Client Component: este sí se envía al navegador y se hidrata
export function Contador({ inicial }: { inicial: number }) {
const [unidades, setUnidades] = useState(inicial)
return (
<button
onClick={() => setUnidades(unidades + 1)}
className="mt-4 rounded bg-indigo-700 px-4 py-2 text-white"
>
Unidades en el carrito: {unidades}
</button>
)
}
El payload RSC, de verdad
Cuando el navegador necesita esta página para una navegación con Link, no pide HTML: pide un payload RSC, con Content-Type: text/x-component. Este es un fragmento del que se descargó para /rsc (sin cambios, salvo los cortes marcados con ...):
1:"$Sreact.fragment"
2:"/_next/static/chunks/3oh00mkbkq8u6.js"
d:"/_next/static/chunks/082_sr3s_lqsj.js"
e:I[59367,["$2","$d"],"Contador"]
...
c:[["children",{"s":"__PAGE__", ... "d":{"r":["$","$1","c",{"children":[[
["$","h1",null,{"className":"mb-4 text-2xl font-bold","children":"RSC: servidor y cliente en una página"}],
["$","p",null,{"children":["Suma de precios calculada en el servidor: ",2897," €"]}],
["$","$Le",null,{"inicial":3}]
], ...
Léelo línea a línea:
- Cada línea es
identificador:valor. Los valores pueden hacer referencia a otras líneas con$. e:I[59367,["$2","$d"],"Contador"]es una referencia de cliente: “hay un componente llamadoContadoren el módulo 59367; para tenerlo, descarga los ficheros de las líneas2yd”. LaIes de import.["$","h1",null,{...}]es un elemento de React ya resuelto: etiqueta, key y props. Es JSX convertido en datos.2897es la suma de precios ya calculada. El navegador no sabe que hubo una API, ni unreduce, ni un'use cache'. Solo recibe el resultado.["$","$Le",null,{"inicial":3}]es el<Contador inicial={3} />: una referencia a la líneae(laLindica que se carga de forma diferida) con sus props. Las props viajan como JSON, y por eso solo se pueden pasar al cliente valores serializables. Lo verás en el módulo 05.
En la primera carga, ese mismo payload va dentro del HTML, en etiquetas <script>self.__next_f.push([1,"..."])</script>. Las has visto de pasada en el apartado 7: el trozo que llega a los 2271 ms en /ppr incluye el payload del hueco, con el párrafo del stock:
<script>self.__next_f.push([1,"c:[\"$\",\"p\",null,{\"id\":\"stock\",\"className\":\"mt-4 rounded
bg-emerald-100 p-3\",\"children\":[75,\" unidades en stock a las \",\"11:46:32\"]}]\n"])</script>
El navegador recibe así dos cosas: el HTML, para pintar ya, y el payload, para que React sepa qué árbol hay detrás de ese HTML y pueda hidratar los Client Components sin volver a ejecutar los de servidor.
Figura 9. El código de PaginaRSC se queda en el servidor. Al navegador viajan el HTML, el payload y el JavaScript de los Client Components.
Comprobado: el código de servidor no se envía
Después del build, busca los textos de cada componente en los ficheros que se envían al navegador (.next/static/chunks/):
grep -l "Unidades en el carrito" .next/static/chunks/*.js | wc -l # 1
grep -l "Suma de precios" .next/static/chunks/*.js | wc -l # 0
El texto del Contador está en un fichero de cliente; el de PaginaRSC, en ninguno. Lo mismo vale para lo que importe un Server Component: una biblioteca de 200 kB para formatear fechas, usada solo en el servidor, no suma ni un byte al JavaScript del navegador.
Navegar no es recargar
Cuando cargas /, el menú tiene siete enlaces. Sin que hagas nada, el navegador hace estas peticiones (son las _rsc que se filtraron en el apartado 3):
/?_rsc=5CB68i4pnAekjehf segmento=/_tree text/x-component 169 car.
/csr?_rsc=5CB68i4pnAekjehf segmento=/_tree text/x-component 218 car.
...
/csr?_rsc=zEfEWGlTjGm9mM51 segmento=/csr/__PAGE__ text/x-component 4391 car.
/ssg?_rsc=5YJ12wb5nnPJtGV7 segmento=/ssg/__PAGE__ text/x-component 5439 car.
/isr?_rsc=pygPAYUDp1o84E9M segmento=/isr/__PAGE__ text/x-component 5450 car.
...
Es el prefetch: Link descarga por adelantado los payloads de las páginas enlazadas, por segmentos (/_tree es la estructura de rutas; __PAGE__, el contenido de la página). Al pulsar luego el enlace “RSC”, el script de Playwright registró cero peticiones nuevas: todo lo necesario ya estaba en el navegador, y React cambió la página sin recargarla.
Este es el comportamiento de la plantilla de Next.js 16.4, que activa partialPrefetching: la precarga se pide por segmentos (_tree, __PAGE__), y lo que comparten varias páginas, como el layout, se descarga una sola vez. En Next.js 15 verás, en cambio, una petición de precarga por ruta. Cómo se decide qué se precarga y cómo se consigue que una navegación sea instantánea se explica en los módulos 03 y 15.
⚠️ Importante: Esto también explica por qué el parámetro
_rsclleva un valor raro: es un identificador que evita que una caché intermedia confunda el payload con el HTML de la misma URL. Si hacescurl -H "RSC: 1" http://localhost:3001/rscsin él, Next responde con una redirección307a/rsc?_rsc. El payload está pensado para el router de Next, no para que lo pidas a mano.
10. La tabla de rutas de next build
Al terminar el build, Next imprime una tabla con una línea por ruta. Es el resumen más fiable de lo que ha decidido, y la primera cosa que debes mirar cuando una página no se comporta como esperabas.
Símbolos de la tabla de rutas 🔄 16.0
🔄 Cambia en Next.js 16.0. Con
cacheComponents, la tabla usa○,◐yƒ, y ya no muestra los tamaños (SizeyFirst Load JS). En Next.js 15 usa○,●yƒ, con tamaños. Si usas Next.js 15: lee●como “estática generada a partir degenerateStaticParams”; no tendrás◐. Para medir el JavaScript en 16, usa el analizador de paquetes del módulo 20.
Esta es la tabla real del laboratorio con Next.js 16.4 (sin las páginas de los ejercicios):
Route (app) Revalidate Expire
┌ ○ /
├ ○ /_not-found
├ ○ /csr
├ ○ /isr 20s 1h
├ ◐ /ppr 15m 1y
├ ○ /rsc 15m 1y
├ ○ /ssg 15m 1y
└ ƒ /ssr
○ (Static) prerendered as static content
◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content
ƒ (Dynamic) server-rendered on demand
Y esta, la de las páginas equivalentes con Next.js 15.5.28:
Route (app) Size First Load JS Revalidate Expire
┌ ○ / 0 B 113 kB
├ ○ /_not-found 0 B 113 kB
├ ○ /isr 0 B 113 kB 20s 1y
├ ● /productos/[id] 0 B 113 kB
├ ├ /productos/1
├ ├ /productos/2
├ └ /productos/3
├ ○ /ssg 0 B 113 kB
├ ƒ /ssr 0 B 113 kB
└ ƒ /streaming 0 B 113 kB
+ First Load JS shared by all 115 kB
○ (Static) prerendered as static content
● (SSG) prerendered as static HTML (uses generateStaticParams)
ƒ (Dynamic) server-rendered on demand
| Símbolo | Next.js 16 con Cache Components | Next.js 15 |
|---|---|---|
○ | Toda la página es HTML prerenderizado (SSG, o ISR si tiene Revalidate) | Igual |
● | No aparece | Prerenderizada a partir de generateStaticParams (rutas con parámetros) |
◐ | Armazón prerenderizado y huecos que se rellenan en cada petición (PPR) | No aparece (solo en canary) |
ƒ | Se renderiza en cada petición; el build aceptó un armazón vacío | Se renderiza en cada petición |
Las columnas Revalidate y Expire aparecen cuando alguna parte de la página tiene caché con tiempo: son los valores de cacheLife (o de revalidate en Next.js 15).
💡 Consejo: Si una página sale
ƒy esperabas○, busca qué lee de la petición (cookies(),headers(),searchParams,connection()) o qué datos no tienen caché. Si el build falla con el error de prerender, ejecutanext build --debug-prerender: desactiva la minificación, añade los source maps del servidor y no se detiene en la primera ruta que falla. No despliegues nunca un build hecho con esa opción.
Lo que no te dice la tabla
- No te dice nada del CSR.
/csrsale○, igual que/ssg, aunque una tenga los datos en el HTML y la otra no. Para eso tienes que mirar el HTML. - No te dice cuánto tarda. Una página
ƒcon una consulta de 3 segundos y otra con una de 3 milisegundos tienen el mismo símbolo. - Los buscadores pueden no ver lo mismo. Algunos bots no procesan bien una página que llega por partes. Next los reconoce por su
User-Agenty, para ellos, se salta el armazón y renderiza la página entera antes de responder. Tú no tienes que hacer nada, pero conviene saberlo si comparas lo que ve Google con lo que ves tú.
11. Cómo elegir
Con todo lo anterior, la decisión para cada trozo de una página se reduce a tres preguntas:
- ¿Es igual para todos los usuarios? Si lo es, debe estar en el armazón: o no usa datos, o los usa con
'use cache'. - Si es igual para todos, ¿cada cuánto cambia? Eso decide el
cacheLife:maxpara lo que casi no cambia,hoursodayspara un catálogo, minutos para lo que se mueve más. Si cambia por una acción concreta (alguien edita un producto), revalida bajo demanda. - ¿Depende de quién lo pide o del momento exacto? Entonces va en un hueco con
<Suspense>, con un fallback del mismo tamaño. Solo si toda la página depende de ello y no quieres mostrar nada provisional, usainstant = false.
Y el CSR queda para lo que solo puede ocurrir en el navegador o cambia mientras el usuario mira.
Así queda la aplicación EMPRESA del curso:
| Página | Qué contiene | Modelo |
|---|---|---|
| Portada de la tienda | Textos y destacados que cambian poco | ○ con cacheLife('days') |
| Catálogo y ficha de producto | Precios y descripciones; se editan desde la zona privada | ○ con cacheTag y revalidación bajo demanda |
| Ficha de producto con “stock ahora” | Lo anterior más un dato en tiempo real | ◐: ficha en el armazón, stock en un hueco |
| Panel de empleados (privado) | Depende de la sesión del usuario | ◐ con el menú en el armazón, o ƒ |
| Buscador con filtros | Resultados según searchParams | ◐ con los resultados en un hueco |
| Gráfico que se actualiza cada segundo | Datos en vivo mientras se mira | Client Component con datos en el navegador |
💡 Consejo: Empieza por lo estático y añade dinamismo donde haga falta, no al revés. El error de prerender de Next.js 16 está diseñado justo para eso: por defecto, todo debe tener armazón, y cada excepción la decides tú.
12. Caso práctico EMPRESA: la ficha de producto de la tienda
Vamos a aplicar todo el módulo a una página real de la aplicación EMPRESA: la ficha del producto 1 en la tienda pública. Tiene cuatro partes, y cada una pide un modelo distinto:
| Parte | ¿Igual para todos? | ¿Cada cuánto cambia? | Decisión |
|---|---|---|---|
| Nombre, categoría, descripción y precio | Sí | Cuando alguien lo edita en la zona privada | 'use cache' con cacheLife('hours') y una etiqueta para revalidar al editar → armazón |
| Stock | Sí, pero debe ser el de este instante | Continuamente | Sin caché, después de connection(), dentro de <Suspense> → hueco |
| Botón “Añadir al carrito” | No: cada usuario tiene su carrito | Mientras el usuario usa la página | Client Component (su HTML inicial va en el armazón) |
| “También te puede interesar” | Sí | Casi nunca | 'use cache' con cacheLife('days') → armazón |
Antes de compilar, predice: ¿qué símbolo tendrá /tienda en el build y qué columnas de revalidación?
La capa de datos
Separar las funciones de datos de los componentes tiene una ventaja inmediata: 'use cache' va donde está la decisión sobre los datos, y cualquier componente puede reutilizarlas.
// app/tienda/datos.ts
import { cacheLife, cacheTag } from 'next/cache'
import { API, type Lista, type Producto } from '../lib/api'
export type Ficha = Producto & { descripcion: string; categoriaId: number }
// Igual para todos y cambia poco: en caché, con etiqueta para revalidarla al editar
export async function leerFicha(id: number): Promise<Ficha> {
'use cache'
cacheLife('hours')
cacheTag(`producto-${id}`)
const respuesta = await fetch(`${API}/productos/${id}`)
if (!respuesta.ok) throw new Error(`Producto ${id}: la API respondió ${respuesta.status}`)
return respuesta.json()
}
// Los productos de la misma categoría cambian todavía menos
export async function leerRelacionados(categoriaId: number, excluir: number): Promise<Producto[]> {
'use cache'
cacheLife('days')
cacheTag('productos')
const respuesta = await fetch(`${API}/productos?categoriaId=${categoriaId}&tamano=4`)
const datos: Lista<Producto> = await respuesta.json()
return datos.contenido.filter((p) => p.productoId !== excluir).slice(0, 3)
}
// El stock debe ser el de este instante: sin caché
export async function leerStock(id: number): Promise<number> {
const respuesta = await fetch(`${API}/productos/${id}`)
const producto: Producto = await respuesta.json()
return producto.stock
}
cacheTag pone una etiqueta a la entrada de la caché. Cuando en el módulo 10 una Server Action modifique el producto 1, bastará con updateTag('producto-1') para que la ficha se regenere al momento, sin esperar a que pase la hora. Aquí solo la dejamos preparada.
⚠️ Importante:
leerFichayleerStockllaman a la misma URL de la API. La diferencia no está en la petición, sino en la decisión: una se guarda y la otra no. Si mañana el stock pasa a una tabla de otro sistema, solo cambialeerStock.
La página
// app/tienda/page.tsx
import { Suspense } from 'react'
import { connection } from 'next/server'
import { horaActual } from '../lib/api'
import { leerFicha, leerRelacionados, leerStock } from './datos'
import { BotonCarrito } from './BotonCarrito'
const ID = 1
export default function FichaProducto() {
return (
<article className="space-y-6">
<Ficha id={ID} />
<Suspense fallback={<p id="stock-esperando" className="h-12 rounded bg-slate-200 p-3 text-slate-500">Comprobando stock...</p>}>
<StockActual id={ID} />
</Suspense>
<BotonCarrito productoId={ID} />
<Relacionados id={ID} />
</article>
)
}
async function Ficha({ id }: { id: number }) {
const p = await leerFicha(id)
return (
<header>
<p className="text-sm text-indigo-700">{p.categoria}</p>
<h1 className="text-2xl font-bold">{p.nombre}</h1>
<p className="mt-1 text-slate-600">{p.descripcion}</p>
<p className="mt-2 text-3xl font-semibold">{p.precio} €</p>
</header>
)
}
async function StockActual({ id }: { id: number }) {
await connection()
await new Promise((r) => setTimeout(r, 1500)) // Simula un almacén lento
const unidades = await leerStock(id)
return (
<p id="stock" className="h-12 rounded bg-emerald-100 p-3">
{unidades} unidades disponibles (consultado a las {horaActual()})
</p>
)
}
async function Relacionados({ id }: { id: number }) {
const ficha = await leerFicha(id)
const otros = await leerRelacionados(ficha.categoriaId, id)
return (
<section>
<h2 className="mb-2 font-semibold">También te puede interesar</h2>
<ul className="grid grid-cols-3 gap-3">
{otros.map((p) => (
<li key={p.productoId} className="rounded border border-slate-200 bg-white p-3 text-sm">
{p.nombre}
<span className="block font-mono">{p.precio} €</span>
</li>
))}
</ul>
</section>
)
}
// app/tienda/BotonCarrito.tsx
'use client'
import { useState } from 'react'
export function BotonCarrito({ productoId }: { productoId: number }) {
const [unidades, setUnidades] = useState(0)
return (
<div className="flex items-center gap-4">
<button
onClick={() => setUnidades(unidades + 1)}
className="rounded bg-indigo-700 px-4 py-2 text-white"
data-producto={productoId}
>
Añadir al carrito
</button>
<span id="carrito">{unidades === 0 ? 'Carrito vacío' : `${unidades} en el carrito`}</span>
</div>
)
}
Detalles que conviene ver:
- La página no es
async. Solo esperan los componentes que necesitan datos, y cada uno espera lo suyo. FichayRelacionadosllaman los dos aleerFicha(1). No se hacen dos peticiones a la API: como la función tiene'use cache'y los mismos argumentos, la segunda llamada reutiliza el resultado.- El fallback del stock tiene la misma altura (
h-12) que el contenido real, para que la página no salte cuando llegue. BotonCarritorecibe solo un número. No le pasamos el objeto del producto entero: lo que reciba un Client Component viaja en el payload.
El resultado del build
Route (app) Revalidate Expire
...
└ ◐ /tienda 1h 1d
Si habías predicho ◐, acertaste: hay un armazón (ficha, botón, relacionados y el fallback del stock) y un hueco (el stock). Las columnas son más interesantes: 1h 1d es el perfil hours (revalida cada hora, caduca al día), aunque los relacionados usan days. Una página prerenderizada caduca cuando caduca la parte más corta de su armazón. No tendría sentido que el HTML guardado durara una semana si la ficha que lleva dentro debe renovarse cada hora.
💡 Consejo: Si en una página
◐ves una revalidación más corta de lo que esperabas, busca la función con'use cache'de vida más corta que entre en el armazón. Esa es la que manda.
El HTML y los tiempos
HTTP 200 content-type: text/html; charset=utf-8
trozo 1: 581 ms 3212 bytes productos, fallback stock, relacionados
trozo 2: 647 ms 7449 bytes productos, fallback stock, relacionados
trozo 3: 680 ms 5723 bytes productos, fallback stock
trozo 4: 681 ms 1466 bytes productos, relacionados
trozo 5: 2129 ms 216 bytes stock real
trozo 6: 2207 ms 73 bytes
trozo 7: 2234 ms 1022 bytes stock real
trozo 8: 2255 ms 14 bytes
Total: 19131 caracteres, 9 <script src>
(Medido mientras el equipo compilaba otro proyecto, por eso los tiempos son algo más altos que los de /ppr; lo que importa es la forma).
El primer trozo mide exactamente 3212 bytes, igual que el fichero .next/server/app/tienda.html que generó el build. No es casualidad: el primer trozo es ese fichero, enviado tal cual. Después llega la reanudación, y a los ~1,5 s del retardo simulado, el stock. En la red no hay ninguna petición a la API desde el navegador: los cuatro datos se pidieron en el servidor (tres en el build, uno en esta petición).
Dos peticiones seguidas muestran que el hueco se genera cada vez, mientras el resto no cambia:
consultado a las <!-- -->12:18:25
consultado a las <!-- -->12:18:27

Figura 10. El armazón de /tienda, tal como se ve antes de que llegue el stock. Todo lo que es igual para todos ya está; el botón aparece con su texto inicial.

Figura 11. La página completa, después de pulsar dos veces el botón: el stock ha llegado por streaming y el Client Component ya está hidratado.
¿Y sin JavaScript?
Esta es la prueba que más sorprende:

Figura 12. Con JavaScript desactivado, el fallback del stock se queda para siempre.
El stock real sí ha llegado en el HTML (está en el <div hidden id="S:0"> del final), pero quien lo mueve a su sitio es el pequeño script $RC, y sin JavaScript ese script no se ejecuta. Conclusiones prácticas:
- Lo que necesites que vea cualquiera (un buscador sencillo, una vista previa de un enlace, un lector que no ejecuta JS) tiene que ir en el armazón, no en un hueco.
- Los huecos son para lo que es propio del momento o de la persona. Un buscador no necesita indexar el stock de las 12:18:25.
- El botón aparece, pero no hace nada sin JavaScript: es un Client Component sin hidratar. Para formularios que funcionen sin JS, en el módulo 10 verás las Server Actions.
Cómo sería en Next.js 15
En Next.js 15 la página haría falta escribirla como SSR con streaming: el <Suspense> es igual, pero la ficha y los relacionados no pueden ir en un armazón prerenderizado porque, en cuanto StockActual llama a connection(), toda la ruta pasa a ser ƒ. Para que la ficha no consulte la API en cada visita, guardarías sus fetch con { next: { revalidate: 3600, tags: ['producto-1'] } }: el HTML se generaría en cada petición, pero con los datos de la caché. El resultado para el usuario es parecido; el coste para el servidor, no.
13. Errores frecuentes
Todos los errores de este apartado con salida se han provocado a propósito en el laboratorio.
Ideas equivocadas
”'use client' significa que la página se renderiza en el cliente”. No. Un Client Component se renderiza también en el servidor para producir el HTML inicial, como has visto en /csr. 'use client' significa “este componente necesita estar también en el navegador”, no “solo en el navegador”.
Medir con npm run dev. En desarrollo no hay tabla de rutas, no hay prefetch y las páginas se compilan al pedirlas. Cualquier conclusión sobre caché o tiempos sacada de dev es dudosa. Usa build y start.
Compilar con la API parada
El build ejecuta las páginas que prerenderiza, y estas llaman a la API. Con la API detenida, este es el resultado real:
Generating static pages using 11 workers (0/15) ...
⨯ [TypeError: fetch failed] {
digest: '3227098399',
[cause]: Error: connect ECONNREFUSED 127.0.0.1:3402
errno: -4078,
code: 'ECONNREFUSED',
syscall: 'connect',
address: '127.0.0.1',
port: 3402
}
}
...
Error occurred prerendering page "/ej/cacheado". Read more: https://nextjs.org/docs/messages/prerender-error
Export encountered an error on /ej/cacheado/page: /ej/cacheado, exiting the build.
⨯ Next.js build worker exited with code: 1 and signal: null
ECONNREFUSED significa “no hay nadie escuchando en ese puerto”. Solución: arranca la API antes de npm run build (y recuerda que ocupa el puerto 3000: Next, en el 3001). Este error es también un recordatorio de algo importante para el despliegue: la máquina que compila necesita acceso a los datos que se prerenderizan, y lo que ve es lo que tendrá la página hasta que se revalide.
Valores que cambian en cada render
Date.now(), new Date(), Math.random() o crypto.randomUUID() dan un valor distinto cada vez. Si aparecen en una parte que se prerenderiza, Next no sabe si quieres congelarlo. Esta página tan inocente:
export default function PaginaConFecha() {
return <p>Hoy es {new Date().toLocaleDateString('es-ES')}</p>
}
hace fallar el build de Next.js 16 con este mensaje real:
Error: Route "/err/fecha": Next.js encountered the unstable value `new Date()` while prerendering.
This value can change between renders, so it must be either prerendered or computed later.
Ways to fix this:
- [dynamic] Render at request time by adding a dynamic data access (e.g. `await connection()`) before this call
- [cache] Prerender and cache the value with `"use cache"`
- [client] Render the value on the client with `"use client"`
- [measure] If the value is for telemetry, use a timing API such as `performance.now()`
Learn more: https://nextjs.org/docs/messages/blocking-prerender-current-time
> 2 | return <p>Hoy es {new Date().toLocaleDateString('es-ES')}</p>
| ^
Solución, según lo que quieras de verdad:
- “La fecha del momento de la visita”:
await connection()antes, en un componente dentro de<Suspense>(igual queStockEnVivo). - “Me vale la fecha en que se generó la página”:
'use cache'en el componente, como hacen/ssge/isrconhoraActual(). - “La fecha del reloj del usuario”: un Client Component que la calcule en un efecto (el patrón de la hidratación del Curso de React).
En Next.js 15 esa misma página compila sin avisar y se queda con la fecha del build.
Datos congelados sin querer en Next.js 15
Es el reverso del error de prerender de la versión 16. Esta página para Next.js 15 usa un fetch sin opciones:
// Next.js 15: app/congelado/page.tsx
import { leerProductos, horaActual } from '../lib/api'
// Next.js 15: fetch sin opciones y sin APIs dinámicas
export default async function PaginaCongelada() {
const productos = await leerProductos()
return (
<>
<p>{productos[0].nombre}: {productos[0].stock} unidades</p>
<p>Generada a las {horaActual()}</p>
</>
)
}
En Next.js 15 los fetch no se guardan en caché por defecto, así que es fácil pensar que la página pedirá el stock en cada visita. El build de 15.5.28 dice otra cosa:
├ ○ /congelado 0 B 113 kB
○: estática, sin revalidación. El stock y la hora (Generada a las 12:20:44) se quedan congelados en el HTML del build hasta el siguiente despliegue. No hay error ni aviso; solo la tabla te lo dice. Solución en Next.js 15: si debe ser de cada visita, añade await connection() (la ruta pasa a ƒ); si basta con renovarlo de vez en cuando, export const revalidate = 60 o { next: { revalidate: 60 } } en el fetch. Con Next.js 16 y cacheComponents este despiste es imposible: o lo guardas en caché, o lo pones en un hueco, o el build falla.
Otros despistes
Esperar que 'use cache' sea para siempre. Sin cacheLife, el perfil default regenera cada 15 minutos (○ /ssg 15m 1y). Si quieres otra cosa, dilo.
Poner async y await en la página o en el layout “por si acaso”. Cada await de datos de la petición en lo alto del árbol arrastra todo lo que hay debajo fuera del armazón. Baja el await al componente que de verdad lo necesita.
Buscar el payload en el panel Elements. Las DevTools enseñan el DOM ya modificado. El HTML tal como llegó, con sus <template id="B:0"> y sus <div hidden>, solo se ve con curl o con “Ver código fuente”.
Comprobar un 404 dentro de un hueco. Si el código de estado depende de los datos (el producto no existe), haz la comprobación antes del primer Suspense. Dentro del hueco ya se ha enviado un 200.
Usar export const dynamic = 'force-dynamic' con cacheComponents. Rompe la compilación, como has visto en el apartado 6. Sustitúyelo por connection().
Poner en un hueco lo que deben ver los buscadores. Sin JavaScript, el contenido de un hueco no sustituye al fallback (figura 12). El título, la descripción y el precio de un producto van en el armazón.
14. Ejercicios
Ejercicio 1: Clasifica sin ejecutar
Sin compilar nada, di qué símbolo (○, ◐ o ƒ) tendrá cada página en Next.js 16 con cacheComponents, y si el build fallará en alguna:
// A
export default function Aviso() {
return <p>Tienda cerrada por inventario</p>
}
// B
export default async function Departamentos() {
'use cache'
const res = await fetch('http://localhost:3000/api/departamentos')
const datos = await res.json()
return <p>{datos.total} departamentos</p>
}
// C
import { cookies } from 'next/headers'
export default async function Hola() {
const nombre = (await cookies()).get('nombre')?.value
return <p>Hola, {nombre}</p>
}
// D
import { Suspense } from 'react'
import { cookies } from 'next/headers'
export default function Hola() {
return (
<>
<h1>Tienda EMPRESA</h1>
<Suspense fallback={<p>Hola</p>}><Nombre /></Suspense>
</>
)
}
async function Nombre() {
const nombre = (await cookies()).get('nombre')?.value ?? 'visitante'
return <p>Hola, {nombre}</p>
}
Ver solución
- A:
○. No usa datos ni nada de la petición: se prerenderiza sola, sin columna de revalidación. - B:
○con15m 1y. Los datos tienen caché con el perfildefault, así que entran en el prerender, que se regenera cada 15 minutos como mucho. - C: el build falla. Lee
cookies()fuera de cualquier<Suspense>, y las cookies son de cada petición. Es el mismo error que/roto, con sus tres salidas. Conexport const instant = falseseríaƒ. - D:
◐. El<h1>y el fallback “Hola” forman el armazón; el saludo con el nombre llega en cada petición. Esta página está en el laboratorio (app/ej/cookies/page.tsx) y el build la marca así:├ ◐ /ej/cookies.
Ejercicio 2: Encuentra el hueco en el HTML
Con el laboratorio en marcha, guarda en un fichero el HTML de /ppr (curl -s http://localhost:3001/ppr -o ppr.html) y localiza en él:
- El sitio donde estaba el hueco y su identificador.
- El contenido real del stock.
- La instrucción que mueve el contenido a su sitio.
¿Por qué el contenido del paso 2 aparece al final del fichero y no en medio?
Ver solución
Puedes buscar con el editor o con grep -o:
grep -o '<template id="B:[0-9]*">' ppr.html # 1. <template id="B:0">
grep -o '<div hidden id="S:0">.\{0,120\}' ppr.html # 2. <div hidden id="S:0"><p id="stock" ...>75 unidades...
grep -o '\$RC("B:0","S:0")' ppr.html # 3. $RC("B:0","S:0")
<!--$?--><template id="B:0"></template>seguido del fallback “Calculando stock en tiempo real…”.<div hidden id="S:0">con el párrafo verde del stock.$RC("B:0","S:0"), en un<script>en línea justo después.
Aparece al final porque el HTML es un flujo: cuando el contenido estuvo listo (unos dos segundos después), el servidor ya había enviado todo lo demás. No puede “volver atrás” en un flujo de bytes, así que lo añade al final y deja que un script lo coloque en su sitio. Esa es la razón de ser del <template> con identificador: marcar el sitio para encontrarlo después.
Ejercicio 3: Un SSG que no caduque
Crea la página /ej/maximo con el catálogo de cinco productos, pero haz que el build la muestre con la caché más larga posible. ¿Qué dice la tabla? ¿Qué cabecera Cache-Control esperas?
Ver solución
// app/ej/maximo/page.tsx
import { cacheLife } from 'next/cache'
import { leerProductos, horaActual } from '../../lib/api'
import { TablaProductos } from '../../ui/TablaProductos'
export default async function CatalogoMaximo() {
'use cache'
cacheLife('max')
const productos = await leerProductos()
return (
<>
<TablaProductos productos={productos} />
<p>Generada a las {horaActual()}</p>
</>
)
}
Resultado real del build:
Route (app) Revalidate Expire
├ ○ /ej/maximo 30d 1y
El perfil max revalida cada 30 días y caduca al año. Por la misma lógica que en /ssg (s-maxage igual a revalidate), la cabecera debería llevar 30 días en s-maxage. Esta es la real:
x-nextjs-cache: HIT
Cache-Control: s-maxage=2592000, stale-while-revalidate=28944000
2 592 000 s son 30 días; 28 944 000 s son los 335 días que faltan hasta el año.
Si de verdad no quieres que se regenere nunca por tiempo, este es el perfil adecuado; los cambios llegarán con un nuevo despliegue o con revalidación bajo demanda (módulo 09).
Ejercicio 4: Arregla /roto de las tres maneras
Toma la página /roto del apartado 6, que rompe el build, y escribe tres versiones que compilen: una por cada salida que propone el error. Predice el símbolo de cada una.
Ver solución
Block (ƒ): es exactamente /ssr. Añade export const instant = false y deja todo lo demás igual.
Stream (◐): saca la parte dinámica a un componente y envuélvelo en Suspense. La página ya no es async:
import { Suspense } from 'react'
import { connection } from 'next/server'
import { leerProductos } from '../lib/api'
export default function PaginaArreglada() {
return (
<Suspense fallback={<p>Contando productos...</p>}>
<Cuenta />
</Suspense>
)
}
async function Cuenta() {
await connection()
const productos = await leerProductos()
return <p>{productos.length} productos</p>
}
Cache (○): el error advierte que 'use cache' no se aplica a connection(). Si los datos no tienen por qué ser de este instante, quita connection() y guarda el resultado:
// app/ej/cacheado/page.tsx
import { leerProductos } from '../../lib/api'
export default async function PaginaCacheada() {
const productos = await productosEnCache()
return <p>{productos.length} productos</p>
}
async function productosEnCache() {
'use cache'
return leerProductos()
}
En el laboratorio el build la marca como ○ /ej/cacheado 15m 1y. Fíjate en que aquí 'use cache' está en una función de datos, no en el componente: también vale, y es la forma más habitual cuando varios componentes usan los mismos datos.
La elección depende de lo que quieras para el usuario: datos frescos con algo provisional (stream), datos de hace un rato al instante (cache) o datos frescos esperando sin ver nada (block).
Ejercicio 5: Una caché demasiado corta
Esta página guarda los precios en caché, pero solo unos segundos. ¿Entrarán los precios en el armazón? ¿Qué símbolo tendrá la ruta?
import { Suspense } from 'react'
import { cacheLife } from 'next/cache'
import { leerProductos, horaActual } from '../../lib/api'
export default function PaginaSegundos() {
return (
<>
<h1>Precios</h1>
<Suspense fallback={<p>Cargando precios...</p>}>
<Precios />
</Suspense>
</>
)
}
async function Precios() {
'use cache'
cacheLife('seconds')
const productos = await leerProductos(3)
return <p>{productos.length} precios a las {horaActual()}</p>
}
Ver solución
No entran. El perfil seconds tiene un expire de un minuto, y una caché que caduca en menos de cinco minutos se excluye del prerender: se convierte en un hueco dinámico que se resuelve en cada petición (aunque luego reutilice la caché entre peticiones cercanas). El armazón lleva el <h1> y el fallback “Cargando precios…”.
El build lo confirma: ◐ /ej/segundos, sin columnas de revalidación, porque nada con tiempo de caché ha entrado en el prerender.
Si quitas el <Suspense>, el build falla con el error de prerender, igual que con datos sin caché: para Next, una caché tan corta es casi lo mismo que no tenerla.
Ejercicio 6: ISR en Next.js 15 a nivel de fetch
En Next.js 15, además de export const revalidate, puedes poner el tiempo en cada fetch. Escribe una página para Next.js 15 que muestre el catálogo y se regenere cada hora usando esa opción, y predice su línea en la tabla de rutas.
Ver solución
// Next.js 15: app/ej/fetch-revalidate/page.tsx
import { API, type Lista, type Producto } from '../../lib/api'
export default async function CatalogoHorario() {
// Next.js 15: la revalidación se declara en el propio fetch
const respuesta = await fetch(`${API}/productos?tamano=5`, { next: { revalidate: 3600 } })
const datos: Lista<Producto> = await respuesta.json()
return <ul>{datos.contenido.map((p) => <li key={p.productoId}>{p.nombre}</li>)}</ul>
}
Resultado real con Next.js 15.5.28:
Route (app) Size First Load JS Revalidate Expire
├ ○ /ej/fetch-revalidate 0 B 113 kB 1h 1y
La página se prerenderiza y hereda el tiempo más corto de todos sus fetch. Esta opción next.revalidate sigue existiendo en Next.js 16, pero con cacheComponents lo idiomático es 'use cache' con cacheLife('hours'). El modelo de caché de Next.js 15 se explica completo en el módulo 09.
Ejercicio 7: Comprueba qué viaja al navegador
Añade al Server Component de /rsc una constante con un texto que no se muestre en ningún sitio, por ejemplo const notaInterna = 'Margen comercial: 35 %', y úsala solo en un console.log. Después de compilar, ¿aparece ese texto en algún fichero de .next/static/? ¿Y si la usas dentro de Contador.tsx?
Ver solución
Busca el texto en lo que se envía al navegador:
grep -rl "Margen comercial" .next/static/ | wc -l
- En el Server Component:
0. El código dePaginaRSCno se empaqueta para el cliente, igual que pasaba con “Suma de precios”. Elconsole.logse ejecuta en el servidor y su salida aparece en la terminal denext start(si la página se prerenderiza, en la del build). - En
Contador.tsx:1. Todo lo que hay en un fichero con'use client'y en lo que importa va al navegador, aunque no se muestre.
Esta es la base de una regla de seguridad que verás en el módulo 22: un secreto puede vivir en un Server Component, pero nunca en un Client Component ni en las props que le pasas (porque las props viajan en el payload).
Ejercicio 8: Lee las cabeceras
Sin mirar el código, solo con estas cabeceras de cuatro rutas reales del laboratorio, deduce qué modelo usa cada una:
Ruta 1: x-nextjs-cache: HIT Cache-Control: s-maxage=31536000
Ruta 2: x-nextjs-cache: STALE Cache-Control: s-maxage=20, stale-while-revalidate=3580
Ruta 3: x-nextjs-postponed: 1 Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
Ruta 4: x-nextjs-cache: HIT Cache-Control: s-maxage=900, stale-while-revalidate=31535100
Ver solución
- Ruta 1: estática sin caducidad práctica (
s-maxagede un año). Es/csr: el HTML es estático, aunque los datos se pidan después en el navegador. Con solo las cabeceras no puedes distinguirla de un SSG conmax: tendrías que mirar el HTML. - Ruta 2: ISR con
revalidatede 20 s yexpirede una hora (20 + 3580 = 3600).STALEindica que esta petición recibió la copia caducada y disparó la regeneración. Es/isr. - Ruta 3: renderizado en cada petición (
no-store), con reanudación de un prerender (x-nextjs-postponed). Puede ser/ppro/ssr: las dos llevan exactamente esas cabeceras, porque en Next.js 16 incluso una rutaƒse sirve como un prerender (vacío) que se reanuda. Para distinguirlas, mira la tabla del build o mide los trozos. - Ruta 4: prerenderizada con el perfil
defaultde'use cache'(s-maxage=900, 15 minutos). Es/ssg.
Ejercicio 9 (propuesto): La ficha de un departamento
Siguiendo el caso práctico del apartado 12, diseña una página /departamento para el departamento 10 de la API (/api/departamentos/10 y /api/departamentos/10/empleados) con: nombre y localidad, que casi nunca cambian; la lista de empleados, que cambia algunas veces al mes; y el número de empleados conectados ahora mismo (simúlalo con un número aleatorio después de connection()). Decide para cada parte si va en el armazón o en un hueco, con qué cacheLife, y predice el símbolo y las columnas del build. Compílala y comprueba tu predicción con la tabla, con medir.mjs y con JavaScript desactivado.
Ejercicio 10 (propuesto): CSR frente a PPR con una red lenta
En las DevTools, pestaña Network, elige el perfil de red “Slow 4G” y desactiva la caché. Carga /csr y /ppr y anota, para cada una, cuándo aparece la lista de productos en pantalla (puedes usar la pestaña Performance o grabar la pantalla). Explica la diferencia con lo que has aprendido sobre la cadena HTML → JS → datos del CSR.
Resumen
| Concepto | API | Idea clave |
|---|---|---|
| Modelo de renderizado | (ninguna opción) | Se distingue por cuándo y dónde se genera el HTML con datos; Next lo deduce de tu código |
| CSR | 'use client' + useEffect | El HTML llega sin datos; el navegador los pide después. La página en sí sigue siendo ○ |
| SSG | 'use cache' | HTML generado en el build; sin cacheLife, el perfil default revalida cada 15 min |
| ISR | cacheLife({ stale, revalidate, expire }) | Se sirve la copia y se regenera en segundo plano (stale-while-revalidate) |
| ISR en Next.js 15 | export const revalidate, fetch(..., { next: { revalidate } }) | Mismo comportamiento, declarado por ruta o por fetch |
| SSR | connection(), cookies(), headers() | HTML generado en cada petición; Cache-Control: no-store |
| Bloqueo explícito | export const instant = false (16.3) | Acepta que la ruta espere a todos los datos: ƒ |
| Error de prerender | stream, cache o block | En Next.js 16 toda página necesita un armazón, o declarar que no lo tiene |
| Streaming | <Suspense fallback> | Primero lo que está listo; lo lento llega por la misma conexión (template B:0, div hidden S:0, $RC) |
| PPR | cacheComponents (16.0) | Armazón prerenderizado + huecos reanudados en cada petición: ◐ |
| Caducidad del armazón | cacheLife más corto que entre en él | Una página ◐ u ○ se revalida al ritmo de su caché más corta (/tienda: 1h 1d) |
| Valores inestables | new Date(), Math.random() | En 16 rompen el prerender: connection(), 'use cache' o Client Component; en 15 se congelan |
| Server Components | (por defecto en app/) | Se ejecutan solo en el servidor; su código no llega al navegador |
| Payload RSC | text/x-component, ?_rsc= | El árbol ya resuelto, con referencias a los Client Components; viaja en el HTML y en las navegaciones |
| Tabla del build | ○ ◐ ƒ (16), ○ ● ƒ (15) | El primer sitio donde mirar; no distingue CSR ni mide tiempos |
| Observar | curl, Ctrl+U, Network, JS desactivado | El HTML tal como llega es la verdad; el panel Elements, no |