Módulo 09: Caché: Cache Components
Objetivos del módulo
- Entender qué guarda la caché de Next.js, dónde lo guarda (HTML prerenderizado, servidor y navegador) y cuándo lo renueva
- Activar Cache Components y saber qué cambia en el build cuando lo haces
- Guardar en caché funciones de datos y componentes con
'use cache', y entender cómo se forma la clave de caché - Elegir la vida de cada dato con
cacheLifey sus tres tiempos (stale,revalidate,expire) - Etiquetar datos con
cacheTage invalidarlos conupdateTag,revalidateTag,revalidatePathyrefresh, sabiendo lo que ve el usuario con cada uno (medido, no supuesto) - Distinguir los datos que se pueden cachear de los que dependen de la petición, y usar
connection(),'use cache: private'y el paso de argumentos para combinarlos - Garantizar que una ruta siga estática con
ensureStaticy repartir el trabajo entre las etapas de la navegación conprefetch()ynavigation(), novedades de Next.js 16.4 - Leer los símbolos
○,◐yƒdenext buildy contar cuántas peticiones llegan de verdad a tu API - Depurar la caché con las cabeceras de respuesta, los ficheros de
.next, el registroNEXT_PRIVATE_DEBUG_CACHEy un manejador de caché propio - Construir de punta a punta la tienda EMPRESA con catálogo estático, fichas con un hueco de stock compartido (
'use cache: remote'), perfiles propios y una administración que invalida con precisión - Dominar el modelo de caché de Next.js 15 (
fetchconcacheynext.revalidate,export const revalidate,dynamic,unstable_cache,revalidateTagcon un argumento) y traducir cada pieza al modelo nuevo con una tabla de equivalencias
🔄 Cambia en Next.js 16.0. Todo este módulo gira en torno a Cache Components, que en Next.js 16 es estable y viene activado en los proyectos nuevos. En Next.js 15 estable no se puede activar:
experimental.cacheComponents(antesdynamicIO) solo se admite en las versiones canary. Si usas Next.js 15: usa el modelo de caché clásico, explicado entero y medido en el apartado 14, y la tabla de equivalencias del apartado 15. Conexperimental.useCache: truepuedes además usar'use cache'en Next.js 15.5 (apartado 14.6).
💡 Consejo: Este módulo continúa el módulo 08. Allí aprendiste a obtener datos en el servidor y viste que, con Cache Components, un dato sin caché fuera de
<Suspense>hace fallar el build. Aquí aprenderás la otra salida: guardarlo. También conviene tener fresco el módulo 02, donde viste qué significan SSG, ISR y Partial Prerendering.
1. Para qué sirve una caché en Next.js
Una aplicación EMPRESA típica tiene dos clases de páginas:
- El catálogo público de la tienda: miles de visitas al día, y los productos cambian unas pocas veces por semana. Pedir la lista a la API en cada visita es tirar trabajo: la respuesta es la misma para todos.
- La zona de gestión: el administrador cambia un precio y espera ver su cambio al instante. Aquí lo importante no es ahorrar peticiones, sino no enseñar nunca un dato viejo a quien acaba de modificarlo.
Una caché es un sitio donde guardas el resultado de un trabajo (una consulta, un cálculo, un trozo de interfaz ya renderizado) para no repetirlo. Pero toda caché plantea tres preguntas, y el modelo de Next.js consiste en responderlas de forma explícita:
| Pregunta | Respuesta en Cache Components | Apartado |
|---|---|---|
| ¿Qué se guarda? | Lo que tú marcas con 'use cache': una función de datos o un componente entero | 3 |
| ¿Cuánto tiempo vale? | Lo que diga cacheLife: un perfil ('hours', 'days'…) o tres tiempos a medida | 4 |
| ¿Cómo se descarta antes de tiempo? | Con una etiqueta (cacheTag) y una invalidación (updateTag, revalidateTag…) | 5 y 6 |
1.1 Dos modelos de caché
Next.js ha tenido dos formas muy distintas de responder a esas preguntas:
- El modelo de Next.js 13 a 15 (que la documentación llama ahora Previous Model): la caché es implícita. Next decide qué guardar según cómo llames a
fetchy según unas opciones de segmento (export const revalidate,export const dynamic). Una página es estática salvo que algo la vuelva dinámica, y a veces lo es sin que te des cuenta: el módulo 00 mostró una página que en Next.js 15 compilaba como estática con el dato congelado del build. - El modelo de Next.js 16 con Cache Components: la caché es explícita. Nada se guarda si no lo pides con
'use cache', y todo lo que no se guarda se considera dinámico. El build comprueba que cada ruta tenga un armazón estático (static shell) y te obliga a decidir qué hacer con cada dato: guardarlo, esperarlo dentro de<Suspense>o sacarlo a una etapa posterior.
📘 Concepto: En el modelo nuevo, la pregunta deja de ser “¿por qué esta página es estática si yo no quería?” y pasa a ser “¿qué partes de esta página quiero guardar?”. Es un cambio de mentalidad: por defecto, todo es dinámico; la caché es una decisión tuya.
La documentación de Next.js 16.4 lo deja claro: Cache Components es el modelo recomendado y será el único en Next.js 17, donde desaparecerán las opciones cacheComponents y partialPrefetching porque estarán siempre activas. Si empiezas un proyecto hoy, aprende este modelo. Si mantienes uno en Next.js 15, el apartado 14 es para ti.
2. Activar Cache Components 🆕 16.0
🆕 Desde Next.js 16.0. No existe en Next.js 15 estable:
experimental.cacheComponentssolo se admite en canary. Si usas Next.js 15: no hay interruptor equivalente; la caché depende de las opciones defetchy de segmento (apartado 14 de este módulo).
Un proyecto creado con create-next-app 16.4 ya trae activadas las dos opciones:
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
}
export default nextConfig
Si actualizas un proyecto antiguo, añádelas tú. partialPrefetching conviene escribirla siempre de forma explícita: si falta, Next avisa en cada arranque, porque la primera versión de Cache Components (16.0) no incluía Partial Prefetching. Al compilar, el build lo confirma en las primeras líneas:
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 271ms
- Cache Components enabled
- Partial Prefetching enabled
2.1 Qué cambia al activarlo
Con cacheComponents: true, Next.js cambia cuatro cosas a la vez:
'use cache',cacheLifeycacheTagpasan a estar disponibles. Sin la opción,'use cache'no compila.- Los datos sin caché son dinámicos. Un
fetcho una consulta sin'use cache'no se ejecuta en el build: se espera en cada petición. Si no está dentro de<Suspense>, el build falla (lo viste en el módulo 08). -
Las opciones de segmento del modelo anterior desaparecen.
export const revalidate,dynamic,fetchCache,runtime… ya no se admiten. Lo comprobamos con una página que todavía exportabarevalidate:./app/err-revalidate/page.tsx:1:14 Error: Route segment config "revalidate" is not compatible with `nextConfig.cacheComponents`. Please remove it. > 1 | export const revalidate = 60 | ^^^^^^^^^^El error aparece en la fase de compilación, antes de prerenderizar nada. La vida del dato se expresa ahora con
cacheLifedentro de la función cacheada. - La ruta se divide en armazón y huecos. Lo estático y lo cacheado forman el static shell; lo dinámico se queda en “huecos” que se rellenan en cada petición por streaming. Es el Partial Prerendering (PPR) del módulo 02, que ahora es el comportamiento normal.
2.2 El laboratorio de este módulo
Para no hablar de memoria, todo lo que sigue se ha medido con un proyecto de prueba en Next.js 16.4 y otro en Next.js 15.5.28, los dos contra la API EMPRESA. Para contar las peticiones que llegan a la API, arrancamos la API con un pequeño envoltorio que escribe una línea por cada petición:
// api-registro.mjs: arranca la API EMPRESA y registra cada petición que recibe
import http from 'node:http'
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)
})
const { syncBuiltinESMExports } = await import('node:module')
syncBuiltinESMExports()
await import(process.argv[2])
# Terminal 1: la API con registro (en el curso, en el puerto 3000)
node api-registro.mjs ./empresa-api/server.mjs
# Terminal 2: la aplicación Next en otro puerto
npm run build
npx next start -p 3001
Si al lado del registro haces peticiones con curl o con el navegador, verás exactamente cuántas veces Next llama a la API. Es la forma más fiable de entender una caché: mira las peticiones, no supongas.
Todas las funciones de datos viven en un único módulo, lib/api.ts. La URL de la API sale de una variable de entorno, con http://localhost:3000/api como valor por defecto:
// lib/api.ts
import 'server-only'
import { cacheLife, cacheTag } from 'next/cache'
export const API = process.env.API_URL ?? 'http://localhost:3000/api'
export type Producto = {
productoId: number
nombre: string
precio: number
stock: number
categoria: string
}
type Lista<T> = { contenido: T[]; total: number }
function hora() {
return new Date().toISOString().slice(11, 19)
}
// Sin caché: cada render llama a la API
export async function productosSinCache() {
const respuesta = await fetch(`${API}/productos?tamano=5`)
const datos: Lista<Producto> = await respuesta.json()
return { productos: datos.contenido, generado: hora() }
}
// Caché a nivel de datos: el resultado se guarda una hora y lleva la etiqueta 'productos'
export async function obtenerProductos() {
'use cache'
cacheLife('hours')
cacheTag('productos')
const respuesta = await fetch(`${API}/productos?tamano=5`)
const datos: Lista<Producto> = await respuesta.json()
return { productos: datos.contenido, generado: hora() }
}
El campo generado es un truco de laboratorio: guarda la hora en que se ejecutó de verdad la función. Si ves la misma hora en dos visitas, la segunda salió de la caché.
💡 Consejo:
import 'server-only'(del módulo 05) impide que alguien importe por error este módulo desde un Client Component. Una función con'use cache'siempre se ejecuta en el servidor, así que es buena costumbre protegerla.
3. 'use cache': guardar datos y componentes 🔄 16.0
🔄 Cambia en Next.js 16.0. En Next.js 15
'use cache'era experimental y se activaba conexperimental.useCache: true, conunstable_cacheLifeyunstable_cacheTag. En 16 es estable y se activa concacheComponents. Si usas Next.js 15: activaexperimental.useCache(comprobado en 15.5.28, apartado 14.6) o usaunstable_cachey las opciones defetch.
'use cache' es una directiva: una cadena de texto al principio de una función, igual que 'use client' o 'use server'. Le dice a Next.js: “guarda el valor que devuelve esta función y, la próxima vez que me llamen con los mismos argumentos, devuelve el guardado sin ejecutarme”.
Se puede poner en tres niveles:
| Nivel | Dónde va la directiva | Qué se guarda |
|---|---|---|
| Datos | Al principio de una función async que devuelve datos | El valor devuelto (un objeto, un array…) |
| Interfaz | Al principio de un componente async, una página o un layout | El resultado renderizado (su RSC Payload) |
| Fichero | En la primera línea del fichero | Todas las funciones exportadas del fichero |
3.1 Caché a nivel de datos
Es la forma más habitual. obtenerProductos() (arriba) es un ejemplo completo. La página del catálogo se limita a llamarla:
// app/catalogo/page.tsx
import { obtenerProductos } from '@/lib/api'
export default async function Catalogo() {
const { productos, generado } = await obtenerProductos()
return (
<main>
<h1>Catálogo</h1>
<ul>
{productos.map((p) => (
<li key={p.productoId}>{p.nombre}: {p.precio} €</li>
))}
</ul>
<p id="generado">Datos obtenidos a las {generado}</p>
</main>
)
}
Fíjate en lo que no hay: ni <Suspense>, ni loading.tsx, ni connection(). No hacen falta, porque el dato está en caché y por tanto se puede resolver en el build. La página entera entra en el armazón estático.
La ventaja de cachear a nivel de datos es la reutilización: el mismo obtenerProductos() lo usan tres páginas del laboratorio (/catalogo, /estatica y /panel), y en el build solo hubo una petición a /api/productos?tamano=5 para las tres.
3.2 Caché a nivel de interfaz
Si lo que quieres guardar es un trozo de interfaz ya montado, pon la directiva dentro del componente:
// app/componente/page.tsx
import { Suspense } from 'react'
import { cacheLife, cacheTag } from 'next/cache'
import { connection } from 'next/server'
import { API, type Producto } from '@/lib/api'
// Caché a nivel de interfaz: se guarda el resultado renderizado del componente
async function Destacados({ categoriaId }: { categoriaId: number }) {
'use cache'
cacheLife('hours')
cacheTag('productos')
const respuesta = await fetch(`${API}/productos?categoriaId=${categoriaId}`)
const { contenido }: { contenido: Producto[] } = await respuesta.json()
return (
<section>
<h2>Categoría {categoriaId}</h2>
<ul>
{contenido.map((p) => (
<li key={p.productoId}>{p.nombre}</li>
))}
</ul>
<p>Renderizado a las {new Date().toISOString().slice(11, 19)}</p>
</section>
)
}
async function HoraPeticion() {
await connection()
return <p id="hora">Petición atendida a las {new Date().toISOString().slice(11, 19)}</p>
}
export default function Componente() {
return (
<main>
<h1>Destacados</h1>
<Destacados categoriaId={2} />
<Destacados categoriaId={3} />
<Suspense fallback={<p>Calculando hora...</p>}>
<HoraPeticion />
</Suspense>
</main>
)
}
Hay tres detalles importantes:
- Cada valor de
categoriaIdes una entrada distinta. Los argumentos forman parte de la clave de caché, así que<Destacados categoriaId={2} />y<Destacados categoriaId={3} />se guardan por separado. En el build hubo una petición a?categoriaId=2y otra a?categoriaId=3. new Date()está permitido dentro de'use cache'. Fuera de una caché, Next 16 lo rechaza en el prerender (lo verás en el apartado 8). Dentro, el valor queda congelado con la entrada: es “la hora a la que se generó”, que es justo lo que queremos mostrar.- La página mezcla lo cacheado con lo dinámico.
HoraPeticionllama aconnection()y está dentro de<Suspense>: es un hueco dinámico. Por eso la ruta sale como◐(Partial Prerender) y no como○.
3.3 Caché a nivel de fichero
Si pones 'use cache' en la primera línea de un fichero, todas sus funciones exportadas quedan cacheadas, y todas deben ser async. Es cómodo para un módulo de consultas del catálogo, pero tiene un riesgo: si mañana alguien añade a ese fichero una función que no debería cachearse, quedará cacheada sin que se note. En este curso preferimos la directiva función a función, que se ve en cada sitio.
3.4 La clave de caché
Cuando llamas a una función cacheada, Next calcula una clave con:
- El identificador de la función (en qué fichero y en qué posición está) y el id del build.
- Los argumentos, serializados.
- Las variables capturadas del ámbito exterior (closures), si la función está anidada dentro de otra y usa variables de la de fuera.
Con la misma clave, el mismo resultado. Por eso un despliegue nuevo empieza con la caché vacía: el id del build cambia.
⚠️ Importante: Los argumentos tienen que ser serializables, con las mismas reglas que las props de un Server Component a un Client Component (módulo 05): números, cadenas, objetos planos, arrays, fechas… Una instancia de clase o una función no sirven como parte de la clave. Si necesitas pasar
childrenu otro JSX a un componente cacheado, sí se admite, pero no forma parte de la clave: se pasa tal cual, sin que el componente cacheado pueda inspeccionarlo.
3.5 Lo que mide el build
Este es el resultado real de next build del laboratorio de Next.js 16.4:
Route (app) Revalidate Expire
┌ ○ /
├ ○ /_not-found
├ ƒ /api/revalidar
├ ○ /catalogo 1h 1d
├ ◐ /componente 1h 1d
├ ◐ /corto
├ ○ /enlaces
├ ○ /enlaces-completos
├ ○ /estatica 1h 1d
├ ◐ /etapas
├ ◐ /panel 1h 1d
├ ◐ /privado
├ /productos/[id]
│ ├ ◐ /productos/[id]
│ ├ ○ /productos/1 1d 1w
│ └ ○ /productos/2 1d 1w
└ ◐ /sin-cache
○ (Static) prerendered as static content
◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content
ƒ (Dynamic) server-rendered on demand
Las columnas Revalidate y Expire muestran la vida más corta de todo lo cacheado en la ruta: 1h / 1d es el perfil 'hours' y 1d / 1w el perfil 'days'. Y estas son las peticiones que registró la API, primero durante el build y después al visitar cada ruta tres veces seguidas con next start:
| Ruta | Qué usa | Símbolo | Peticiones en el build | Peticiones en 3 visitas |
|---|---|---|---|---|
/sin-cache | fetch sin caché dentro de <Suspense> | ◐ | 0 | 3 |
/catalogo | obtenerProductos() con 'hours' | ○ | 1 (compartida con /estatica y /panel) | 0 |
/componente | Dos <Destacados> cacheados + hora con connection() | ◐ | 2 | 0 |
/corto | Caché con cacheLife('seconds') (apartado 4.3) | ◐ | 1 | 3 |
/productos/1 | Ficha prerenderizada con generateStaticParams | ○ | 1 | 0 |
/productos/3 | Ficha no prerenderizada | ◐ | 0 | 1 (solo la primera) |
Lee la tabla con calma, porque resume el módulo:
- Sin caché, una petición por visita.
/sin-cachellama a la API cada vez, y en el build ni siquiera lo intenta: elfetchsin caché dentro de<Suspense>se deja para la petición. - Con caché en el armazón, cero peticiones.
/catalogoy/componentesirven el HTML generado en el build; la API no se entera de las visitas. - Una ficha no prerenderizada se genera una vez.
/productos/3no estaba engenerateStaticParams: la primera visita llamó a la API y guardó el resultado; las dos siguientes ya no.
💡 Consejo: Como el armazón de
/catalogose genera en el build,next buildnecesita la API en marcha. Si no lo está, elfetchde dentro de'use cache'falla y el build también. Esto no pasaba en el módulo 08, donde todo el dato era dinámico.
4. cacheLife: cuánto vale un dato 🔄 16.0
🔄 Cambia en Next.js 16.0. En Next.js 15 se llamaba
unstable_cacheLife, solo funcionaba conexperimental.useCache, y el perfil'seconds'teníastalede 0 segundos (en 16 son 30). Si usas Next.js 15: impórtala comounstable_cacheLife as cacheLife, o expresa la vida connext: { revalidate: segundos }enfetcho con la opciónrevalidatedeunstable_cache.
cacheLife va dentro de una función con 'use cache' y fija cuánto tiempo vale la entrada. Se le pasa un perfil por nombre o un objeto con tres tiempos.
4.1 Los tres tiempos
Cada entrada de caché tiene tres tiempos, en segundos:
| Tiempo | Quién lo usa | Qué significa |
|---|---|---|
stale | El navegador (la caché del router) | Durante este tiempo, el cliente reutiliza su copia sin preguntar al servidor |
revalidate | El servidor | Pasado este tiempo, la siguiente petición recibe la copia vieja y dispara una regeneración en segundo plano |
expire | El servidor | Pasado este tiempo sin visitas, la entrada caduca: la siguiente petición espera a que se regenere |
Figura 1. Los tres tiempos de cacheLife('hours'). Entre revalidate y expire se sirve la copia vieja mientras se regenera detrás (stale-while-revalidate).
El comportamiento entre revalidate y expire se llama stale-while-revalidate: es mejor responder al instante con algo un poco viejo que hacer esperar al usuario. Es lo mismo que hacía la ISR del módulo 02.
4.2 Perfiles predefinidos
Estos son los perfiles de Next.js 16.4, copiados de los tipos instalados (next/cache.d.ts):
| Perfil | stale | revalidate | expire | Úsalo para |
|---|---|---|---|---|
default | 5 min | 15 min | nunca | Si no llamas a cacheLife |
seconds | 30 s | 1 s | 1 min | Datos casi en tiempo real (stock, cotizaciones) |
minutes | 5 min | 1 min | 1 h | Datos que cambian a menudo (pedidos pendientes) |
hours | 5 min | 1 h | 1 día | Listados que cambian varias veces al día (catálogo) |
days | 5 min | 1 día | 1 semana | Fichas de producto, departamentos |
weeks | 5 min | 1 semana | 30 días | Contenidos editoriales |
max | 5 min | 30 días | nunca | Datos que solo cambian cuando tú lo dices (con etiqueta) |
💡 Consejo: La documentación recomienda acompañar siempre
'use cache'de uncacheLife. Sin él se aplicadefault, y quien lea tu código no sabrá si fue una decisión o un olvido.
Si ningún perfil encaja, pasa un objeto. Los tiempos que omitas se toman del perfil default:
'use cache'
cacheLife({
stale: 60, // el navegador la reutiliza 1 minuto
revalidate: 600, // el servidor la regenera como mucho cada 10 minutos
expire: 86400, // sin visitas, caduca al día
})
También puedes definir perfiles con nombre en next.config.ts y usarlos en todo el proyecto: lo verás en el apartado 4.4.
4.3 Las cachés de vida corta se convierten en huecos
Hay un caso especial que conviene conocer. La página /corto usa una caché de un segundo:
// lib/api.ts
// Vida muy corta: Next lo excluye del prerender y lo convierte en un hueco dinámico
export async function estadisticasAlSegundo() {
'use cache'
cacheLife('seconds')
const respuesta = await fetch(`${API}/estadisticas`)
const datos = await respuesta.json()
return { datos, generado: hora() }
}
// app/corto/page.tsx
import { Suspense } from 'react'
import { estadisticasAlSegundo } from '@/lib/api'
async function Estadisticas() {
const { datos, generado } = await estadisticasAlSegundo()
return (
<p id="generado">
{Object.keys(datos).length} indicadores, calculados a las {generado}
</p>
)
}
export default function Corto() {
return (
<main>
<h1>Estadísticas al segundo</h1>
<Suspense fallback={<p>Cargando estadísticas...</p>}>
<Estadisticas />
</Suspense>
</main>
)
}
Aunque tiene caché, la ruta sale como ◐, sin columnas de revalidate, y cada visita (con un segundo de separación) llegó a la API: 3 visitas, 3 peticiones. La razón es que Next considera de vida corta toda caché con el perfil 'seconds', con revalidate: 0 o con expire menor de 5 minutos, y la excluye del armazón: no tiene sentido grabar en el HTML estático algo que caduca en un segundo. Se convierte en un hueco dinámico y, como todo hueco, necesita <Suspense>.
¿Sirve entonces para algo? Sí: si diez usuarios piden la página en el mismo segundo, la API recibe una sola petición. Es una caché para picos de tráfico, no para el armazón.
4.4 Perfiles propios en next.config.ts
Repetir cacheLife({ stale: 300, revalidate: 600, expire: 86400 }) en diez funciones es una invitación a que alguien cambie un número en una y no en las demás. La opción cacheLife de next.config.ts (estable en Next.js 16.4, junto a cacheComponents) da nombre a un juego de tiempos:
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
// Perfiles propios: se usan con cacheLife('catalogo')
cacheLife: {
catalogo: {
stale: 300, // 5 minutos en el navegador
revalidate: 600, // se regenera como mucho cada 10 minutos
expire: 86400, // sin visitas, caduca al día
},
},
}
export default nextConfig
export async function obtenerCatalogo() {
'use cache'
cacheLife('catalogo')
cacheTag('productos')
// ...
}
En el caso práctico, el build mostró ○ /tienda 10m 1d: los 600 s y el día del perfil. Y la respuesta llevaba Cache-Control: s-maxage=600, stale-while-revalidate=85800 (apartado 12.2).
Hay dos comprobaciones que te protegen de las erratas, y las dos se vieron en el laboratorio con cacheLife('catalogos') (con una s de más):
-
TypeScript. Al definir perfiles propios, Next genera tipos con sus nombres, y una errata ya no compila:
app/errores/perfil/page.tsx(5,13): error TS2769: No overload matches this call. The last overload gave the following error. Type '"catalogos"' has no properties in common with type '{ stale?: number | undefined; revalidate?: number | undefined; expire?: number | undefined; }'. -
En ejecución (si te saltas TypeScript), la página falla con un mensaje que te sugiere la configuración que falta:
⨯ Error: Unknown `cacheLife()` profile "catalogos" is not configured in next.config.js module.exports = { cacheLife: { "catalogos": ... } }
También puedes redefinir un perfil predefinido escribiendo uno con el mismo nombre (hours, days…). Hazlo con cuidado: cambiarías el significado de cacheLife('hours') en todo el proyecto, también en el código que no has escrito tú.
💡 Consejo: Nombra los perfiles por lo que representan en tu negocio (
catalogo,informes,cotizaciones) y no por su duración (diezMinutos). Si mañana el catálogo debe renovarse cada cinco minutos, cambias un número en un sitio y el nombre sigue siendo verdad.
5. cacheTag: etiquetar para invalidar 🔄 16.0
🔄 Cambia en Next.js 16.0. En Next.js 15 se llamaba
unstable_cacheTagy solo funcionaba conexperimental.useCache. Si usas Next.js 15: etiqueta losfetchconnext: { tags: ['productos'] }o usa la opcióntagsdeunstable_cache.
Esperar a que un dato caduque solo vale si te da igual cuándo se actualice. Cuando el administrador cambia un precio, quieres descartar ya la entrada vieja. Para eso, la entrada necesita un nombre: una etiqueta.
cacheTag recibe una o varias etiquetas y se llama dentro de la función cacheada:
// lib/api.ts
// Un producto concreto: el id forma parte de la clave de caché
export async function obtenerProducto(id: number) {
'use cache'
cacheLife('days')
cacheTag('productos', `producto-${id}`)
const respuesta = await fetch(`${API}/productos/${id}`)
if (!respuesta.ok) return null
const producto: Producto = await respuesta.json()
return { producto, generado: hora() }
}
Esta función pone dos etiquetas a cada ficha: una general (productos) y otra concreta (producto-7). Así puedes elegir el alcance de la invalidación:
updateTag('producto-7'): descarta solo la ficha del producto 7.updateTag('productos'): descarta todas las fichas y la lista del catálogo, que también lleva la etiquetaproductos.
💡 Consejo: Diseña las etiquetas como diseñarías los nombres de una tabla de base de datos: una general por tipo de dato (
productos,empleados) y una por registro (producto-7,empleado-105). Con esas dos puedes expresar casi cualquier invalidación.
La página de la ficha usa generateStaticParams (módulo 04) para prerenderizar los dos primeros productos:
// app/productos/[id]/page.tsx
import { notFound } from 'next/navigation'
import { obtenerProducto } from '@/lib/api'
export async function generateStaticParams() {
return [{ id: '1' }, { id: '2' }]
}
export default async function FichaProducto({ params }: PageProps<'/productos/[id]'>) {
const { id } = await params
const resultado = await obtenerProducto(Number(id))
if (!resultado) notFound()
const { producto, generado } = resultado
return (
<main>
<h1>{producto.nombre}</h1>
<p id="precio">{producto.precio} €</p>
<p id="generado">Datos obtenidos a las {generado}</p>
</main>
)
}
En el build aparecen /productos/1 y /productos/2 como ○ con 1d / 1w (el perfil 'days'), y la ruta genérica /productos/[id] como ◐: para cualquier otro id, Next sirve el armazón genérico (el App Shell) y rellena la ficha en cuanto la tiene, guardándola para el siguiente visitante. Es la ISR de siempre, con el nombre que le da la documentación de Next 16: ISR with Cache Components.
6. Invalidar: updateTag, revalidateTag, revalidatePath y refresh
Next.js 16 ofrece cuatro funciones para invalidar, todas en next/cache. Leídas en la documentación parecen parecidas. Medidas, se comportan de forma muy distinta, y elegir mal significa que el administrador cambia un precio y no ve su cambio.
6.1 El experimento
La página /panel muestra el precio del producto 1 de dos formas: el que sale de la caché (obtenerProductos()) y el que da la API en ese momento, sin caché:
// app/panel/page.tsx
import { Suspense } from 'react'
import { connection } from 'next/server'
import { API, obtenerProductos } from '@/lib/api'
import {
subirConExpiracionInmediata,
subirConRefresh,
subirConRevalidateTag,
subirConUpdateTag,
subirSinInvalidar,
} from './acciones'
async function PrecioCacheado() {
const { productos, generado } = await obtenerProductos()
return (
<p id="cacheado" className="rounded bg-indigo-50 p-3">
En caché: <strong>{productos[0].precio} €</strong> (obtenido a las {generado})
</p>
)
}
async function PrecioReal() {
await connection()
const producto = await fetch(`${API}/productos/1`).then((r) => r.json())
return (
<p id="real" className="rounded bg-amber-50 p-3">
En la API: <strong>{producto.precio} €</strong>
</p>
)
}
const boton = 'rounded bg-indigo-700 px-3 py-2 text-sm text-white hover:bg-indigo-800'
export default function Panel() {
return (
<main className="max-w-xl space-y-3">
<h1 className="text-2xl font-bold">Panel de precios: Portátil UltraBook 14</h1>
<PrecioCacheado />
<Suspense fallback={<p id="real" className="p-3">Consultando la API...</p>}>
<PrecioReal />
</Suspense>
<p className="text-sm text-slate-600">Cada botón sube el precio 10 € en la API y después:</p>
<div className="flex flex-wrap gap-2">
<form action={subirConUpdateTag}><button id="update" className={boton}>updateTag</button></form>
<form action={subirConRevalidateTag}><button id="revalidate" className={boton}>revalidateTag max</button></form>
<form action={subirConExpiracionInmediata}><button id="expira" className={boton}>revalidateTag expire 0</button></form>
<form action={subirConRefresh}><button id="refresh" className={boton}>refresh</button></form>
<form action={subirSinInvalidar}><button id="nada" className={boton}>sin invalidar</button></form>
</div>
</main>
)
}
Cada botón ejecuta una Server Action (módulo 10, aquí basta saber que es una función del servidor que se llama desde un formulario). Todas suben 10 € el precio en la API y se diferencian solo en la última línea:
// app/panel/acciones.ts
'use server'
import { refresh, revalidateTag, updateTag } from 'next/cache'
import { cambiarPrecio } from '@/lib/api'
export async function subirConUpdateTag() {
await cambiarPrecio(1, 10)
updateTag('productos')
}
export async function subirConRevalidateTag() {
await cambiarPrecio(1, 10)
revalidateTag('productos', 'max')
}
export async function subirConExpiracionInmediata() {
await cambiarPrecio(1, 10)
revalidateTag('productos', { expire: 0 })
}
export async function subirConRefresh() {
await cambiarPrecio(1, 10)
refresh()
}
export async function subirSinInvalidar() {
await cambiarPrecio(1, 10)
}
cambiarPrecio inicia sesión en la API y hace el PUT del producto:
// lib/api.ts (continuación)
export async function login() {
const respuesta = await fetch(`${API}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'ana.garcia@empresa.es', password: 'secreto' }),
})
const { token } = await respuesta.json()
return token as string
}
export async function cambiarPrecio(id: number, incremento: number) {
const token = await login()
const actual = await fetch(`${API}/productos/${id}`).then((r) => r.json())
const respuesta = await fetch(`${API}/productos/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ ...actual, precio: actual.precio + incremento }),
})
if (!respuesta.ok) throw new Error(`La API respondió ${respuesta.status}`)
}

Figura 2. El panel del laboratorio. La caché y la API no coinciden: el precio cambió en la API, pero nadie invalidó la etiqueta productos.
Un script de Playwright pulsó cada botón y leyó los dos precios en cuatro momentos: antes, justo después de la acción (sin recargar), tras una recarga y tras una segunda recarga un segundo después. Esta es la salida real, en el orden en que se ejecutó:
== nada
antes caché=899 € (obtenido a las 11:41:26) api=899 €
tras la acción caché=899 € (obtenido a las 11:41:26) api=899 €
peticiones a la API durante la acción: 3
1.ª recarga caché=899 € (obtenido a las 11:41:26) api=909 €
2.ª recarga caché=899 € (obtenido a las 11:41:26) api=909 €
== refresh
antes caché=899 € (obtenido a las 11:41:26) api=909 €
tras la acción caché=899 € (obtenido a las 11:41:26) api=919 €
peticiones a la API durante la acción: 4
1.ª recarga caché=899 € (obtenido a las 11:41:26) api=919 €
2.ª recarga caché=899 € (obtenido a las 11:41:26) api=919 €
== update
antes caché=899 € (obtenido a las 11:41:26) api=919 €
tras la acción caché=929 € (obtenido a las 11:43:44) api=929 €
peticiones a la API durante la acción: 5
1.ª recarga caché=929 € (obtenido a las 11:43:44) api=929 €
2.ª recarga caché=929 € (obtenido a las 11:43:44) api=929 €
== revalidate
antes caché=929 € (obtenido a las 11:43:44) api=929 €
tras la acción caché=929 € (obtenido a las 11:43:44) api=929 €
peticiones a la API durante la acción: 3
1.ª recarga caché=929 € (obtenido a las 11:43:44) api=939 €
2.ª recarga caché=939 € (obtenido a las 11:43:50) api=939 €
== expira
antes caché=939 € (obtenido a las 11:43:50) api=939 €
tras la acción caché=949 € (obtenido a las 11:43:53) api=949 €
peticiones a la API durante la acción: 5
1.ª recarga caché=949 € (obtenido a las 11:43:53) api=949 €
2.ª recarga caché=949 € (obtenido a las 11:43:53) api=949 €
Las tres peticiones fijas de cada acción son las de cambiarPrecio: login, GET del producto y PUT. Lo que haya de más es Next volviendo a pedir datos para repintar.
Figura 3. Resumen del experimento. “Dato viejo” se refiere siempre a lo que sale de 'use cache'.
6.2 updateTag: lee lo que acabas de escribir 🆕 16.0
🆕 Desde Next.js 16.0. No existe en Next.js 15. Si usas Next.js 15: llama a
revalidateTag('productos')(con un solo argumento) dentro de la Server Action; en 15.5 la respuesta de la acción ya trae el dato nuevo (medido en el apartado 14.5).
updateTag caduca la etiqueta en el acto y hace que la respuesta de la Server Action traiga la página repintada con datos frescos. En la medición, tras pulsar el botón la caché pasó de 919 € a 929 € sin recargar, y la hora de obtenido a las cambió a la de la acción. Las dos peticiones de más son la lista del catálogo (recalculada) y el precio real (el hueco dinámico, repintado).
Es la herramienta para el patrón read-your-own-writes: quien hace un cambio lo ve inmediatamente. Tiene una restricción: solo se puede llamar dentro de una Server Action. En un Route Handler falla; lo comprobamos con un webhook que intentaba usarla:
⨯ Error: updateTag can only be called from within a Server Action. To invalidate cache tags in Route Handlers or other contexts, use revalidateTag instead. See more info here: https://nextjs.org/docs/app/api-reference/functions/updateTag
La petición al webhook respondió con 500.
6.3 revalidateTag: renovar sin hacer esperar a nadie 🔄 16.0
🔄 Cambia en Next.js 16.0. En Next.js 15
revalidateTag(tag)lleva un solo argumento y caduca la etiqueta en el acto. En 16 exige un segundo argumento, el perfil, y con'max'sirve lo viejo mientras regenera. Si usas Next.js 15: llama arevalidateTag('productos')sin segundo argumento; se comporta comoupdateTago comorevalidateTag(tag, { expire: 0 })de la 16.
revalidateTag(etiqueta, perfil) marca la etiqueta como vieja con la semántica stale-while-revalidate: la siguiente petición recibe todavía el dato viejo y dispara la regeneración; la de después ya ve el nuevo. Es exactamente lo que se midió:
- Tras la acción, la página no se repintó: 3 peticiones (las de
cambiarPrecio) y ni siquiera el precio real cambió. - En la 1.ª recarga, la caché mostraba todavía 929 € (viejo) y la API 939 €.
- En la 2.ª recarga, la caché ya mostraba 939 €, obtenido a las 11:43:50, entre las dos recargas.
El segundo argumento indica cuánto tiempo se admite servir lo viejo mientras se regenera. Puede ser el nombre de un perfil o un objeto de cacheLife:
| Llamada | Comportamiento |
|---|---|
revalidateTag('productos', 'max') | El recomendado. Se sirve lo viejo con el margen más largo posible mientras se regenera |
revalidateTag('productos', 'hours') | Igual, con el margen del perfil hours |
revalidateTag('productos', { expire: 0 }) | Nunca se sirve lo viejo: la siguiente lectura espera al dato nuevo |
revalidateTag('productos') | Obsoleto. TypeScript lo marca como error (el parámetro profile es obligatorio en los tipos de 16.4); en tiempo de ejecución se comporta como { expire: 0 } |
En el experimento, { expire: 0 } dentro de una Server Action se comportó igual que updateTag: respuesta repintada con el dato nuevo, 5 peticiones.
¿Cuándo usar revalidateTag con 'max'? Cuando el cambio no lo hace quien está mirando: un catálogo que se actualiza desde el ERP, una ficha editada por otro departamento… Ahí no importa que el primer visitante vea el precio de hace un segundo, y a cambio nadie espera. Y, sobre todo, es la que puedes usar fuera de las Server Actions.
Un webhook para revalidar
Si el catálogo lo actualiza otro sistema, este puede avisar a tu aplicación con una petición HTTP. Un Route Handler (módulo 12) recibe el aviso e invalida la etiqueta:
// app/api/revalidar/route.ts
import { revalidateTag, updateTag } from 'next/cache'
// Webhook: el CMS o el ERP avisa de que han cambiado los productos
export async function POST(request: Request) {
const { searchParams } = new URL(request.url)
if (searchParams.get('modo') === 'update') {
updateTag('productos') // solo vale en Server Actions: veamos qué pasa
} else {
revalidateTag('productos', 'max')
}
return Response.json({ revalidado: true, hora: new Date().toISOString().slice(11, 19) })
}
El modo update es el que dio el error del apartado anterior. El modo normal funcionó así, consultando /catalogo (una ruta ○, estática) justo después:
curl -X POST http://localhost:3001/api/revalidar
{"revalidado":true,"hora":"11:44:37"} 200
UltraBook 14: 949 obtenidos a las 11:43:53
UltraBook 14: 949 obtenidos a las 11:44:38
UltraBook 14: 949 obtenidos a las 11:44:38
[API 11:44:38.204] GET /api/productos?tamano=5
La primera visita recibió el HTML viejo y disparó una única petición a la API en segundo plano; la segunda ya llevaba la hora nueva. Fíjate en algo importante: /catalogo es una ruta estática (○) y aun así se regeneró. La invalidación de una etiqueta alcanza también al HTML prerenderizado que contiene ese dato, no solo a la entrada de la función.
⚠️ Importante: Un webhook como este debe protegerse con un secreto compartido (una cabecera o un parámetro que solo conozcan los dos sistemas). Sin él, cualquiera podría vaciar tu caché a base de peticiones. Lo verás en el módulo 22.
6.4 refresh: repintar sin invalidar 🆕 16.0
🆕 Desde Next.js 16.0. No existe en Next.js 15. Si usas Next.js 15: llama a
router.refresh()desde un Client Component (conuseRouterdenext/navigation) o arevalidatePathcon la ruta actual dentro de la acción.
refresh() pide al router del cliente que vuelva a cargar la página actual al terminar la Server Action, sin tocar ninguna caché. En la medición, tras pulsar el botón el precio real (sin caché) pasó a 919 €, pero el cacheado siguió en 899 €, y así siguió en las recargas.
Úsala cuando la acción cambia algo que no está cacheado (un dato que lees dentro de <Suspense> en cada petición, un contador de la sesión…) y quieres que la pantalla lo refleje sin recargar. Si el dato está en caché, refresh() solo no basta: necesitas updateTag o revalidateTag. Como updateTag, solo funciona dentro de Server Actions.
6.5 revalidatePath
revalidatePath('/catalogo') invalida todo lo cacheado que aparezca en esa ruta, sin necesidad de conocer sus etiquetas. Existe igual en Next.js 15 y en 16. La documentación recomienda preferir las etiquetas, porque son más precisas: revalidatePath('/catalogo') no sabe que la ficha /productos/1 usa el mismo dato, y updateTag('productos') sí.
📘 Concepto: Elige así:
- El usuario cambia algo y debe verlo ya →
updateTagen la Server Action.- Algo cambia fuera (otro sistema, un webhook) y basta con que se vea pronto →
revalidateTag(etiqueta, 'max')en un Route Handler.- Algo cambia fuera y debe desaparecer ya →
revalidateTag(etiqueta, { expire: 0 }).- La acción cambia datos no cacheados →
refresh().
7. Dónde vive lo cacheado
Ahora que has visto la caché funcionar, conviene saber dónde se guarda exactamente, porque explica comportamientos que de otro modo parecen caprichosos.
Figura 4. El resultado de una función cacheada se guarda en hasta tres sitios, cada uno con sus tiempos.
- HTML prerenderizado. Lo que entra en el armazón se escribe en disco (
.next/server/appen un servidor propio) o en el almacenamiento de la plataforma, detrás de la CDN. Se regenera al pasarrevalidateo al invalidar la etiqueta, como viste con/catalogo. - Almacén del servidor. Las entradas que se generan en tiempo de petición (la ficha
/productos/3, por ejemplo) se guardan en memoria, por instancia. Si reiniciasnext start, se pierden. En serverless, cada instancia tiene la suya y duran poco. - Navegador. Cuando el cliente navega con
<Link>, el router guarda el payload recibido y lo reutiliza durantestalesegundos sin preguntar al servidor.
Los tres almacenes pertenecen a un único despliegue. El id del build forma parte de la clave, así que un despliegue nuevo empieza de cero.
7.1 'use cache: remote' y 'use cache: private' 🔄 16.0
🔄 Cambia en Next.js 16.0. En Next.js 15 estas variantes solo existían como experimento junto a
experimental.useCache. Si usas Next.js 15: para datos de la sesión, lee la cookie fuera y pasa su valor como argumento aunstable_cache; para una caché compartida entre instancias, configura uncacheHandlerpara la Data Cache.
Además de 'use cache', hay dos variantes. La documentación de Next.js 16.4 resume cuándo usar cada una:
'use cache' | 'use cache: remote' | 'use cache: private' | |
|---|---|---|---|
| Almacén en el servidor | En memoria (o el manejador default) | El manejador remote | Ninguno |
Puede leer cookies() y headers() | No | No | Sí |
| Para qué | Casi todo | Datos de los huecos muy repetidos, APIs con límite de peticiones | Datos personales que quieres precargar |
'use cache: remote'guarda la entrada en un almacén compartido y duradero (Redis, un KV de la plataforma…) configurado con la opcióncacheHandlersdenext.config.ts. Cada lectura es un viaje por la red, así que solo compensa si muchas peticiones reutilizan la misma entrada. Si no configurascacheHandlers, Next usa pararemoteel mismo almacén en memoria que paradefault, según la documentación de 16.4, así que la directiva funciona en tu ordenador sin infraestructura extra. En el caso práctico (apartado 13) la usarás para el stock, con un manejador propio que registra cada operación, y en el módulo 23 verás cómo conectarla a un almacén real.'use cache: private'puede leercookies()yheaders()directamente, pero su resultado solo se guarda en el navegador, nunca en el servidor. Sirve para que un dato personal entre en el prefetch y la navegación sea instantánea:
// app/privado/page.tsx
import { Suspense } from 'react'
import { cookies } from 'next/headers'
import { cacheLife } from 'next/cache'
async function Recomendaciones() {
'use cache: private'
cacheLife({ stale: 60 })
const tema = (await cookies()).get('tema')?.value ?? 'claro'
return <p>Recomendaciones para el tema {tema}</p>
}
export default function Privado() {
return (
<main>
<h1>Zona personal</h1>
<Suspense fallback={<p>Cargando...</p>}>
<Recomendaciones />
</Suspense>
</main>
)
}
La ruta compila como ◐: el contenido personal nunca entra en el armazón estático, que es compartido por todos los usuarios.
8. Datos de la petición, fechas y connection()
8.1 cookies() no puede ir dentro de 'use cache'
Una entrada de 'use cache' (sin : private) se comparte entre todos los usuarios. Si pudiera leer las cookies, el primero que la generara “contagiaría” su sesión al resto. Por eso Next lo prohíbe. Esta página:
async function Saludo() {
'use cache'
const tema = (await cookies()).get('tema')?.value
return <p>Tema {tema}</p>
}
hizo fallar el build con este mensaje:
⨯ Error: Route "/err-cookies": `cookies()` can't be read inside `"use cache"`. Read it outside the cached function and pass what you need as an argument.
Learn more: https://nextjs.org/docs/messages/next-request-in-use-cache
4 | async function Saludo() {
5 | 'use cache'
> 6 | const tema = (await cookies()).get('tema')?.value
| ^
El propio mensaje da la solución: lee la cookie fuera y pásala como argumento. El argumento entra en la clave, así que habrá una entrada por cada valor distinto (una para claro, otra para oscuro), compartida por todos los usuarios con ese valor:
async function productoParaTema(id: number, tema: string) {
'use cache'
cacheLife('hours')
const producto = await fetch(`${API}/productos/${id}?tema=${tema}`).then((r) => r.json())
return `${producto.nombre} (tema ${tema})`
}
async function EnElShell() {
const tema = (await cookies()).get('tema')?.value ?? 'claro'
return <p>A: {await productoParaTema(3, tema)}</p>
}
Lo mismo vale para headers(), searchParams y params: se leen fuera y se pasan dentro.
⚠️ Importante: Pasar como argumento el id de sesión o el id de usuario crea una entrada por usuario, y en memoria eso puede crecer mucho. Para datos realmente personales, valora
'use cache: private'o no cachear.
8.2 Fechas, números aleatorios y connection()
new Date(), Date.now(), Math.random() o crypto.randomUUID() dan un valor distinto cada vez. Si los usas fuera de una caché, Next no sabe si quieres el valor del build o el de cada petición, y te obliga a decidirlo. Esta página:
export default function Pagina() {
return <p>Son las {new Date().toLocaleTimeString('es-ES')}</p>
}
dio este error en el build:
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
Las cuatro salidas son razonables, según lo que quieras:
| Quieres… | Haz | Ejemplo del laboratorio |
|---|---|---|
| La hora de cada petición | await connection() antes, y el componente dentro de <Suspense> | HoraPeticion en /componente |
| La hora en que se generó la caché | Ponlo dentro de 'use cache' | generado en obtenerProductos() |
| La hora del navegador | Muévelo a un Client Component | Un reloj que se actualiza cada segundo |
| Medir cuánto tarda algo | performance.now(), que Next no vigila | Registro de tiempos |
connection(), de next/server, no devuelve nada útil: su único efecto es decir “lo que viene después depende de la petición”. Es el sustituto explícito del antiguo export const dynamic = 'force-dynamic', pero aplicado a un solo componente en lugar de a toda la ruta. Existe también en Next.js 15, donde sirve para lo mismo.
9. Garantizar que una ruta siga estática: ensureStatic 🆕 16.4
🆕 Desde Next.js 16.4. No existe en Next.js 15. Si usas Next.js 15:
export const dynamic = 'error'hace que el build falle si la ruta usa algo dinámico (comprobado: falla al leercookies()), aunque solo cubre la ruta completa, no las etapas del prefetch.
La portada de la tienda tiene que ser estática: se sirve desde la CDN y debe cargar al instante. Hoy lo es. Pero dentro de tres meses alguien añade un componente que lee la cookie del carrito sin 'use cache', el build sigue funcionando (lo mete en <Suspense>) y la portada pasa de ○ a ◐ sin que nadie se dé cuenta.
ensureStatic convierte esa intención en una regla que comprueba el build. Se exporta desde una página o un layout:
// app/estatica/page.tsx
import { obtenerProductos } from '@/lib/api'
// Garantía: esta ruta debe salir entera del prerender
export const ensureStatic = 'navigation'
export default async function Estatica() {
const { productos } = await obtenerProductos()
return (
<main>
<h1>Portada de la tienda</h1>
<p>{productos.length} productos destacados</p>
</main>
)
}
Esta ruta compila como ○ /estatica 1h 1d. Y si alguien mete un dato sin caché, aunque sea dentro de <Suspense>:
// app/err-ensure/page.tsx
import { Suspense } from 'react'
import { productosSinCache } from '@/lib/api'
export const ensureStatic = 'navigation'
async function Lista() {
const { productos } = await productosSinCache()
return <p>{productos.length} productos</p>
}
export default function Pagina() {
return <Suspense fallback={<p>Cargando...</p>}><Lista /></Suspense>
}
el build se detiene:
Error: Route "/err-ensure": Next.js encountered uncached or runtime data on a route that must be fully static.
This route is configured to be fully static, but some data requires rendering at request time.
Ways to fix this:
- [cache] For uncached data (`fetch`, database calls): cache the access with `"use cache"` (does not apply to `connection()`)
- [remove] Remove the data access
- [client] Read the data on the client
Learn more: https://nextjs.org/docs/messages/static-route-dynamic
ensureStatic admite cinco valores. Los tres intermedios se refieren a las etapas de la navegación que verás en el apartado siguiente:
| Valor | Qué debe ser estático |
|---|---|
'auto' (por defecto) | Nada en especial: comportamiento normal |
'shell' | El App Shell que carga un <Link> normal |
'prefetch' | El App Shell y el prefetch de cada enlace con prefetch={true} |
'navigation' | La ruta completa: carga directa y navegaciones salen del prerender |
false | Nada, y además impide que otro segmento imponga un nivel |
Reglas que conviene recordar, tomadas de la documentación de Next.js 16.4:
- Con
'navigation', el build rechaza datos sin caché,connection(),cookies(),headers(),searchParamsdel servidor y cachés de vida corta (expiremenor de 5 minutos,stalemenor de 30 segundos orevalidate: 0), aunque estén dentro de<Suspense>. - En una ruta dinámica con
'navigation',generateStaticParamses obligatorio y debe devolver al menos un juego completo de parámetros. - Un nivel puesto en un layout se aplica a todas sus rutas. Un hijo puede ser más estricto que el padre, pero no menos: si lo intentas, el build falla con
A child segment cannot override a parent segment with a less-constrained ensureStatic. - Solo funciona con
cacheComponents; sin la opción, el build falla.
💡 Consejo: Pon
export const ensureStatic = 'navigation'en el layout de la parte pública de la tienda (por ejemplo, en un grupo(tienda), módulo 04). Así cualquier página nueva del catálogo nace con la garantía.
10. Etapas de la navegación: prefetch() y navigation() 🆕 16.4
🆕 Desde Next.js 16.4. No existen en Next.js 15, donde tampoco hay Partial Prefetching. Si usas Next.js 15: el prefetch de un
<Link>trae la ruta hasta el primerloading.tsx(o entera conprefetch={true}); para que una parte pesada no se precargue, ponla tras unloading.tsxo cárgala en el cliente.
Con Partial Prefetching (activado con partialPrefetching: true, módulo 15), Next.js carga una ruta en tres etapas:
- App Shell. Un
<Link>normal, en cuanto aparece en pantalla, precarga la parte de la ruta que no depende de la URL: lo estático, lo cacheado y lo que deriva decookies()yheaders()(datos de la sesión). - Prefetch por enlace. Un
<Link prefetch={true}>precarga además lo que depende de la URL de ese enlace (params,searchParams). Cuesta una petición al servidor por enlace. - Navegación. Al hacer clic, se renderiza el resto.
Las dos funciones nuevas de next/cache mueven un subárbol a una etapa posterior:
await prefetch(): lo que va debajo sale del App Shell; se calcula en el prefetch por enlace o en la navegación.await navigation(): lo que va debajo sale del App Shell y del prefetch; solo se calcula al navegar.
Las dos requieren cacheComponents (sin él, lanzan un error) y deben ir en un componente envuelto en <Suspense>, cuyo fallback es lo que se precarga en su lugar.
10.1 El experimento
La página /etapas tiene tres componentes idénticos (todos leen la cookie tema y llaman a una función cacheada con un producto distinto). Solo cambia la primera línea:
// app/etapas/page.tsx
import { Suspense } from 'react'
import { cookies } from 'next/headers'
import { cacheLife, navigation, prefetch } from 'next/cache'
import { API } from '@/lib/api'
async function productoParaTema(id: number, tema: string) {
'use cache'
cacheLife('hours')
const producto = await fetch(`${API}/productos/${id}?tema=${tema}`).then((r) => r.json())
return `${producto.nombre} (tema ${tema})`
}
async function EnElShell() {
const tema = (await cookies()).get('tema')?.value ?? 'claro'
return <p>A: {await productoParaTema(3, tema)}</p>
}
async function TrasPrefetch() {
await prefetch()
const tema = (await cookies()).get('tema')?.value ?? 'claro'
return <p>B: {await productoParaTema(4, tema)}</p>
}
async function TrasNavigation() {
await navigation()
const tema = (await cookies()).get('tema')?.value ?? 'claro'
return <p>C: {await productoParaTema(5, tema)}</p>
}
export default function Etapas() {
return (
<main>
<h1>Etapas de navegación</h1>
<Suspense fallback={<p>A cargando...</p>}><EnElShell /></Suspense>
<Suspense fallback={<p>B cargando...</p>}><TrasPrefetch /></Suspense>
<Suspense fallback={<p>C cargando...</p>}><TrasNavigation /></Suspense>
</main>
)
}
Dos páginas enlazan a /etapas: /enlaces con un <Link> normal y /enlaces-completos con <Link prefetch={true}>. Un script de Playwright abrió cada una con una cookie distinta (para que no se reutilizaran las entradas), esperó tres segundos sin tocar nada y luego hizo clic. Esto es lo que registró la API:
== /enlaces (tema azul)
tras cargar el enlace (prefetch): [ '/api/productos/3?tema=azul' ]
peticiones RSC del prefetch: 2
tras hacer clic (navegación): [ '/api/productos/5?tema=azul', '/api/productos/4?tema=azul' ]
== /enlaces-completos (tema verde)
tras cargar el enlace (prefetch): [ '/api/productos/3?tema=verde', '/api/productos/4?tema=verde' ]
peticiones RSC del prefetch: 3
tras hacer clic (navegación): [ '/api/productos/5?tema=verde' ]
Figura 5. Cada componente se calculó exactamente en la etapa que dice la documentación.
Exactamente lo previsto:
- A (sin marca) depende de una cookie, así que forma parte del App Shell: se calculó en cuanto el enlace apareció en pantalla, con los dos tipos de
<Link>. - B (
prefetch()) salió del App Shell: con un<Link>normal esperó al clic; conprefetch={true}se calculó en el prefetch, a cambio de una petición RSC más. - C (
navigation()) esperó al clic en los dos casos.
¿Para qué sirve todo esto? Para controlar el coste del prefetch. Una página con 40 enlaces visibles dispara 40 prefetches. Si cada uno calcula algo caro (recomendaciones, un gráfico…), estás pagando 40 veces por algo que quizá nadie mire. Con prefetch() o navigation() mueves ese trabajo a cuando hay más certeza de que hace falta. La regla general: lo barato y probable, pronto; lo caro e improbable, tarde.
11. La trampa de la caché del build
Durante el laboratorio apareció un comportamiento que no esperábamos y que conviene conocer. Tras varios cambios de precio, se volvió a ejecutar next build… y el panel mostraba 899 €, el precio original, cuando la API ya tenía 979 € (es la captura de la figura 2). En el registro de la API no había ninguna petición durante ese build.
La explicación está en la carpeta .next/cache/fetch-cache. Durante el prerender, Next.js 16 guarda en disco las respuestas de los fetch que se hacen dentro de 'use cache', con su propia vida (en el laboratorio, revalidate: 900, los 15 minutos del perfil default). Un build posterior dentro de ese margen reutiliza las respuestas guardadas sin llamar a la API:
http://127.0.0.1:3409/api/productos?categoriaId=2 900 []
http://127.0.0.1:3409/api/productos/1 900 []
http://127.0.0.1:3409/api/estadisticas 900 []
http://127.0.0.1:3409/api/productos/2 900 []
http://127.0.0.1:3409/api/productos?tamano=5 900 []
http://127.0.0.1:3409/api/productos?categoriaId=3 900 []
En producción no suele notarse, porque cada despliegue arranca de un entorno limpio o los datos no cambian tan deprisa. Pero en tu ordenador, mientras pruebas, puede volverte loco.
⚠️ Importante: Si un build local muestra datos más viejos que los de tu API, borra
.next/cache/fetch-cache(o la carpeta.nextentera) y vuelve a compilar. Y no confundas esta caché del build con la de'use cache'en tiempo de ejecución: trasupdateTag, el servidor sí llamó a la API, como viste en el apartado 6.
12. Depurar la caché
Una caché que no entiendes es peor que no tener caché: sirve datos viejos y no sabes por qué. Por suerte, Next.js deja rastro de todo lo que guarda. Este apartado reúne las cinco herramientas que usamos en el laboratorio, de la más rápida a la más detallada. Los ejemplos salen del caso práctico del apartado 13 (la tienda en /tienda, las fichas en /tienda/[id] y la administración en /admin).
12.1 La tabla de next build
Es lo primero que debes mirar. Cada línea responde a “¿qué ha podido prerenderizar Next?”:
| Símbolo | Significa | Qué buscar si no lo esperabas |
|---|---|---|
○ Static | Toda la ruta salió del prerender | Nada: es lo mejor que puede pasar |
◐ Partial Prerender | Hay armazón estático y al menos un hueco | Qué componente lee cookies(), headers(), searchParams, connection() o un dato sin caché |
ƒ Dynamic | No hay armazón: todo se renderiza por petición | En 16, casi solo Route Handlers que no son GET o leen datos de la petición |
| Columnas Revalidate / Expire | La vida más corta de lo cacheado en el armazón | Un perfil más corto de lo que creías en algún componente |
Hay un matiz que confunde al principio. En el caso práctico, la ficha /tienda/1 está en generateStaticParams y su dato está en caché, y aun así sale como ◐. La razón es el componente del stock, que llama a connection(): es un hueco, y basta un hueco para que la ruta sea ◐. El armazón (nombre, descripción y precio) sí está prerenderizado, y por eso aparecen las columnas 1d 1w.
Si una ruta sale ◐ y no sabes por qué, comenta los componentes de uno en uno y vuelve a compilar, o usa next build --debug-prerender, que da trazas completas cuando el prerender falla.
12.2 Las cabeceras de la respuesta
Con curl -D - (o la pestaña Network de las herramientas del navegador) puedes ver las cabeceras que Next añade a cada respuesta. Estas son las reales de next start en el caso práctico:
curl -s -D - -o /dev/null http://localhost:3001/tienda
== /tienda (○)
HTTP/1.1 200 OK
x-nextjs-cache: HIT
x-nextjs-prerender: 1
x-nextjs-stale-time: 300
Cache-Control: s-maxage=600, stale-while-revalidate=85800
ETag: "vqfa12kmo8a1x"
== /tienda/1 (◐, en generateStaticParams)
HTTP/1.1 200 OK
x-nextjs-stale-time: 300
x-nextjs-prerender: 1
x-nextjs-postponed: 1
Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
== /tienda/999 (○, notFound() en el prerender)
HTTP/1.1 404 Not Found
x-nextjs-cache: HIT
x-nextjs-prerender: 1
x-nextjs-stale-time: 300
Cache-Control: s-maxage=86400, stale-while-revalidate=518400
== /api/stock/1 (ƒ)
HTTP/1.1 200 OK
Cómo se leen:
| Cabecera | Qué te dice |
|---|---|
x-nextjs-cache: HIT | El HTML salió entero del almacén de rutas prerenderizadas. Otros valores posibles son STALE (servido viejo mientras se regenera) y MISS |
x-nextjs-prerender: 1 | Hay un prerender detrás de esta respuesta (entero o como armazón) |
x-nextjs-postponed: 1 | Es un ◐: se envió el armazón y el resto se está completando en esta misma respuesta |
x-nextjs-stale-time | El stale que el navegador aplicará en su caché del router (300 s: el de los perfiles) |
Cache-Control: s-maxage=600, stale-while-revalidate=85800 | Lo que la CDN puede hacer: guardar 600 s (el revalidate del perfil catalogo) y servir viejo hasta completar 86 400 s (el expire) |
Cache-Control: private, no-cache, no-store... | La CDN no debe guardar la respuesta completa, porque lleva huecos que dependen de la petición |
Fíjate en el Cache-Control de /tienda: s-maxage=600 y stale-while-revalidate=85800 suman 86 400 segundos. Son los tiempos del perfil catalogo que definimos en next.config.ts (apartado 4.4) traducidos al idioma de las CDN.
12.3 La carpeta .next
Tras next build, lo prerenderizado está en .next/server/app. Para la tienda:
.next/server/app/tienda.html el HTML del armazón
.next/server/app/tienda.meta estado, cabeceras y etiquetas
.next/server/app/tienda/1.html la ficha 1 (con el hueco del stock pendiente)
.next/server/app/tienda/1.meta
.next/server/app/tienda/999.html la ficha inexistente
.next/server/app/tienda/999.meta
.next/server/app/tienda/[id].html el App Shell para ids desconocidos
Los ficheros .meta son JSON y son la mejor respuesta a “¿qué etiquetas tiene esta página?”:
{
"status": 200,
"headers": {
"x-nextjs-stale-time": "300",
"x-nextjs-prerender": "1",
"x-next-cache-tags": "_N_T_/layout,_N_T_/tienda/layout,_N_T_/tienda/page,_N_T_/tienda,productos"
}
}
x-next-cache-tags lista las etiquetas de la página: las tuyas (productos) y unas implícitas que empiezan por _N_T_ y corresponden a la ruta y sus layouts. Esas implícitas son las que usa revalidatePath: invalidar /tienda es invalidar la etiqueta _N_T_/tienda. La ficha 1 tiene ...,_N_T_/tienda/1,productos,producto-1, y la 999 tiene "status": 404. El .meta de una ficha ◐ incluye además un campo postponed con el estado que React necesita para completar el hueco en cada petición.
Cuando una página se regenera en ejecución (por tiempo o por una etiqueta), next start reescribe estos ficheros. En el laboratorio, la ficha 1 regenerada a las 12:34:29 tras un updateTag seguía mostrando esa hora después de reiniciar el servidor: en un servidor propio, la regeneración persiste en disco.
💡 Consejo:
.next/cache/fetch-cache(apartado 11) guarda las respuestas defetchdel build;.next/server/appguarda las páginas. Si algo sale viejo y no sabes de dónde, mira las dos, o bórralas y vuelve a compilar.
12.4 El registro detallado: NEXT_PRIVATE_DEBUG_CACHE
La documentación de Next.js 16.4 describe una variable de entorno que hace que el servidor cuente lo que hace con la caché:
NEXT_PRIVATE_DEBUG_CACHE=1 npx next start -p 3001
Esto es lo que escribió next start al visitar /tienda/1 por primera vez (las claves están recortadas):
use-cache: initializing cache handlers
use-cache: setting "default" cache handler from default
use-cache: setting "remote" cache handler from default
use-cache: setting cache handler for "remote"
FileSystemCache: get /route-cache/APP_PAGE/8d0768d0.../$/tienda/1 APP_PAGE false
'use cache' Resume Data Cache entry found ["c0f7cf1634...",[1],["79qVYYY-..."]]
'use cache: remote' Resume Data Cache entry not found ["c09f55a31f...",[1],["79qVYYY-..."]] (1 entries)
'use cache: remote' registering as intra-request invocation leader [...]
'use cache: remote' registering as cross-request invocation leader [...]
'use cache: remote' Resume Data Cache entry saved [...]
pending revalidates promise finished for: /tienda/1
Se aprende mucho de estas líneas:
- La forma de la clave.
["c0f7cf1634...",[1],["79qVYYY-..."]]es el identificador de la función (obtenerProducto), los argumentos ([1]) y un tercer elemento ligado al build. Lo que explicamos en el apartado 3.4, a la vista. obtenerProducto(1)se encontró en los datos guardados con el prerender (Resume Data Cache): por eso no hubo petición a la API para el nombre y el precio.obtenerStock(1)no estaba y se calculó. El “líder” (leader) es la invocación que hace el trabajo; si otras peticiones simultáneas piden la misma clave, esperan a la líder en lugar de llamar también a la API.
⚠️ Importante: El prefijo
NEXT_PRIVATE_indica que es una herramienta interna: el formato de los mensajes puede cambiar entre versiones. Úsala para entender y depurar, nunca para automatizar nada. Ennext dev, además, losconsole.logque hagas dentro de una función cacheada aparecen con el prefijoCache.
12.5 Un manejador de caché que lo cuenta todo
La herramienta más precisa es sustituir el almacén por uno tuyo que escriba cada operación. La opción cacheHandlers de next.config.ts lo permite (lo verás completo en el apartado 13.3). El nuestro, para 'use cache: remote', escribió esto durante el recorrido del caso práctico:
[cache 12:30:41.759] get MISS ...[1]
[cache 12:30:43.286] set ...[1] 47 bytes revalidate=60 expire=3600 etiquetas=stock-1
[cache 12:30:55.686] get HIT ...[1] etiquetas=stock-1
[cache 12:34:25.995] updateTags producto-1,productos inmediato
[cache 12:34:35.752] updateTags stock-1 inmediato
[cache 12:34:39.323] get EXPIRADA por stock-1
[cache 12:34:40.245] set ...[1] 46 bytes revalidate=60 expire=3600 etiquetas=stock-1
Lo ves todo: el primer fallo, la escritura con los tiempos del perfil 'minutes' (60 s y 3600 s), los aciertos, las invalidaciones que llegan desde las Server Actions (updateTag llama a updateTags del manejador sin duraciones, es decir, caducidad inmediata) y la siguiente lectura que encuentra la entrada caducada. Fíjate en que updateTags producto-1,productos llega también a este manejador, aunque ninguna de sus entradas tenga esas etiquetas: Next avisa a todos los manejadores y cada uno decide.
12.6 Resumen: por qué mi ruta sale así
| Síntoma | Pregunta que debes hacerte | Herramienta |
|---|---|---|
◐ en lugar de ○ | ¿Qué componente lee algo de la petición o un dato sin caché? | Tabla del build, --debug-prerender, ensureStatic = 'navigation' para que el build lo señale |
| La API recibe más peticiones de las previstas | ¿Es un perfil de vida corta? ¿Son prefetches de enlaces? ¿La clave cambia en cada llamada? | Registro de la API, NEXT_PRIVATE_DEBUG_CACHE |
| Un dato no se actualiza tras invalidar | ¿La función tiene de verdad esa etiqueta? | x-next-cache-tags en el .meta, manejador con registro |
| Datos viejos tras un build | ¿Viene de .next/cache/fetch-cache? | Borrar la carpeta y recompilar |
| La CDN no guarda la página | ¿Tiene huecos? | Cache-Control de la respuesta |
13. Caso práctico: la tienda EMPRESA de punta a punta
Juntemos todo en una versión completa de la parte pública de la aplicación EMPRESA, con su administración. Los requisitos:
- Catálogo (
/tienda): estático, servido sin tocar la API, con un perfil de caché propio de la empresa, y protegido para que nadie lo vuelva dinámico por descuido. - Fichas (
/tienda/[id]): los productos más vendidos se generan en el build y los demás la primera vez que alguien los visita. Un id inexistente debe dar 404. - Stock en la ficha: debe estar al día (como mucho, un minuto de retraso) aunque la ficha sea estática, y las consultas se comparten entre todas las instancias del servidor.
- Administración (
/admin): siempre datos actuales. Cuando se cambia un precio, la tienda y la ficha lo reflejan en la siguiente visita; cuando se cambia el stock, solo cambia el stock.

Figura 6. El catálogo del caso práctico, servido desde el armazón estático.
13.1 La capa de datos
Todo el acceso a la API vive en lib/tienda.ts. Cada función declara su política de caché en sus primeras líneas, y quien lea el fichero sabe cuánto vale cada dato sin buscar en otra parte:
// lib/tienda.ts
import 'server-only'
import { cacheLife, cacheTag } from 'next/cache'
export const API = process.env.API_URL ?? 'http://localhost:3000/api'
export type Producto = {
productoId: number
nombre: string
descripcion: string
precio: number
stock: number
categoria: string
}
const hora = () => new Date().toISOString().slice(11, 19)
// Catálogo: perfil propio 'catalogo' definido en next.config.ts
export async function obtenerCatalogo() {
'use cache'
cacheLife('catalogo')
cacheTag('productos')
const respuesta = await fetch(`${API}/productos?tamano=8`)
const { contenido }: { contenido: Producto[] } = await respuesta.json()
return { productos: contenido, generado: hora() }
}
// Ficha: un día, con etiqueta general y etiqueta por producto
export async function obtenerProducto(id: number) {
'use cache'
cacheLife('days')
cacheTag('productos', `producto-${id}`)
const respuesta = await fetch(`${API}/productos/${id}`)
if (!respuesta.ok) return null
const producto: Producto = await respuesta.json()
return { producto, generado: hora() }
}
// Stock: cambia a menudo y se lee en cada petición; se comparte en un almacén remoto
export async function obtenerStock(id: number) {
'use cache: remote'
cacheLife('minutes')
cacheTag(`stock-${id}`)
const respuesta = await fetch(`${API}/productos/${id}`)
const { stock }: Producto = await respuesta.json()
return { stock, consultado: hora() }
}
async function login() {
const respuesta = await fetch(`${API}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'ana.garcia@empresa.es', password: 'secreto' }),
})
const { token } = await respuesta.json()
return token as string
}
export async function guardarProducto(id: number, cambios: Partial<Producto>) {
const token = await login()
const actual = await fetch(`${API}/productos/${id}`).then((r) => r.json())
const respuesta = await fetch(`${API}/productos/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ ...actual, ...cambios }),
})
if (!respuesta.ok) throw new Error(`La API respondió ${respuesta.status}`)
}
| Función | Directiva | Vida | Etiquetas | Por qué |
|---|---|---|---|---|
obtenerCatalogo | 'use cache' | catalogo: 10 min / 1 día | productos | Lo ve todo el mundo y cambia poco |
obtenerProducto | 'use cache' | days | productos, producto-N | Cambia poco; se invalida al editarlo |
obtenerStock | 'use cache: remote' | minutes: 1 min / 1 h | stock-N | Se lee en cada petición; un minuto de retraso es aceptable y el almacén se comparte |
13.2 La configuración
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
// Perfiles propios: se usan con cacheLife('catalogo')
cacheLife: {
catalogo: {
stale: 300, // 5 minutos en el navegador
revalidate: 600, // se regenera como mucho cada 10 minutos
expire: 86400, // sin visitas, caduca al día
},
},
// Manejador para 'use cache: remote' (aquí, uno en memoria que escribe lo que hace)
cacheHandlers: {
remote: require.resolve('./cache-handlers/registro.js'),
},
}
export default nextConfig
(El proyecto de create-next-app añade además la regla de Turbopack para Tailwind; la omitimos aquí.)
13.3 El manejador de caché remoto
En producción, cacheHandlers.remote apuntaría a un módulo que guarda en Redis o en el almacén de tu plataforma; en Vercel y otros proveedores ya viene configurado. Para aprender, usamos uno en memoria que escribe todo lo que hace. Está basado en el ejemplo In-memory handler de la documentación de Next.js 16.4, con mensajes en español:
// cache-handlers/registro.js
// Manejador de caché en memoria que registra cada lectura y escritura.
// Solo para aprender y depurar: no tiene límite de tamaño.
const entradas = new Map()
const pendientes = new Map()
const etiquetas = new Map() // etiqueta -> { stale, expired } en milisegundos
const hora = () => new Date().toISOString().slice(11, 23)
const corta = (clave) => clave.slice(-40)
function flujo(bytes) {
return new ReadableStream({
start(controlador) {
controlador.enqueue(bytes)
controlador.close()
},
})
}
module.exports = {
async get(clave) {
const pendiente = pendientes.get(clave)
if (pendiente) await pendiente
const entrada = entradas.get(clave)
if (!entrada) {
console.log(`[cache ${hora()}] get MISS ...${corta(clave)}`)
return undefined
}
let revalidate = entrada.revalidate
for (const etiqueta of entrada.tags) {
const { stale = 0, expired = 0 } = etiquetas.get(etiqueta) ?? {}
if (expired > entrada.timestamp && expired <= Date.now()) {
console.log(`[cache ${hora()}] get EXPIRADA por ${etiqueta}`)
return undefined
}
if (stale > entrada.timestamp) revalidate = -1
}
console.log(`[cache ${hora()}] get HIT ...${corta(clave)} etiquetas=${entrada.tags.join(',')}`)
return { ...entrada, revalidate, value: flujo(entrada.value) }
},
async set(clave, entradaPendiente) {
let resolver
pendientes.set(clave, new Promise((r) => (resolver = r)))
try {
const entrada = await entradaPendiente
const value = new Uint8Array(await new Response(entrada.value).arrayBuffer())
entradas.set(clave, { ...entrada, value })
console.log(
`[cache ${hora()}] set ...${corta(clave)} ${value.length} bytes ` +
`revalidate=${entrada.revalidate} expire=${entrada.expire} etiquetas=${entrada.tags.join(',')}`,
)
} finally {
resolver()
pendientes.delete(clave)
}
},
async refreshTags() {},
async getExpiration(lista) {
return Math.max(...lista.map((e) => etiquetas.get(e)?.expired ?? 0), 0)
},
async updateTags(lista, duraciones) {
const ahora = Date.now()
for (const etiqueta of lista) {
const marcas = etiquetas.get(etiqueta) ?? {}
if (duraciones) {
marcas.stale = ahora
if (duraciones.expire !== undefined) marcas.expired = ahora + duraciones.expire * 1000
} else {
marcas.expired = ahora
}
etiquetas.set(etiqueta, marcas)
}
console.log(`[cache ${hora()}] updateTags ${lista.join(',')} ${duraciones ? JSON.stringify(duraciones) : 'inmediato'}`)
},
}
Un manejador es un objeto con cinco métodos:
| Método | Cuándo lo llama Next | Qué debe hacer |
|---|---|---|
get(clave) | Al llamar a una función cacheada | Devolver la entrada (con su valor como ReadableStream) o undefined |
set(clave, promesa) | Tras calcular una entrada | Esperar la promesa y guardar el valor como bytes, nunca el flujo |
updateTags(etiquetas, duraciones) | Con updateTag (sin duraciones) o revalidateTag (con ellas) | Anotar cuándo caduca cada etiqueta |
getExpiration(etiquetas) | Para comprobar etiquetas implícitas | Devolver la marca de caducidad más reciente |
refreshTags() | Al empezar cada petición | Sincronizar el estado de las etiquetas si vive en otro sitio |
⚠️ Importante: Este manejador vive en la memoria de un proceso y no tiene límite de tamaño: no aporta nada que no haga el de serie y no sirve para producción. Su valor es didáctico: te deja ver la caché. Uno real guardaría las entradas y las marcas de etiquetas en un almacén compartido, de forma que todas las instancias del servidor vean lo mismo.
13.4 Las páginas
La tienda, con la garantía de ensureStatic:
// app/tienda/page.tsx
import Link from 'next/link'
import { obtenerCatalogo } from '@/lib/tienda'
// La portada de la tienda debe salir entera del prerender
export const ensureStatic = 'navigation'
export default async function Tienda() {
const { productos, generado } = await obtenerCatalogo()
return (
<main className="mx-auto max-w-4xl">
<h1 className="mb-4 text-2xl font-bold">Tienda EMPRESA</h1>
<ul className="grid grid-cols-2 gap-3 md:grid-cols-4">
{productos.map((p) => (
<li key={p.productoId} className="rounded-lg border border-slate-200 bg-white p-3 shadow-sm">
<Link href={`/tienda/${p.productoId}`} className="font-semibold text-indigo-800 hover:underline">
{p.nombre}
</Link>
<p className="text-sm text-slate-500">{p.categoria}</p>
<p className="mt-2 text-lg font-bold">{p.precio} €</p>
</li>
))}
</ul>
<p id="generado" className="mt-4 text-xs text-slate-500">Catálogo generado a las {generado}</p>
</main>
)
}
La ficha, con un armazón cacheado y un hueco para el stock:
// app/tienda/[id]/page.tsx
import { Suspense } from 'react'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { connection } from 'next/server'
import { obtenerProducto, obtenerStock } from '@/lib/tienda'
export async function generateStaticParams() {
// Los más vendidos, y un id que no existe para ver qué pasa con notFound()
return [{ id: '1' }, { id: '2' }, { id: '999' }]
}
async function Stock({ id }: { id: number }) {
await connection() // el stock se consulta en cada petición...
const { stock, consultado } = await obtenerStock(id) // ...pero se comparte un minuto
return (
<p id="stock" className={stock > 0 ? 'text-green-700' : 'text-red-700'}>
{stock > 0 ? `${stock} unidades disponibles` : 'Agotado'} (consultado a las {consultado})
</p>
)
}
export default async function FichaProducto({ params }: PageProps<'/tienda/[id]'>) {
const { id } = await params
const resultado = await obtenerProducto(Number(id))
if (!resultado) notFound()
const { producto, generado } = resultado
return (
<main className="mx-auto max-w-xl space-y-2 rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
<Link href="/tienda" className="text-sm text-indigo-700">← Volver a la tienda</Link>
<h1 className="text-2xl font-bold">{producto.nombre}</h1>
<p className="text-slate-600">{producto.descripcion}</p>
<p id="precio" className="text-3xl font-bold">{producto.precio} €</p>
<Suspense fallback={<p id="stock" className="text-slate-400">Comprobando stock...</p>}>
<Stock id={producto.productoId} />
</Suspense>
<p id="generado" className="text-xs text-slate-500">Ficha generada a las {generado}</p>
</main>
)
}
// app/tienda/[id]/not-found.tsx
import Link from 'next/link'
export default function ProductoNoEncontrado() {
return (
<main className="mx-auto max-w-xl">
<h1 className="text-2xl font-bold">Ese producto no existe</h1>
<Link href="/tienda" className="text-indigo-700">Volver a la tienda</Link>
</main>
)
}
Fíjate en el hueco del stock: connection() lo saca del armazón (se calcula en cada petición), pero obtenerStock está cacheado. El resultado es una ficha que se sirve al instante desde el armazón y cuyo stock tiene como mucho un minuto de antigüedad, con la API protegida de las ráfagas de visitas.

Figura 7. La ficha tras el recorrido de la administración: precio nuevo (armazón regenerado por updateTag) y stock agotado (hueco con 'use cache: remote').
La administración no cachea nada: lo que ve el administrador debe ser siempre el estado real.
// app/admin/page.tsx
import { Suspense } from 'react'
import { connection } from 'next/server'
import { API, type Producto } from '@/lib/tienda'
import { cambiarPrecio, cambiarStock } from './acciones'
// La administración siempre lee datos actuales: nada de caché aquí
async function TablaProductos() {
await connection()
const { contenido }: { contenido: Producto[] } = await fetch(`${API}/productos?tamano=4`).then((r) =>
r.json(),
)
return (
<table className="w-full bg-white text-left text-sm">
<thead>
<tr className="bg-indigo-900 text-white">
<th className="p-2">Producto</th><th className="p-2">Precio</th><th className="p-2">Stock</th>
</tr>
</thead>
<tbody>
{contenido.map((p) => (
<tr key={p.productoId} className="border-b border-slate-200">
<td className="p-2">{p.productoId}. {p.nombre}</td>
<td className="p-2">{p.precio} €</td>
<td className="p-2">{p.stock}</td>
</tr>
))}
</tbody>
</table>
)
}
const campo = 'w-24 rounded border border-slate-300 px-2 py-1'
const boton = 'rounded bg-indigo-700 px-3 py-1 text-white'
export default function Admin() {
return (
<main className="mx-auto max-w-xl space-y-4">
<h1 className="text-2xl font-bold">Administración de la tienda</h1>
<Suspense fallback={<p>Cargando productos...</p>}>
<TablaProductos />
</Suspense>
<form action={cambiarPrecio} className="flex items-center gap-2">
<input name="id" defaultValue="1" className={campo} aria-label="Id" />
<input name="precio" placeholder="Precio" className={campo} aria-label="Precio" />
<button id="guardar-precio" className={boton}>Cambiar precio</button>
</form>
<form action={cambiarStock} className="flex items-center gap-2">
<input name="id" defaultValue="1" className={campo} aria-label="Id" />
<input name="stock" placeholder="Stock" className={campo} aria-label="Stock" />
<button id="guardar-stock" className={boton}>Cambiar stock</button>
</form>
</main>
)
}
// app/admin/acciones.ts
'use server'
import { updateTag } from 'next/cache'
import { guardarProducto } from '@/lib/tienda'
export async function cambiarPrecio(formData: FormData) {
const id = Number(formData.get('id'))
const precio = Number(formData.get('precio'))
await guardarProducto(id, { precio })
updateTag(`producto-${id}`) // la ficha de ese producto
updateTag('productos') // y el catálogo
}
export async function cambiarStock(formData: FormData) {
const id = Number(formData.get('id'))
const stock = Number(formData.get('stock'))
await guardarProducto(id, { stock })
updateTag(`stock-${id}`) // solo el stock: el catálogo y la ficha no cambian
}

Figura 8. La administración: tabla siempre actual y dos acciones con invalidaciones distintas.
En una aplicación real, estas acciones comprobarían que quien las llama es administrador (módulo 17) y validarían los datos (módulo 11). Aquí nos centramos en la caché.
Para completar, el carrito de la compra (que verás en módulos posteriores) puede preguntar por el stock con un Route Handler que reutiliza la misma función cacheada:
// app/api/stock/[id]/route.ts
import { obtenerStock } from '@/lib/tienda'
// GET dinámico: lo usa el carrito para comprobar el stock antes de comprar
export async function GET(_request: Request, { params }: RouteContext<'/api/stock/[id]'>) {
const { id } = await params
return Response.json(await obtenerStock(Number(id)))
}
13.5 El build
Route (app) Revalidate Expire
┌ ○ /
├ ○ /_not-found
├ ◐ /admin
├ ƒ /api/stock/[id]
├ ○ /tienda 10m 1d
└ /tienda/[id]
├ ◐ /tienda/[id]
├ ◐ /tienda/1 1d 1w
├ ◐ /tienda/2 1d 1w
└ ○ /tienda/999 1d 1w
Y lo que pidió el build a la API (para la tienda): /api/productos/1, /api/productos/2, /api/productos/999 y /api/productos?tamano=8. Ninguna petición de stock: el hueco no se calcula en el build.
| Ruta | Símbolo | Por qué |
|---|---|---|
/tienda | ○ 10m 1d | Todo cacheado; las columnas son el perfil catalogo |
/tienda/1, /tienda/2 | ◐ 1d 1w | Armazón con el perfil days y hueco del stock |
/tienda/999 | ○ 1d 1w | notFound() durante el prerender: la página 404 entera es estática |
/tienda/[id] | ◐ | App Shell para los ids que no están en la lista |
/admin | ◐ | Título y formularios en el armazón; la tabla es un hueco |
/api/stock/[id] | ƒ | Lee params sin generateStaticParams: se ejecuta en cada petición |
13.6 Peticiones medidas
Con next start en marcha, visitamos /tienda, /tienda/1 y /tienda/3 tres veces cada una (nueve visitas). La API recibió dos peticiones:
[API 12:32:11.730] GET /api/productos/1
[API 12:32:14.960] GET /api/productos/3
Las dos son del stock (obtenerStock), y el registro del manejador remoto explica por qué solo dos: la entrada del stock 1 tenía más de un minuto y la primera visita la regeneró en segundo plano (get HIT seguido de set); las demás visitas fueron get HIT. El catálogo y la ficha 1 salieron del armazón, y la ficha 3 ya se había generado en una visita anterior.
13.7 El recorrido de la administración
Un script de Playwright hizo el recorrido completo: mirar la tienda y la ficha, cambiar el precio del producto 1 a 1001 € desde /admin, volver a mirar, cambiar su stock a 0 y mirar otra vez. Salida real:
-- estado inicial
tienda: Portátil UltraBook 14Portátiles899 € | Catálogo generado a las 12:28:36
ficha: 899 € | 25 unidades disponibles (consultado a las 12:32:11) | Ficha generada a las 12:28:33
peticiones a la API: GET /api/productos/1, GET /api/productos/4, GET /api/productos/5, GET /api/productos/6, GET /api/productos/7
-- cambiar el precio del producto 1 a 1001
peticiones a la API: GET /api/productos?tamano=4, POST /api/auth/login, GET /api/productos/1, PUT /api/productos/1, GET /api/productos?tamano=4
-- después del precio
tienda: Portátil UltraBook 14Portátiles1001 € | Catálogo generado a las 12:34:27
ficha: 1001 € | 25 unidades disponibles (consultado a las 12:34:20) | Ficha generada a las 12:34:29
peticiones a la API: GET /api/productos?tamano=8, GET /api/productos/1, GET /api/productos/2, GET /api/productos/3, GET /api/productos/4
-- cambiar el stock del producto 1 a 0
peticiones a la API: GET /api/productos?tamano=4, POST /api/auth/login, GET /api/productos/1, PUT /api/productos/1, GET /api/productos?tamano=4
-- después del stock
tienda: Portátil UltraBook 14Portátiles1001 € | Catálogo generado a las 12:34:27
ficha: 1001 € | Agotado (consultado a las 12:34:40) | Ficha generada a las 12:34:29
peticiones a la API: GET /api/productos/1, GET /api/productos/5, GET /api/productos/6, GET /api/productos/7, GET /api/productos/8
Lo que demuestra, paso a paso:
- Cambio de precio.
updateTag('producto-1')yupdateTag('productos')caducaron el catálogo y la ficha: en la siguiente visita, los dos mostraban 1001 € con horas de generación nuevas (12:34:27 y 12:34:29). El stock no se tocó: siguió con su hora de consulta. - Cambio de stock.
updateTag('stock-1')solo afectó al hueco: el stock pasó a “Agotado” (consultado a las 12:34:40), pero el catálogo y la ficha conservaron sus horas de generación. Es la ventaja de tener etiquetas separadas: un cambio de stock no obliga a regenerar páginas. - Las peticiones que no esperabas. Las llamadas a
/api/productos/4,/5,/6… no las hizo el usuario: son los prefetches de los<Link>del catálogo, que generan en segundo plano las fichas que todavía no existían. Y tras el cambio de precio se volvieron a pedir/2,/3,/4: elupdateTag('productos')había caducado todas las fichas, porque todas llevan la etiquetaproductos.
Este último punto es una lección de diseño. Cambiar el precio de un producto no debería invalidar las fichas de los otros diecinueve. La causa es que la etiqueta productos está puesta en dos sitios con significados distintos: “la lista” y “cualquier producto”. Lo resolverás en el ejercicio 9.
13.8 El código de estado de notFound()
En el módulo 08 viste que notFound() dentro de una ruta con streaming responde 200 con <meta name="robots" content="noindex">, porque el código de estado ya se había enviado cuando se descubrió que el producto no existía. Con la caché, la historia cambia. Lo medimos con curl en el caso práctico:
| Petición | Situación | Código | Contenido |
|---|---|---|---|
/tienda/999 | El id está en generateStaticParams; notFound() ocurrió en el build | 404 | Página not-found.tsx, con noindex |
/tienda/997, 1.ª visita | No estaba en el build: Next sirve el App Shell y descubre el 404 mientras envía | 200 | Página not-found.tsx, con noindex |
/tienda/997, 2.ª y 3.ª visita | La primera visita dejó generado el resultado | 404 | Página not-found.tsx, con noindex |
Así que la afirmación del módulo 08 es cierta, con un matiz: el 404 llega cuando notFound() se ejecuta en un prerender, sea en el build o en la regeneración en segundo plano que dispara la primera visita. Esa primera visita a un id desconocido sigue recibiendo 200 + noindex. Para un buscador es suficiente (no indexa la página), pero si necesitas el 404 desde la primera visita para un id concreto, inclúyelo en generateStaticParams.
📘 Concepto: El código de estado HTTP va en la primera línea de la respuesta. Si la página sale entera de un prerender, Next sabe el código antes de empezar a enviar. Si hay que calcular algo mientras se envía (streaming), el
200ya se ha ido. La caché, al mover el trabajo al prerender, permite códigos de estado correctos.
13.9 El reparto final
| Parte | Ruta | Peticiones a la API por visita | Frescura |
|---|---|---|---|
| Catálogo | ○ | 0 | Como mucho 10 min, o inmediata tras updateTag |
| Ficha (armazón) | ◐ | 0 | Un día, o inmediata tras updateTag |
| Ficha (stock) | hueco | Como mucho 1 por minuto y producto | Un minuto, o inmediata tras updateTag |
| Ficha inexistente | ○ o 404 tras la primera visita | 0 | Un día |
| Administración | ◐ | 1 | Siempre actual |
Con tres directivas, un perfil propio y dos acciones, la tienda resiste un pico de tráfico sin que la API se entere, y el administrador nunca ve un dato viejo.
14. El modelo de caché de Next.js 15
Si tu proyecto sigue en Next.js 15, este apartado es el importante. Todo lo que aparece aquí se ha comprobado en un proyecto creado con create-next-app@15.5.28, sin cacheComponents (que la versión estable no admite).
14.1 Las cuatro cachés
La documentación de Next.js 15 describe cuatro mecanismos que trabajan juntos, todos implícitos:
| Caché | Qué guarda | Dónde | Cuánto dura |
|---|---|---|---|
| Request Memoization | Respuestas de fetch con la misma URL y opciones dentro de un mismo render | Memoria del servidor | Lo que dura el render (la viste en el módulo 08) |
| Data Cache | Respuestas de fetch marcadas para guardarse | Disco del servidor o cacheHandler | Hasta que caducan o las invalidas |
| Full Route Cache | El HTML y el RSC Payload de las rutas estáticas | Disco del servidor | Hasta el siguiente build o revalidación |
| Router Cache | Los payloads de las rutas visitadas | Navegador | staleTimes: 0 s para dinámicas y 300 s para estáticas por defecto |
La gran diferencia con el modelo nuevo es quién decide. En Next.js 15, una ruta es estática (Full Route Cache) salvo que use algo dinámico: cookies(), headers(), searchParams, connection(), un fetch con cache: 'no-store' o export const dynamic = 'force-dynamic'. Y un fetch se guarda en la Data Cache solo si lo pides con cache: 'force-cache' o next.revalidate.
14.2 Opciones de fetch
| Opción | Efecto en Next.js 15 |
|---|---|
| Sin opciones | No se guarda en la Data Cache, pero si la ruta es estática se ejecuta en el build y el dato queda congelado en el HTML |
cache: 'force-cache' | Se guarda en la Data Cache indefinidamente |
cache: 'no-store' | No se guarda y vuelve dinámica la ruta |
next: { revalidate: 60 } | Se guarda 60 s (ISR); la ruta, si era estática, se regenera con ese periodo |
next: { revalidate: 0 } | Como no-store |
next: { tags: ['productos'] } | Etiqueta la entrada para revalidateTag |
14.3 Opciones de segmento
Se exportan desde page.tsx o layout.tsx y afectan a toda la ruta:
| Opción | Efecto |
|---|---|
export const revalidate = 60 | La ruta y sus fetch se regeneran como mucho cada 60 s |
export const dynamic = 'force-dynamic' | Se renderiza en cada petición; los fetch sin opciones no se guardan |
export const dynamic = 'force-static' | Se fuerza estática; cookies() y headers() devuelven valores vacíos |
export const dynamic = 'error' | Se exige estática; el build falla si algo la vuelve dinámica |
export const fetchCache = '...' | Cambia el comportamiento por defecto de los fetch de la ruta |
14.4 Medido en Next.js 15.5.28
Cada página del laboratorio de Next.js 15 pide un producto distinto a la API, con una de las opciones anteriores. Este es el build:
Route (app) Size First Load JS Revalidate Expire
┌ ○ /_not-found 0 B 113 kB
├ ƒ /dinamica-cache 0 B 113 kB
├ ○ /f-defecto 0 B 113 kB
├ ƒ /f-nostore 0 B 113 kB
├ ○ /f-revalidate 0 B 113 kB 10s 1y
├ ƒ /force-dynamic 0 B 113 kB
├ ƒ /panel15 0 B 113 kB
├ ○ /seg-revalidate 0 B 113 kB 10s 1y
├ ƒ /unstable 0 B 113 kB
└ ○ /use-cache15 0 B 113 kB 1h 1d
○ (Static) prerendered as static content
ƒ (Dynamic) server-rendered on demand
En Next.js 15 no hay ◐: una ruta es entera estática o entera dinámica. Y estas son las peticiones a la API en tres visitas seguidas:
| Página | Código clave | Símbolo | Peticiones en 3 visitas | Lo que se vio |
|---|---|---|---|---|
/f-defecto | fetch(url) | ○ | 0 | El dato y la hora del build, congelados |
/f-nostore | fetch(url, { cache: 'no-store' }) | ƒ | 3 | Hora nueva en cada visita |
/force-dynamic | dynamic = 'force-dynamic' + fetch(url) | ƒ | 3 | Hora nueva en cada visita |
/dinamica-cache | force-dynamic + fetch con force-cache y tags | ƒ | 1 | Página nueva en cada visita, dato de la Data Cache |
/unstable | connection() + unstable_cache | ƒ | 1 | Página dinámica, dato guardado |
/use-cache15 | 'use cache' con experimental.useCache | ○ | 0 | Como /catalogo en Next.js 16 |
Las páginas con revalidate de 10 segundos (/f-revalidate con next.revalidate y /seg-revalidate con la opción de segmento) se comportaron como la ISR clásica. Tras esperar 11 segundos, tres visitas seguidas:
id="dato">79.9 a las 11:53:46 id="dato">139 a las 11:53:46 <- build
id="dato">79.9 a las 11:56:38 id="dato">139 a las 11:56:38 <- 1.ª visita: copia vieja
id="dato">79.9 a las 11:56:50 id="dato">139 a las 11:56:51 <- 2.ª visita: ya regenerada
[API 11:56:50.811] GET /api/productos/7
[API 11:56:51.575] GET /api/productos/9
Es stale-while-revalidate, igual que entre revalidate y expire en Cache Components.
Los ejemplos clave del laboratorio de Next.js 15:
// app/dinamica-cache/page.tsx (Next.js 15)
import { API, hora } from '@/lib/api'
// Ruta dinámica, pero con el dato en la Data Cache etiquetado como 'productos'
export const dynamic = 'force-dynamic'
export default async function Pagina() {
const producto = await fetch(`${API}/productos/11`, {
cache: 'force-cache',
next: { tags: ['productos'] },
}).then((r) => r.json())
return <p id="dato">{producto.precio}, página renderizada a las {hora()}</p>
}
// app/unstable/page.tsx (Next.js 15)
import { unstable_cache } from 'next/cache'
import { connection } from 'next/server'
import { API, hora } from '@/lib/api'
// unstable_cache: la forma de Next.js 15 de guardar el resultado de cualquier función
const obtenerEstadisticas = unstable_cache(
async () => {
const datos = await fetch(`${API}/estadisticas`, { cache: 'no-store' }).then((r) => r.json())
return { indicadores: Object.keys(datos).length, generado: hora() }
},
['estadisticas'],
{ tags: ['estadisticas'], revalidate: 3600 },
)
export default async function Pagina() {
await connection()
const { indicadores, generado } = await obtenerEstadisticas()
return <p id="dato">{indicadores} indicadores obtenidos a las {generado}</p>
}
unstable_cache recibe la función, un array de partes de la clave (además de los argumentos) y las opciones tags y revalidate. A diferencia de 'use cache', solo guarda datos, no componentes, y necesita que le des tú una clave.
⚠️ Importante: El fallo más típico de Next.js 15 es
/f-defecto: unfetchsin opciones en una página sin nada dinámico se ejecuta en el build y el dato queda congelado para siempre, hasta el siguiente despliegue. Si el dato debe cambiar, añadenext: { revalidate: N },export const revalidate = No hazla dinámica.
14.5 Invalidar en Next.js 15
El panel de Next.js 15 es dinámico (force-dynamic) y lee el precio con force-cache y la etiqueta productos. Las acciones:
// app/panel15/acciones.ts (Next.js 15)
'use server'
import { revalidatePath, revalidateTag } from 'next/cache'
import { cambiarPrecio } from '@/lib/api'
export async function subirConRevalidateTag() {
await cambiarPrecio(1, 10)
revalidateTag('productos')
}
export async function subirConRevalidatePath() {
await cambiarPrecio(1, 10)
revalidatePath('/panel15')
}
export async function subirSinInvalidar() {
await cambiarPrecio(1, 10)
}
Resultado medido con el mismo script de Playwright:
== nada
antes caché=949 € (render 11:57:14) api=949 €
tras la acción caché=949 € (render 11:57:14) api=949 €
1.ª recarga caché=949 € (render 11:57:16) api=959 €
== tag
antes caché=949 € (render 11:57:16) api=959 €
tras la acción caché=969 € (render 11:57:17) api=969 €
1.ª recarga caché=969 € (render 11:57:18) api=969 €
== path
antes caché=969 € (render 11:57:18) api=969 €
tras la acción caché=979 € (render 11:57:19) api=979 €
1.ª recarga caché=979 € (render 11:57:20) api=979 €
En Next.js 15, revalidateTag(tag) dentro de una Server Action ya da read-your-own-writes: la respuesta de la acción trae el dato nuevo. Es lo que en 16 hace updateTag. revalidatePath de la ruta actual tiene el mismo efecto visible. Al migrar a 16, cada revalidateTag('x') de una acción debería convertirse en updateTag('x'), y cada uno de un Route Handler en revalidateTag('x', 'max') o revalidateTag('x', { expire: 0 }) según la urgencia.
Las funciones de invalidación que exporta next/cache en 15.5.28 son revalidatePath, revalidateTag(tag) (un solo argumento), y las experimentales unstable_expirePath y unstable_expireTag. No hay updateTag ni refresh.
14.6 'use cache' en Next.js 15.5
Next.js 15.5 estable no admite cacheComponents, pero sí admite experimental.useCache, que habilita la directiva sin el resto del modelo:
// next.config.ts (Next.js 15)
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
experimental: {
useCache: true, // permite 'use cache' en Next.js 15 estable
},
}
export default nextConfig
// app/use-cache15/page.tsx (Next.js 15)
import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache'
import { API, hora } from '@/lib/api'
async function obtenerProducto(id: number) {
'use cache'
cacheLife('hours')
cacheTag('productos')
const producto = await fetch(`${API}/productos/${id}`).then((r) => r.json())
return { precio: producto.precio as number, generado: hora() }
}
export default async function Pagina() {
const { precio, generado } = await obtenerProducto(12)
return <p id="dato">{precio} obtenido a las {generado}</p>
}
El build lo anuncia (Experiments (use with caution): ✓ useCache), la ruta sale como ○ 1h 1d y tres visitas no llegaron a la API. Es una buena forma de preparar la migración: escribes ya las funciones de datos con la sintaxis de Next.js 16 y, al actualizar, solo cambias los imports (unstable_cacheLife pasa a cacheLife). Lo que no tendrás en 15 es el resto del modelo: ni ◐, ni comprobación de huecos en el build, ni updateTag.
15. Tabla de equivalencias de Next.js 15 a 16
| En Next.js 15 | En Next.js 16 con Cache Components | Notas |
|---|---|---|
fetch(url) en ruta estática (dato congelado) | 'use cache' + cacheLife('max') en la función | En 16, sin caché el build exige <Suspense> |
fetch(url, { cache: 'force-cache' }) | 'use cache' + cacheLife('max') | |
fetch(url, { next: { revalidate: 3600 } }) | 'use cache' + cacheLife('hours') | O un objeto { revalidate: 3600 } |
fetch(url, { next: { tags: ['x'] } }) | cacheTag('x') dentro de 'use cache' | |
fetch(url, { cache: 'no-store' }) | fetch(url) sin caché, dentro de <Suspense> | Lo no cacheado ya es dinámico |
unstable_cache(fn, claves, { tags, revalidate }) | Función con 'use cache', cacheTag y cacheLife | La clave sale sola de los argumentos |
export const revalidate = 60 | cacheLife({ revalidate: 60 }) en cada función cacheada | La opción de segmento rompe el build |
export const dynamic = 'force-dynamic' | await connection() en el componente que lo necesite, dentro de <Suspense> | La opción de segmento rompe el build |
export const dynamic = 'force-static' | 'use cache' en la página o el layout | |
export const dynamic = 'error' | export const ensureStatic = 'navigation' | ensureStatic es de 16.4 |
revalidateTag('x') en una Server Action | updateTag('x') | Dato nuevo en la respuesta |
revalidateTag('x') en un Route Handler | revalidateTag('x', 'max') o revalidateTag('x', { expire: 0 }) | El segundo argumento es obligatorio |
revalidatePath('/ruta') | revalidatePath('/ruta') | Igual; mejor etiquetas |
router.refresh() en el cliente tras una acción | refresh() dentro de la Server Action | router.refresh() sigue existiendo |
unstable_cacheLife, unstable_cacheTag (con useCache) | cacheLife, cacheTag | 16.4 aún exporta los alias unstable_ |
Full Route Cache (ruta ○ entera) | Armazón estático (○ o la parte estática de ◐) | |
staleTimes (Router Cache) | stale de cada cacheLife | staleTimes sigue en next.config |
| No existe | prefetch(), navigation() | 16.4, con Partial Prefetching |
💡 Consejo: La guía oficial Migrating to Cache Components (en
node_modules/next/dist/docs/01-app/02-guides/migrating-to-cache-components.mdde tu proyecto) recorre esta migración paso a paso. En el módulo 24 ejecutarás los codemods de 15 a 16.
15.1 Una migración paso a paso: el directorio de departamentos
La tabla está bien como chuleta, pero la mejor forma de entenderla es migrar algo real. Partimos de un directorio de departamentos escrito para Next.js 15, compilado y medido en el laboratorio de 15.5.28, y lo llevamos a Next.js 16.4, también compilado y medido.
El punto de partida: Next.js 15
// lib/departamentos.ts (Next.js 15): la caché se decide en cada fetch
export const API = process.env.API_URL ?? 'http://localhost:3000/api'
export type Departamento = { deptId: number; nombre: string; localidad: string; presupuesto: number }
export type Empleado = { empId: number; nombre: string; apellidos: string; salario: number }
export async function obtenerDepartamentos() {
const r = await fetch(`${API}/departamentos`, {
next: { revalidate: 3600, tags: ['departamentos'] },
})
const { contenido }: { contenido: Departamento[] } = await r.json()
return contenido
}
export async function obtenerDepartamento(id: number) {
const r = await fetch(`${API}/departamentos/${id}`, {
cache: 'force-cache',
next: { tags: ['departamentos', `departamento-${id}`] },
})
if (!r.ok) return null
return (await r.json()) as Departamento
}
export async function obtenerEmpleadosDe(id: number, orden: string) {
const r = await fetch(`${API}/departamentos/${id}/empleados?orden=${orden}`, { cache: 'no-store' })
const datos = await r.json()
return (Array.isArray(datos) ? datos : datos.contenido) as Empleado[]
}
export async function renombrarDepartamento(id: number, nombre: string) {
const login = await fetch(`${API}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'ana.garcia@empresa.es', password: 'secreto' }),
}).then((r) => r.json())
const actual = await fetch(`${API}/departamentos/${id}`, { cache: 'no-store' }).then((r) => r.json())
await fetch(`${API}/departamentos/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${login.token}` },
body: JSON.stringify({ ...actual, nombre }),
})
}
// app/departamentos/[id]/page.tsx (Next.js 15)
import { cookies } from 'next/headers'
import { notFound } from 'next/navigation'
import { obtenerDepartamento, obtenerEmpleadosDe } from '@/lib/departamentos'
import { renombrar } from './acciones'
// Next.js 15: la cookie hace dinámica toda la ruta; lo decimos de forma explícita
export const dynamic = 'force-dynamic'
export default async function FichaDepartamento({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const departamento = await obtenerDepartamento(Number(id))
if (!departamento) notFound()
const orden = (await cookies()).get('orden')?.value ?? 'apellidos'
const empleados = await obtenerEmpleadosDe(departamento.deptId, orden)
return (
<main>
<h1 id="nombre">{departamento.nombre}</h1>
<p>{departamento.localidad}</p>
<ul>
{empleados.map((e) => (
<li key={e.empId}>{e.apellidos}, {e.nombre}</li>
))}
</ul>
<form action={renombrar}>
<input type="hidden" name="id" value={departamento.deptId} />
<input name="nombre" defaultValue={departamento.nombre} />
<button id="renombrar">Renombrar</button>
</form>
</main>
)
}
// app/departamentos/[id]/acciones.ts (Next.js 15)
'use server'
import { revalidateTag } from 'next/cache'
import { renombrarDepartamento } from '@/lib/departamentos'
export async function renombrar(formData: FormData) {
const id = Number(formData.get('id'))
await renombrarDepartamento(id, String(formData.get('nombre')))
revalidateTag(`departamento-${id}`)
revalidateTag('departamentos')
}
La lista (app/departamentos/page.tsx) se limita a llamar a obtenerDepartamentos() y pintar enlaces; es idéntica en las dos versiones. El build de Next.js 15:
├ ○ /departamentos 3.42 kB 118 kB 1h 1y
└ ƒ /departamentos/[id] 0 B 115 kB
Paso 1: activar Cache Components
Añade cacheComponents: true y partialPrefetching: true a next.config.ts (apartado 2). Si compilas ahora, el build se para en la primera opción de segmento que encuentre, con el error del apartado 2.1 (allí era revalidate; aquí será dynamic). Es lo esperado: el resto de pasos lo arreglan.
Paso 2: convertir cada fetch cacheado en una función con 'use cache'
Cada opción de fetch tiene su traducción directa (apartado 15). Las opciones salen del fetch y pasan a ser líneas al principio de la función:
// lib/departamentos.ts (Next.js 16): la caché se decide en cada función
import 'server-only'
import { cacheLife, cacheTag } from 'next/cache'
export const API = process.env.API_URL ?? 'http://localhost:3000/api'
export type Departamento = { deptId: number; nombre: string; localidad: string; presupuesto: number }
export type Empleado = { empId: number; nombre: string; apellidos: string; salario: number }
export async function obtenerDepartamentos() {
'use cache'
cacheLife('hours') // antes: next: { revalidate: 3600 }
cacheTag('departamentos') // antes: next: { tags: ['departamentos'] }
const r = await fetch(`${API}/departamentos`)
const { contenido }: { contenido: Departamento[] } = await r.json()
return contenido
}
export async function obtenerDepartamento(id: number) {
'use cache'
cacheLife('max') // antes: cache: 'force-cache'
cacheTag('departamentos', `departamento-${id}`)
const r = await fetch(`${API}/departamentos/${id}`)
if (!r.ok) return null
return (await r.json()) as Departamento
}
// Antes: cache: 'no-store'. Ahora basta con no cachear
export async function obtenerEmpleadosDe(id: number, orden: string) {
const r = await fetch(`${API}/departamentos/${id}/empleados?orden=${orden}`)
const datos = await r.json()
return (Array.isArray(datos) ? datos : datos.contenido) as Empleado[]
}
renombrarDepartamento no cambia, salvo que el cache: 'no-store' del fetch interno sobra: en 16, lo que no está en 'use cache' no se guarda.
Paso 3: sustituir dynamic = 'force-dynamic' por un límite de <Suspense>
En 15, la cookie volvía dinámica toda la ficha. En 16, solo la parte que la lee. Sacamos la lista de empleados a su propio componente y lo envolvemos en <Suspense>. Y como la ruta es dinámica, añadimos generateStaticParams para prerenderizar los siete departamentos (reutilizando la función cacheada):
// app/departamentos/[id]/page.tsx (Next.js 16)
import { Suspense } from 'react'
import { cookies } from 'next/headers'
import { notFound } from 'next/navigation'
import { obtenerDepartamento, obtenerDepartamentos, obtenerEmpleadosDe } from '@/lib/departamentos'
import { renombrar } from './acciones'
// Sin export const dynamic: la parte dinámica se marca con Suspense
export async function generateStaticParams() {
const departamentos = await obtenerDepartamentos()
return departamentos.map((d) => ({ id: String(d.deptId) }))
}
async function Empleados({ deptId }: { deptId: number }) {
const orden = (await cookies()).get('orden')?.value ?? 'apellidos'
const empleados = await obtenerEmpleadosDe(deptId, orden)
return (
<ul>
{empleados.map((e) => (
<li key={e.empId}>{e.apellidos}, {e.nombre}</li>
))}
</ul>
)
}
export default async function FichaDepartamento({ params }: PageProps<'/departamentos/[id]'>) {
const { id } = await params
const departamento = await obtenerDepartamento(Number(id))
if (!departamento) notFound()
return (
<main>
<h1 id="nombre">{departamento.nombre}</h1>
<p>{departamento.localidad}</p>
<Suspense fallback={<p>Cargando empleados...</p>}>
<Empleados deptId={departamento.deptId} />
</Suspense>
<form action={renombrar}>
<input type="hidden" name="id" value={departamento.deptId} />
<input name="nombre" defaultValue={departamento.nombre} />
<button id="renombrar">Renombrar</button>
</form>
</main>
)
}
Fíjate también en el tipo de las props: PageProps<'/departamentos/[id]'> en lugar de escribir el tipo a mano (existe desde 15.5, así que podrías haberlo usado ya en la versión antigua).
Paso 4: revalidateTag de las acciones pasa a updateTag
// app/departamentos/[id]/acciones.ts (Next.js 16)
'use server'
import { updateTag } from 'next/cache'
import { renombrarDepartamento } from '@/lib/departamentos'
export async function renombrar(formData: FormData) {
const id = Number(formData.get('id'))
await renombrarDepartamento(id, String(formData.get('nombre')))
updateTag(`departamento-${id}`) // antes: revalidateTag(`departamento-${id}`)
updateTag('departamentos')
}
Si dejaras revalidateTag('departamentos'), TypeScript se quejaría del argumento que falta (apartado 16.1), y con 'max' el usuario no vería su cambio en la respuesta (apartado 6.3).
El resultado
El build de Next.js 16.4:
├ ○ /departamentos 1h 1d
├ /departamentos/[id]
│ ├ ◐ /departamentos/[id]
│ ├ ◐ /departamentos/10 30d 1y
│ ├ ◐ /departamentos/20 30d 1y
│ └ ◐ [+5 more paths]
La ficha ha pasado de ƒ (todo en cada petición) a ◐: nombre, localidad y formulario salen del armazón, y solo la lista de empleados (que depende de la cookie) se calcula al pedirla. Las columnas 30d 1y son el perfil max.
El mismo recorrido de Playwright en las dos versiones (abrir la ficha del departamento 10, renombrarlo y mirar la lista):
| Paso | Next.js 15.5.28 | Next.js 16.4 |
|---|---|---|
| Abrir la ficha | 2 peticiones: departamento (primera visita, aún no estaba en la Data Cache) y empleados | 1 petición: empleados (el resto, del armazón) |
| Renombrar | El nombre nuevo aparece en la respuesta de la acción | El nombre nuevo aparece en la respuesta de la acción |
| Peticiones de la acción | login, GET, PUT, GET del departamento, empleados | login, GET, PUT, GET del departamento, empleados |
| Abrir la lista | Nombre nuevo, 1 petición (regenerada por la etiqueta) | Nombre nuevo, 1 petición (regenerada por la etiqueta) |
El comportamiento visible es el mismo, que es lo que se espera de una migración. Lo que cambia es el coste: en 16, abrir una ficha ya no regenera la página entera.
16. Errores frecuentes
| Síntoma | Causa | Solución |
|---|---|---|
Route segment config "revalidate" is not compatible with nextConfig.cacheComponents | Opción de segmento del modelo anterior | Quítala y usa cacheLife en las funciones cacheadas |
`cookies()` can't be read inside "use cache" | Lees datos de la petición dentro de una caché compartida | Léelos fuera y pásalos como argumento, o usa 'use cache: private' |
encountered the unstable value new Date() while prerendering | Fecha o aleatorio fuera de una caché y sin marca de dinámico | await connection() + <Suspense>, o métela en 'use cache' |
updateTag can only be called from within a Server Action | updateTag en un Route Handler | revalidateTag(tag, 'max') o { expire: 0 } |
TypeScript: falta un argumento en revalidateTag | Código de Next.js 15 con un argumento | Añade el perfil: 'max' o { expire: 0 }; en una acción, mejor updateTag |
uncached or runtime data on a route that must be fully static | ensureStatic = 'navigation' con un dato sin caché | Cachéalo o quítalo de esa ruta |
| El dato no cambia tras la acción | La acción no invalida, o usa refresh() con un dato cacheado | updateTag con la etiqueta correcta |
| El administrador ve el precio viejo una vez | revalidateTag(tag, 'max') en la acción | Es stale-while-revalidate; usa updateTag |
La caché “no funciona” con cacheLife('seconds') | Las cachés de vida corta son huecos dinámicos | Es lo esperado; usa un perfil más largo si quieres armazón |
| El build local muestra datos más viejos que la API | .next/cache/fetch-cache reutilizado | Borra esa carpeta y recompila |
next build falla al conectar con la API | Lo cacheado se ejecuta en el build | Arranca la API antes de compilar |
| En Next.js 15 el dato de la página nunca cambia | fetch sin opciones en una ruta estática | next: { revalidate } o hazla dinámica |
"use cache" functions must be async functions | Directiva en una función síncrona | Hazla async, o no la caches si es un cálculo barato |
`cacheTag()` can only be called inside a "use cache" | Falta la directiva en la función | Añade 'use cache' como primera línea |
`connection()` can't be called inside "use cache" | Pides datos de la petición dentro de una caché | Llama a connection() fuera, en el componente que llama a la función |
Cannot access aUrl on the server. You cannot dot into a temporary client reference | Argumento no serializable (una instancia de clase) | Pasa datos planos (categoriaId: 2) y construye el objeto dentro |
Unknown cacheLife() profile "..." is not configured | Errata en el nombre de un perfil propio | Corrige el nombre; TypeScript ya te lo avisaba |
It is not allowed to define inline "use cache" annotated functions in Client Components | 'use cache' en un fichero 'use client' | Mueve la función a un módulo del servidor y pásala como prop, o llámala desde un Server Component |
refresh can only be called from within a Server Action | refresh() en un Route Handler | En un webhook no hay cliente que refrescar: usa revalidateTag |
Una ficha inexistente da 200 la primera vez | Id no prerenderizado: el 404 se descubre durante el streaming | Es normal (lleva noindex); inclúyelo en generateStaticParams si necesitas el 404 desde el principio |
| Cambiar un producto regenera todas las fichas | Etiqueta general compartida por lista y fichas | Etiquetas distintas para la lista y para cada ficha (ejercicio 9) |
16.1 Los mensajes reales
Todos estos errores se provocaron a propósito en el laboratorio de Next.js 16.4 (con next dev los de ejecución, con tsc los de tipos). Los mensajes se copian tal cual, para que los reconozcas al verlos.
Función síncrona. Es un error de compilación: aparece antes de ejecutar nada.
function precioConIva(precio: number) {
'use cache'
return precio * 1.21
}
⨯ ./app/errores/sincrona/page.tsx:1:10
Error: "use cache" functions must be async functions.
> 1 | function precioConIva(precio: number) {
| ^^^^^^^^^^^^
2 | 'use cache'
Un cálculo síncrono y barato como este no necesita caché. Si fuera caro, hazlo async.
cacheTag sin directiva.
⨯ Error: Route "/errores/cachetag": `cacheTag()` can only be called inside a `"use cache"` or `"use cache: private"` function.
Learn more: https://nextjs.org/docs/messages/cache-tag-outside-use-cache
> 5 | cacheTag('productos') // falta 'use cache'
| ^
Fíjate en que el mensaje menciona también 'use cache: private', donde cacheTag sí se admite.
connection() dentro de la caché.
⨯ Error: Route "/errores/connection": `connection()` can't be called inside `"use cache"` because cached functions may run during prerendering, without an incoming request. Call it outside the cached function.
Learn more: https://nextjs.org/docs/messages/next-request-in-use-cache
> 6 | await connection()
| ^
Ojo con este: como el componente estaba dentro de <Suspense>, la página respondió 200 en next dev y el error solo apareció en la terminal (y en el overlay del navegador). No des una página por buena solo porque carga.
Un argumento que no es un dato. El código parece razonable: una clase Filtro con un método que construye la consulta.
class Filtro {
constructor(public categoriaId: number) {}
aUrl() {
return `categoriaId=${this.categoriaId}`
}
}
async function buscar(filtro: Filtro) {
'use cache'
const r = await fetch(`${API}/productos?${filtro.aUrl()}`)
return (await r.json()).total as number
}
⨯ Error: Cannot access aUrl on the server. You cannot dot into a temporary client reference from a server component. You can only pass the value through to the client.
> 12 | const r = await fetch(`${API}/productos?${filtro.aUrl()}`)
| ^
El mensaje es confuso (habla de “referencias de cliente”) porque los argumentos de una función cacheada se serializan con el mismo mecanismo que las props de un Client Component (módulo 05). Una instancia de clase no se puede serializar con sus métodos, y lo que llega dentro de la función es un sustituto opaco. La solución es pasar categoriaId (un número) y construir la URL dentro.
'use cache' en un Client Component.
⨯ ./app/errores/cliente/boton.tsx:4:3
Error: It is not allowed to define inline "use cache" annotated functions in Client Components.
To use "use cache" functions in a Client Component, you can either export them from a separate file with "use cache" or "use server" at the top, or pass them down through props from a Server Component.
refresh() fuera de una acción.
⨯ Error: refresh can only be called from within a Server Action. See more info here: https://nextjs.org/docs/app/api-reference/functions/refresh
revalidateTag con un argumento (código de Next.js 15, comprobado con tsc --noEmit):
app/errores/un-argumento.ts(6,3): error TS2554: Expected 2 arguments, but got 1.
17. Ejercicios
Ejercicio 1: Leer el build
Sin ejecutar nada, predice el símbolo de next build (○, ◐ o ƒ) y las columnas Revalidate y Expire de estas rutas de Next.js 16 con cacheComponents:
- Una página que solo llama a
obtenerProductos()(con'hours'). - Una página con un
fetchsin caché dentro de<Suspense>. - Una página con
obtenerProductos()y, dentro de<Suspense>, un componente conawait connection(). - Una página cuyo único dato tiene
cacheLife('seconds'), dentro de<Suspense>. - Un Route Handler
POST.
Ver solución
Las cinco aparecen en el build del apartado 3.5:
○con1h 1d(como/catalogo). Todo cabe en el armazón.◐sin columnas (como/sin-cache). El armazón lleva el fallback y el dato llega por streaming.◐con1h 1d(como/panel). La parte cacheada aporta las columnas; el hueco hace que sea◐.◐sin columnas (como/corto). Una caché de vida corta no entra en el armazón: es un hueco.ƒ(como/api/revalidar). UnPOSTsiempre se ejecuta en cada petición.
La clave para predecirlo: ○ si todo se puede resolver en el build; ◐ si hay al menos un hueco; las columnas muestran la vida más corta de lo que sí entró en el armazón.
Ejercicio 2: Ficha de departamento cacheada
Escribe obtenerDepartamento(id) para la API EMPRESA (/api/departamentos/:id) con caché de un día y dos etiquetas (una general y otra por registro), y la página /departamentos/[id] que prerenderiza los departamentos 10 y 20.
Ver solución
Es el mismo patrón que obtenerProducto (apartado 5). Esta solución se compiló en el laboratorio:
// lib/api.ts
export type Departamento = { deptId: number; nombre: string; localidad: string; presupuesto: number }
export async function obtenerDepartamento(id: number) {
'use cache'
cacheLife('days')
cacheTag('departamentos', `departamento-${id}`)
const respuesta = await fetch(`${API}/departamentos/${id}`)
if (!respuesta.ok) return null
const departamento: Departamento = await respuesta.json()
return departamento
}
// app/departamentos/[id]/page.tsx
import { notFound } from 'next/navigation'
import { obtenerDepartamento } from '@/lib/api'
export async function generateStaticParams() {
return [{ id: '10' }, { id: '20' }]
}
export default async function FichaDepartamento({ params }: PageProps<'/departamentos/[id]'>) {
const { id } = await params
const departamento = await obtenerDepartamento(Number(id))
if (!departamento) notFound()
return (
<main>
<h1>{departamento.nombre}</h1>
<p>{departamento.localidad}</p>
</main>
)
}
En el build verás /departamentos/10 y /departamentos/20 como ○ 1d 1w, y la ruta genérica como ◐. El return null dentro de la función cacheada también se guarda: un id inexistente no volverá a preguntar a la API durante un día. Si eso no te conviene (por ejemplo, porque el departamento se va a crear pronto), lanza un error en lugar de devolver null: los errores no se guardan en caché.
Ejercicio 3: ¿Por qué llega a la API?
Un compañero ha escrito esto y se queja de que “la caché no funciona”: cada visita a la página llama a la API.
export async function stockActual() {
'use cache'
cacheLife('seconds')
const r = await fetch(`${API}/productos?disponibles=true`)
return r.json()
}
Explica qué pasa y qué le recomendarías.
Ver solución
La caché funciona, pero con el perfil 'seconds' su revalidate es de 1 segundo: cualquier visita que llegue más de un segundo después de la anterior encuentra la entrada vieja y dispara una regeneración. En el laboratorio, /corto recibió 3 visitas espaciadas un segundo y la API registró 3 peticiones. Además, al ser de vida corta, no entra en el armazón: la ruta es ◐ y el dato se rellena en cada petición.
Lo que sí consigue es agrupar las visitas que coinciden en el mismo segundo. Si el objetivo era aligerar la API, la recomendación es preguntarse cuánto puede tardar en verse un cambio de stock: si basta con un minuto, cacheLife('minutes'); y si el stock solo cambia al hacer pedidos, mejor cacheLife('max') con cacheTag('stock') y un updateTag('stock') en la acción que crea el pedido.
Ejercicio 4: Elegir la invalidación
Para cada caso, elige updateTag, revalidateTag(tag, 'max'), revalidateTag(tag, { expire: 0 }), refresh() o nada:
- El administrador cambia el nombre de un departamento desde un formulario.
- El ERP avisa por un webhook de que han cambiado los precios de la tienda.
- Un webhook avisa de que un producto se ha retirado por un problema legal y no debe mostrarse más.
- Un usuario marca una notificación como leída; las notificaciones se leen sin caché dentro de
<Suspense>. - Un usuario cambia su tema de color (una cookie) desde una acción.
Ver solución
updateTag('departamentos')(o la etiqueta del registro). Quien hace el cambio debe verlo en la respuesta de la acción, como en la medición del apartado 6.1.revalidateTag('productos', 'max')en el Route Handler.updateTagno se puede usar ahí (da error), y un segundo de dato viejo no hace daño.revalidateTag('productos', { expire: 0 }). Fuera de una acción, es la forma de que nadie vuelva a ver el dato viejo: la siguiente lectura espera al nuevo.refresh(). No hay nada cacheado que invalidar; solo hace falta que la página se vuelva a pintar con el dato nuevo, como le pasó al precio real en la medición.refresh()si el tema se lee dentro de un componente sin caché, o nada si se aplica en el cliente. Las cookies nunca deben estar dentro de'use cache'.
Ejercicio 5: Arreglar cookies() dentro de la caché
Este componente hace fallar el build. Arréglalo de forma que el resultado siga en caché, con una entrada por idioma.
async function Novedades() {
'use cache'
cacheLife('hours')
const idioma = (await cookies()).get('idioma')?.value ?? 'es'
const r = await fetch(`${API}/productos?orden=-fechaAlta&tamano=3&idioma=${idioma}`)
const { contenido } = await r.json()
return <ul>{contenido.map((p: Producto) => <li key={p.productoId}>{p.nombre}</li>)}</ul>
}
Ver solución
Se separa en dos componentes: el de fuera lee la cookie, sin caché; el de dentro recibe el idioma como argumento y se cachea. Es el patrón de EnElShell y productoParaTema del apartado 10, que compiló y se midió:
async function Novedades() {
const idioma = (await cookies()).get('idioma')?.value ?? 'es'
return <ListaNovedades idioma={idioma} />
}
async function ListaNovedades({ idioma }: { idioma: string }) {
'use cache'
cacheLife('hours')
const r = await fetch(`${API}/productos?orden=-fechaAlta&tamano=3&idioma=${idioma}`)
const { contenido } = await r.json()
return <ul>{contenido.map((p: Producto) => <li key={p.productoId}>{p.nombre}</li>)}</ul>
}
// En la página:
// <Suspense fallback={<p>Cargando novedades...</p>}><Novedades /></Suspense>
Novedades lee una cookie, así que debe ir dentro de <Suspense>. Con Partial Prefetching, al depender solo de una cookie, forma parte del App Shell y se precarga con cualquier <Link> (lo viste con el componente A). Habrá una entrada de caché por idioma, compartida por todos los usuarios con ese idioma.
Ejercicio 6: Traducir a Next.js 15
Tienes este código de Next.js 16 y debes llevarlo a un proyecto en Next.js 15.5 sin experimental.useCache:
export async function obtenerEstadisticas() {
'use cache'
cacheLife('hours')
cacheTag('estadisticas')
const r = await fetch(`${API}/estadisticas`)
return r.json()
}
Escribe dos versiones: una con opciones de fetch y otra con unstable_cache.
Ver solución
Con opciones de fetch (el mismo mecanismo que /dinamica-cache, apartado 14.4):
export async function obtenerEstadisticas() {
const r = await fetch(`${API}/estadisticas`, {
next: { revalidate: 3600, tags: ['estadisticas'] },
})
return r.json()
}
Con unstable_cache (como /unstable, apartado 14.4):
import { unstable_cache } from 'next/cache'
export const obtenerEstadisticas = unstable_cache(
async () => {
const r = await fetch(`${API}/estadisticas`, { cache: 'no-store' })
return r.json()
},
['estadisticas'],
{ tags: ['estadisticas'], revalidate: 3600 },
)
Las dos guardan el dato una hora en la Data Cache con la etiqueta estadisticas, y en las dos la invalidación en una acción es revalidateTag('estadisticas'), con un solo argumento. La segunda sirve también para lo que no es fetch (una consulta a base de datos), que la primera no puede cubrir. El cache: 'no-store' de dentro evita que el fetch interno se guarde por su cuenta: la caché la gestiona unstable_cache.
Ejercicio 7: Proteger la portada
La portada de la tienda está en app/(tienda)/page.tsx y las fichas en app/(tienda)/productos/[id]/page.tsx. Haz que el build falle si cualquiera de ellas deja de ser completamente estática. ¿Qué necesitan las fichas para cumplirlo?
Ver solución
Basta con una línea en el layout del grupo:
// app/(tienda)/layout.tsx
export const ensureStatic = 'navigation'
export default function LayoutTienda({ children }: LayoutProps<'/'>) {
return <div className="mx-auto max-w-5xl">{children}</div>
}
El nivel se aplica a todas las rutas que usan ese layout. Para cumplirlo:
- La portada solo puede usar datos con
'use cache'y uncacheLifeque no sea de vida corta, como/estaticaen el apartado 9. - Las fichas, al ser una ruta dinámica, deben exportar
generateStaticParamscon al menos unid. Unidque no estuviera en la lista se genera al pedirlo (la petición espera a que esté listo) y se guarda. - Ninguna puede leer
cookies(),headers()nisearchParamsen el servidor, ni siquiera dentro de<Suspense>. Si la ficha necesita algo así (el carrito, por ejemplo), debe ir a un Client Component que lo lea en el navegador.
Si una ficha concreta quisiera ser más estricta, podría; si quisiera ser menos ('shell' o false), el build fallaría.
Ejercicio 8: Diagnóstico del panel
Un alumno escribe esta acción y se queja de que, tras pulsar el botón, el precio de la caché no cambia hasta recargar dos veces:
export async function guardarPrecio(formData: FormData) {
await cambiarPrecio(Number(formData.get('id')), Number(formData.get('incremento')))
revalidateTag('productos', 'max')
}
Explica qué pasa usando la medición del apartado 6.1 y corrígelo.
Ver solución
Es exactamente la fila revalidate de la medición: con 'max', la etiqueta se marca como vieja (stale-while-revalidate). La respuesta de la acción no repinta la página; la 1.ª recarga recibe todavía el dato viejo (929 €) y dispara la regeneración; la 2.ª ya ve el nuevo (939 €).
Como el cambio lo hace el propio usuario y debe verlo, la función correcta es updateTag:
export async function guardarPrecio(formData: FormData) {
const id = Number(formData.get('id'))
await cambiarPrecio(id, Number(formData.get('incremento')))
updateTag(`producto-${id}`)
updateTag('productos')
}
Con ella, en la medición, la respuesta de la acción ya traía 929 € sin recargar. En Next.js 15 el código original (sin el segundo argumento) habría funcionado, porque allí revalidateTag en una acción caduca la etiqueta en el acto: por eso este error aparece tanto al migrar.
Ejercicio 9: Etiquetas que no arrastran a las demás
En el recorrido del caso práctico (apartado 13.7), cambiar el precio del producto 1 hizo que se volvieran a pedir a la API las fichas de los productos 2, 3 y 4. Explica por qué y rediseña las etiquetas de lib/tienda.ts y de la acción cambiarPrecio para que un cambio de precio regenere solo el catálogo y la ficha afectada.
Ver solución
La causa está en que la etiqueta productos tiene dos significados a la vez. obtenerCatalogo() la usa para decir “soy la lista”, y obtenerProducto(id) la usa para decir “soy un producto”. Cuando la acción hace updateTag('productos') para refrescar la lista, caduca también todas las fichas, porque todas llevan esa etiqueta. Los prefetches de los enlaces del catálogo las volvieron a generar en cuanto el usuario volvió a la tienda: de ahí las peticiones a /2, /3, /4.
La solución es dar a cada cosa su propia etiqueta:
// lib/tienda.ts (solo cambian las etiquetas)
export async function obtenerCatalogo() {
'use cache'
cacheLife('catalogo')
cacheTag('catalogo') // la lista
// ...igual que antes
}
export async function obtenerProducto(id: number) {
'use cache'
cacheLife('days')
cacheTag('productos', `producto-${id}`) // 'productos' queda para "todas las fichas"
// ...igual que antes
}
// app/admin/acciones.ts
export async function cambiarPrecio(formData: FormData) {
const id = Number(formData.get('id'))
const precio = Number(formData.get('precio'))
await guardarProducto(id, { precio })
updateTag(`producto-${id}`) // la ficha afectada
updateTag('catalogo') // y la lista, que muestra precios
}
Ahora cada etiqueta responde a una pregunta distinta:
| Etiqueta | Cuándo invalidarla |
|---|---|
catalogo | Cambia algo que se ve en la lista (precio, nombre, alta o baja de un producto) |
producto-N | Cambia algo de ese producto |
productos | Cambia algo que afecta a todas las fichas (por ejemplo, el IVA o el formato de precio) |
stock-N | Cambia el stock de ese producto |
Para comprobarlo, repite el recorrido del apartado 13.7 con el registro de la API: tras cambiar el precio deberías ver la petición del catálogo y la de la ficha 1, y ninguna de las fichas que no han cambiado (salvo las que el prefetch genere por primera vez). Y mira el .meta de una ficha (apartado 12.3) para confirmar que ya no lleva la etiqueta catalogo.
Ejercicio 10: ¿Qué código de estado?
En el caso práctico, generateStaticParams de /tienda/[id] devuelve los ids 1, 2 y 999, y el producto 999 no existe. Predice el código HTTP de cada una de estas peticiones, hechas en este orden tras next start, y explica la diferencia:
/tienda/999/tienda/997(no existe)/tienda/997otra vez/tienda/5(existe, pero no está en la lista)
Ver solución
Los tres primeros se midieron con curl (apartado 13.8):
- 404.
notFound()se ejecutó en el build: la página 404 es un prerender completo (○ /tienda/999en la tabla) y Next conoce el código antes de enviar nada. - 200, con la página
not-found.tsxynoindex. El id no estaba en la lista: Next empieza a enviar el App Shell (y con él la línea200 OK) y descubre después, al calcular la ficha, que no existe. - 404. La primera visita dejó el resultado generado y guardado; esta ya sale de un prerender.
- 200, con la ficha completa. Es una ficha válida que se genera la primera vez, igual que
/tienda/3en el apartado 13.6.
La diferencia está en cuándo se ejecuta notFound(): si es durante un prerender (en el build o en la regeneración), el código es correcto; si es durante el streaming de una respuesta ya empezada, el 200 ya se ha enviado y Next solo puede añadir noindex. Si para ti es importante el 404 desde la primera visita, prerenderiza los ids que conozcas.
Ejercicio 11 (propuesto): Las estadísticas del panel de mando
Crea /estadisticas con los totales de /api/estadisticas. Debe ser ◐: los totales en caché con un perfil propio panel (definido en next.config.ts, 2 minutos de revalidate) y una etiqueta, y debajo, dentro de <Suspense>, la hora de la petición con connection(). Añade un botón con una Server Action que llame a updateTag y comprueba, con el registro de la API y con NEXT_PRIVATE_DEBUG_CACHE=1, cuántas peticiones hay en cinco visitas y cuántas al pulsar el botón. Mira también las cabeceras de la respuesta: ¿qué Cache-Control lleva y por qué?
Ejercicio 12 (propuesto): Migración de un proyecto de Next.js 15
Toma el laboratorio del apartado 14 (las páginas /f-defecto, /f-revalidate, /seg-revalidate, /force-dynamic, /dinamica-cache y /unstable) y pásalo a Next.js 16 con cacheComponents, usando la tabla del apartado 15. Para cada página, anota el símbolo de next build antes y después, y las peticiones a la API en tres visitas. ¿Cuál cambia de comportamiento visible y por qué?
Resumen
| Concepto | API | Idea clave |
|---|---|---|
| Activar el modelo | cacheComponents: true, partialPrefetching: true | Por defecto todo es dinámico; la caché es explícita |
| Guardar datos | 'use cache' en una función async | Clave = función + argumentos + variables capturadas + build |
| Guardar interfaz | 'use cache' en un componente o página | Se guarda el RSC Payload renderizado |
| Vida de la caché | cacheLife('hours') o { stale, revalidate, expire } | stale en el navegador; revalidate y expire en el servidor |
| Vida corta | cacheLife('seconds'), expire < 5 min | No entra en el armazón: es un hueco dinámico |
| Etiquetas | cacheTag('productos', 'producto-7') | Una general por tipo y una por registro |
| Leer lo escrito | updateTag(tag) | Solo en Server Actions; dato nuevo en la respuesta |
| Renovar sin esperar | revalidateTag(tag, 'max') | Stale-while-revalidate; vale en Route Handlers |
| Borrar ya desde fuera | revalidateTag(tag, { expire: 0 }) | La siguiente lectura espera al dato nuevo |
| Repintar | refresh() | Solo en Server Actions; no toca la caché |
| Por ruta | revalidatePath('/ruta') | Igual en 15 y 16; mejor etiquetas |
| Datos de la petición | Leer fuera y pasar como argumento | cookies() dentro de 'use cache' rompe el build |
| Por petición | await connection() + <Suspense> | Sustituye a dynamic = 'force-dynamic' |
| Datos personales | 'use cache: private' | Solo en el navegador; entra en el prefetch |
| Caché compartida | 'use cache: remote' + cacheHandlers | Para muchas instancias y alto acierto |
| Perfil propio | cacheLife: { catalogo: {...} } en next.config.ts | Un nombre de negocio para unos tiempos; TypeScript detecta erratas |
| Manejador propio | cacheHandlers: { remote: ... } | Cinco métodos: get, set, updateTags, getExpiration, refreshTags |
| Depurar | x-nextjs-cache, .meta, NEXT_PRIVATE_DEBUG_CACHE=1 | Cabeceras, etiquetas en disco y registro detallado del servidor |
| 404 con caché | notFound() + generateStaticParams | El 404 solo llega si notFound() ocurre en un prerender |
| Garantía estática | export const ensureStatic = 'navigation' | 16.4; el build falla si algo se vuelve dinámico |
| Etapas | await prefetch(), await navigation() | 16.4; sacan trabajo del App Shell o del prefetch |
Next.js 15: fetch | cache: 'force-cache' \| 'no-store', next: { revalidate, tags } | Caché implícita; sin opciones, dato congelado en rutas estáticas |
| Next.js 15: segmento | revalidate, dynamic | Afectan a toda la ruta; rompen el build en 16 |
| Next.js 15: funciones | unstable_cache(fn, claves, { tags, revalidate }) | Equivale a 'use cache' + cacheTag + cacheLife |
| Next.js 15: invalidar | revalidateTag(tag) | En una acción, equivale a updateTag de la 16 |