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 cacheLife y sus tres tiempos (stale, revalidate, expire)
  • Etiquetar datos con cacheTag e invalidarlos con updateTag, revalidateTag, revalidatePath y refresh, 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 ensureStatic y repartir el trabajo entre las etapas de la navegación con prefetch() y navigation(), novedades de Next.js 16.4
  • Leer los símbolos ○, ◐ y ƒ de next build y contar cuántas peticiones llegan de verdad a tu API
  • Depurar la caché con las cabeceras de respuesta, los ficheros de .next, el registro NEXT_PRIVATE_DEBUG_CACHE y 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 (fetch con cache y next.revalidate, export const revalidate, dynamic, unstable_cache, revalidateTag con 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 (antes dynamicIO) 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. Con experimental.useCache: true puedes 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 fetch y 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.cacheComponents solo se admite en canary. Si usas Next.js 15: no hay interruptor equivalente; la caché depende de las opciones de fetch y 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:

  1. 'use cache', cacheLife y cacheTag pasan a estar disponibles. Sin la opción, 'use cache' no compila.
  2. Los datos sin caché son dinámicos. Un fetch o 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).
  3. 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 exportaba revalidate:

    ./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 cacheLife dentro de la función cacheada.

  4. 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 con experimental.useCache: true, con unstable_cacheLife y unstable_cacheTag. En 16 es estable y se activa con cacheComponents. Si usas Next.js 15: activa experimental.useCache (comprobado en 15.5.28, apartado 14.6) o usa unstable_cache y las opciones de fetch.

'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 categoriaId es 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=2 y 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. HoraPeticion llama a connection() 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 children u 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-cache llama a la API cada vez, y en el build ni siquiera lo intenta: el fetch sin caché dentro de <Suspense> se deja para la petición.
  • Con caché en el armazón, cero peticiones. /catalogo y /componente sirven el HTML generado en el build; la API no se entera de las visitas.
  • Una ficha no prerenderizada se genera una vez. /productos/3 no estaba en generateStaticParams: la primera visita llamó a la API y guardó el resultado; las dos siguientes ya no.

💡 Consejo: Como el armazón de /catalogo se genera en el build, next build necesita la API en marcha. Si no lo está, el fetch de 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 con experimental.useCache, y el perfil 'seconds' tenía stale de 0 segundos (en 16 son 30). Si usas Next.js 15: impórtala como unstable_cacheLife as cacheLife, o expresa la vida con next: { revalidate: segundos } en fetch o con la opción revalidate de unstable_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

Línea de tiempo del perfil hours: stale de 5 minutos, revalidate de 1 hora y expire de 1 día, con la zona intermedia de stale-while-revalidate entre 1 hora y 1 día

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 un cacheLife. Sin él se aplica default, 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_cacheTag y solo funcionaba con experimental.useCache. Si usas Next.js 15: etiqueta los fetch con next: { tags: ['productos'] } o usa la opción tags de unstable_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 etiqueta productos.

💡 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}`)
}

Panel de precios con el precio en caché (899 euros, obtenido a las 11:51:59) y el precio real de la API (979 euros), y cinco botones: updateTag, revalidateTag max, revalidateTag expire 0, refresh y sin invalidar

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.

Tabla con lo que ve el usuario tras cada tipo de invalidación: updateTag y revalidateTag con expire 0 muestran el dato nuevo en la respuesta; revalidateTag max no repinta, sirve el dato viejo en la primera recarga y el nuevo en la segunda; refresh y no invalidar dejan el dato cacheado viejo

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 a revalidateTag('productos') sin segundo argumento; se comporta como updateTag o como revalidateTag(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 (con useRouter de next/navigation) o a revalidatePath con 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 → updateTag en 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.

Diagrama: la función obtenerProductos con use cache se serializa como RSC Payload y se copia en tres almacenes: HTML prerenderizado en disco o CDN, almacén en memoria del servidor (o un cacheHandler remoto con use cache remote) y la caché del router en el navegador; los dos primeros los controlan revalidate y expire, el tercero stale

Figura 4. El resultado de una función cacheada se guarda en hasta tres sitios, cada uno con sus tiempos.

  1. HTML prerenderizado. Lo que entra en el armazón se escribe en disco (.next/server/app en un servidor propio) o en el almacenamiento de la plataforma, detrás de la CDN. Se regenera al pasar revalidate o al invalidar la etiqueta, como viste con /catalogo.
  2. 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 reinicias next start, se pierden. En serverless, cada instancia tiene la suya y duran poco.
  3. Navegador. Cuando el cliente navega con <Link>, el router guarda el payload recibido y lo reutiliza durante stale segundos 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 a unstable_cache; para una caché compartida entre instancias, configura un cacheHandler para 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ón cacheHandlers de next.config.ts. Cada lectura es un viaje por la red, así que solo compensa si muchas peticiones reutilizan la misma entrada. Si no configuras cacheHandlers, Next usa para remote el mismo almacén en memoria que para default, 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 leer cookies() y headers() 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 leer cookies()), 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(), searchParams del servidor y cachés de vida corta (expire menor de 5 minutos, stale menor de 30 segundos o revalidate: 0), aunque estén dentro de <Suspense>.
  • En una ruta dinámica con 'navigation', generateStaticParams es 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 primer loading.tsx (o entera con prefetch={true}); para que una parte pesada no se precargue, ponla tras un loading.tsx o cárgala en el cliente.

Con Partial Prefetching (activado con partialPrefetching: true, módulo 15), Next.js carga una ruta en tres etapas:

  1. 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 de cookies() y headers() (datos de la sesión).
  2. 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.
  3. 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' ]

Las tres etapas de la navegación (App Shell, prefetch por enlace y navegación) y la tabla medida: con Link normal, el prefetch calcula A y el clic B y C; con prefetch true, el prefetch calcula A y B y el clic solo C

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; con prefetch={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 .next entera) y vuelve a compilar. Y no confundas esta caché del build con la de 'use cache' en tiempo de ejecución: tras updateTag, 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 de fetch del build; .next/server/app guarda 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. En next dev, además, los console.log que hagas dentro de una función cacheada aparecen con el prefijo Cache.

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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.

Catálogo de la tienda EMPRESA: ocho tarjetas con el nombre del producto, su categoría y su precio, y al pie la hora a la que se generó el catálogo

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.

Ficha del Portátil UltraBook 14 con su descripción, el precio de 1001 euros, el texto Agotado en rojo con la hora de consulta, y la hora a la que se generó la ficha

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
}

Pantalla de administración con una tabla de cuatro productos (precio y stock) y dos formularios: cambiar precio y cambiar stock

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') y updateTag('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: el updateTag('productos') había caducado todas las fichas, porque todas llevan la etiqueta productos.

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 200 ya 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: un fetch sin 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ñade next: { revalidate: N }, export const revalidate = N o 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.md de 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:

  1. Una página que solo llama a obtenerProductos() (con 'hours').
  2. Una página con un fetch sin caché dentro de <Suspense>.
  3. Una página con obtenerProductos() y, dentro de <Suspense>, un componente con await connection().
  4. Una página cuyo único dato tiene cacheLife('seconds'), dentro de <Suspense>.
  5. Un Route Handler POST.
Ver solución

Las cinco aparecen en el build del apartado 3.5:

  1. ○ con 1h 1d (como /catalogo). Todo cabe en el armazón.
  2. ◐ sin columnas (como /sin-cache). El armazón lleva el fallback y el dato llega por streaming.
  3. ◐ con 1h 1d (como /panel). La parte cacheada aporta las columnas; el hueco hace que sea ◐.
  4. ◐ sin columnas (como /corto). Una caché de vida corta no entra en el armazón: es un hueco.
  5. ƒ (como /api/revalidar). Un POST siempre 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:

  1. El administrador cambia el nombre de un departamento desde un formulario.
  2. El ERP avisa por un webhook de que han cambiado los precios de la tienda.
  3. Un webhook avisa de que un producto se ha retirado por un problema legal y no debe mostrarse más.
  4. Un usuario marca una notificación como leída; las notificaciones se leen sin caché dentro de <Suspense>.
  5. Un usuario cambia su tema de color (una cookie) desde una acción.
Ver solución
  1. 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.
  2. revalidateTag('productos', 'max') en el Route Handler. updateTag no se puede usar ahí (da error), y un segundo de dato viejo no hace daño.
  3. 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.
  4. 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.
  5. 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 un cacheLife que no sea de vida corta, como /estatica en el apartado 9.
  • Las fichas, al ser una ruta dinámica, deben exportar generateStaticParams con al menos un id. Un id que 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() ni searchParams en 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:

  1. /tienda/999
  2. /tienda/997 (no existe)
  3. /tienda/997 otra vez
  4. /tienda/5 (existe, pero no está en la lista)
Ver solución

Los tres primeros se midieron con curl (apartado 13.8):

  1. 404. notFound() se ejecutó en el build: la página 404 es un prerender completo (○ /tienda/999 en la tabla) y Next conoce el código antes de enviar nada.
  2. 200, con la página not-found.tsx y noindex. El id no estaba en la lista: Next empieza a enviar el App Shell (y con él la línea 200 OK) y descubre después, al calcular la ficha, que no existe.
  3. 404. La primera visita dejó el resultado generado y guardado; esta ya sale de un prerender.
  4. 200, con la ficha completa. Es una ficha válida que se genera la primera vez, igual que /tienda/3 en 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