Disponible para freelance¡Contáctame para que pueda ayudar a que tu negocio crezca o convertir tu idea en realidad!

Estoy interesado
Caché en Next.js 16: use cache, cacheLife y cacheTag

Caché en Next.js 16: use cache, cacheLife y cacheTag

André Luiz Vieira7 min de lectura

La caché de Next.js siempre tuvo fama de caja negra. En la 13 y la 14, fetch cacheaba todo sin pedir permiso. En la 15, dejó de cachear por defecto. Y en el camino tenías revalidate, dynamic = 'force-static', unstable_cache y next: { tags }, cada uno con sus propias reglas.

Next.js 16 junta todo eso en un solo modelo, explícito: nada se cachea hasta que escribes 'use cache'. En este post te muestro cómo encajan las tres piezas (use cache, cacheLife y cacheTag), y dónde solemos tropezar.


Primer paso: activar Cache Components

El modelo nuevo viene detrás de un flag, que reemplaza al antiguo experimental.ppr:

// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  cacheComponents: true,
}

export default nextConfig

Con el flag activado, cada parte de la página cae en una de tres categorías:

  • Estático: código síncrono e imports, renderizados en el build.
  • En caché: datos asíncronos marcados con 'use cache'.
  • Dinámico: todo lo que depende de la petición (cookies(), headers(), searchParams), envuelto en <Suspense>.

Next arma una estructura estática, la rellena con lo que está en caché y deja que la parte dinámica llegue por streaming. Eso es Partial Prerendering, ahora sin flag experimental.

Una página dividida en tres franjas: estructura estática, bloque en caché y bloque dinámico en Suspense llegando por streaming


use cache: qué entra en la caché

La directiva funciona en tres niveles: archivo, componente y función. Yo prefiero usarla en funciones de datos, porque queda claro qué se está guardando:

// lib/posts.ts
import { cacheLife, cacheTag } from 'next/cache'

type PostSummary = {
  slug: string
  title: string
  date: string
}

export async function getPosts(locale: string): Promise<PostSummary[]> {
  'use cache'
  cacheLife('hours')
  cacheTag('posts', `posts-${locale}`)

  const response = await fetch(`https://api.example.com/posts?locale=${locale}`)
  if (!response.ok) {
    throw new Error(`La API respondió ${response.status}`)
  }
  return response.json()
}

Fíjate que no hay clave de caché por ningún lado. Next la arma solo, a partir del build, de la función y de los argumentos serializables. getPosts('pt') y getPosts('es') son dos entradas distintas, sin que hagas nada. Si alguna vez mantuviste un array de keyParts en unstable_cache, sabes el alivio que es esto.


cacheLife: cuánto tiempo vale el dato

Sin cacheLife, se aplica el perfil default. Los perfiles incluidos son 'seconds', 'minutes', 'hours', 'days', 'weeks' y 'max'. Cuando ninguno sirve, pasas los tres tiempos a mano:

import { cacheLife } from 'next/cache'

export async function getExchangeRate(currency: string): Promise<number> {
  'use cache'
  cacheLife({
    stale: 60, // el navegador usa la copia local hasta 1 minuto
    revalidate: 300, // pasados 5 minutos, el servidor vuelve a buscar en segundo plano
    expire: 3600, // tras 1 hora sin tráfico, la entrada desaparece
  })

  const response = await fetch(`https://api.example.com/rates/${currency}`)
  const data: { rate: number } = await response.json()
  return data.rate
}

Piensa en los tres números como la fecha de un pan en la panadería: stale es cuánto tiempo te lo comes sin mirarlo, revalidate es cuando el panadero ya empieza a hornear otro mientras tú sigues con el viejo, y expire es cuando va directo a la basura.


cacheTag: ponerle nombre para poder borrarlo

El tiempo resuelve el caso común. Pero cuando alguien publica un post, no quieres esperar una hora. Para eso están las tags: marcas la entrada y la invalidas por su nombre.

Next 16 separa la invalidación en dos funciones, y elegir la equivocada es el tropiezo más común:

'use server'

import { revalidateTag, updateTag } from 'next/cache'
import { savePost } from '@/lib/db'
import { requireAdmin } from '@/lib/auth'

export async function publishPost(locale: string, slug: string, mdx: string): Promise<void> {
  await requireAdmin()
  await savePost(locale, slug, mdx)

  // quien publicó tiene que ver el post nuevo en esta misma respuesta
  updateTag(`posts-${locale}`)
}

export async function importPosts(): Promise<void> {
  await requireAdmin()
  // ... importación en lote

  // nadie está esperando: sirve la copia vieja y actualiza en segundo plano
  revalidateTag('posts', 'max')
}
  • updateTag: expira al instante, y la misma petición ya lee el dato nuevo. Solo funciona en Server Actions. Es el "guardé y quiero verlo".
  • revalidateTag: marca la entrada como vieja y la actualiza en segundo plano (stale-while-revalidate). También funciona en Route Handlers, así que sirve para webhooks. En la 16, el segundo argumento con el perfil pasó a ser esperado; la forma con un solo argumento está obsoleta.

La caché que no ves

Una lección que aprendí en este mismo sitio: la caché de Next no es la única de la cadena. Aquí el blog lee los posts de una API aparte, con la tag posts y un webhook que llama a revalidateTag en cada publicación. Todo bien sobre el papel. Aun así, un post recién publicado seguía dando 404.

El motivo: la API enviaba s-maxage=60, stale-while-revalidate=300. El webhook invalidaba la tag, el sitio volvía a buscar al instante… y recibía del CDN de la API la copia de antes de la escritura. Esa copia vieja se quedaba guardada una hora más.

// ❌ Incorrecto: la API de origen cacheada en el CDN, por debajo de la caché de Next
reply.header('Cache-Control', 's-maxage=60, stale-while-revalidate=300')

// ✅ Correcto: Next es la única capa de caché; el origen siempre responde fresco
reply.header('Cache-Control', 'no-store')

cacheTag invalida la caché de Next, no la del mundo entero. Si hay un CDN, un proxy o una caché de base de datos en el medio, la invalidación tiene que pasar por todos. O solo una capa cachea.


Errores comunes

Leer cookies dentro de la caché

// ❌ Incorrecto: cookies() dentro de 'use cache' lanza un error
async function CartSummary() {
  'use cache'
  const cartId = (await cookies()).get('cart')?.value
  const cart = await getCart(cartId)
  return <p>{cart.items.length} productos</p>
}

// ✅ Correcto: léela fuera y pásala como argumento (pasa a formar parte de la clave)
async function CartPage() {
  const cartId = (await cookies()).get('cart')?.value ?? 'anon'
  return <CartSummary cartId={cartId} />
}

async function CartSummary({ cartId }: { cartId: string }) {
  'use cache'
  const cart = await getCart(cartId)
  return <p>{cart.items.length} productos</p>
}

Usar revalidateTag cuando el usuario espera ver el cambio

// ❌ Incorrecto: el autor guarda y la pantalla sigue mostrando la versión vieja
revalidateTag(`post-${slug}`, 'max')

// ✅ Correcto: en una Server Action, leer lo que acabas de escribir
updateTag(`post-${slug}`)

Valores no deterministas dentro de la caché

// ❌ Incorrecto: la hora se congela en el momento en que se creó la entrada
async function Footer() {
  'use cache'
  return <p>Generado a las {new Date().toLocaleTimeString('es-ES')}</p>
}

✅ Si el valor tiene que cambiar en cada petición, va fuera de 'use cache', en un componente dinámico dentro de <Suspense>.

Olvidar la tag

❌ Cachear con cacheLife('days') y ninguna cacheTag. ✅ Si el dato cambia porque alguien hace algo, necesita una tag. Si no, la única forma de actualizarlo es esperar o hacer un nuevo deploy.


Cuándo usarlo y cuándo evitarlo

SituaciónLo que yo haría
Lista de posts, catálogo, documentación'use cache' + cacheLife('hours') + tag
Dato que cambia por una acción del admintag + updateTag en la Server Action
Webhook de un CMS o de otra APIrevalidateTag(tag, 'max') en Route Handler
Carrito, sesión, datos por usuariodinámico en <Suspense>, sin caché
Cotización, marcador, stock en tiempo realcacheLife('seconds') o dinámico
Origen que ya tiene CDN con s-maxageelige una sola capa para cachear

Próximos pasos

  1. Activa cacheComponents: true en una rama y ejecuta el build: los errores señalan exactamente dónde hay acceso dinámico sin <Suspense>.
  2. Cambia cada unstable_cache por una función con 'use cache', llevando tags a cacheTag y revalidate a cacheLife.
  3. Para cada cacheTag, pregúntate: ¿quién invalida esto, y el usuario necesita verlo al momento? La respuesta elige entre updateTag y revalidateTag.
  4. Revisa los headers de tu origen. Si la API envía s-maxage, tienes dos cachés y solo invalidas una.

Una buena caché es una caché que puedes explicar. Si nadie en el equipo sabe responder "cuánto tiempo queda vieja esto y quién la borra", vale la pena parar y dibujarlo antes de activar el flag. Y si ya migraste algún proyecto al modelo nuevo, cuéntame cómo te fue: quiero saber dónde tropezaste.

André Luiz Vieira

Sobre el autor

André Luiz Vieira

Ingeniero Frontend y Mobile

Vivo en Curitiba, Brasil, y hace cinco años construyo productos en React y React Native para bancos, cooperativas y e-commerce. Aquí escribo sobre lo que aprendo en el camino.

Únete a la conversación

¿Dudas, ideas o una opinión diferente? Los comentarios usan GitHub Discussions; inicia sesión con GitHub para comentar.