
Caché en Next.js 16: use cache, cacheLife y cacheTag
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.

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ón | Lo que yo haría |
|---|---|
| Lista de posts, catálogo, documentación | 'use cache' + cacheLife('hours') + tag |
| Dato que cambia por una acción del admin | tag + updateTag en la Server Action |
| Webhook de un CMS o de otra API | revalidateTag(tag, 'max') en Route Handler |
| Carrito, sesión, datos por usuario | dinámico en <Suspense>, sin caché |
| Cotización, marcador, stock en tiempo real | cacheLife('seconds') o dinámico |
Origen que ya tiene CDN con s-maxage | elige una sola capa para cachear |
Próximos pasos
- Activa
cacheComponents: trueen una rama y ejecuta el build: los errores señalan exactamente dónde hay acceso dinámico sin<Suspense>. - Cambia cada
unstable_cachepor una función con'use cache', llevandotagsacacheTagyrevalidateacacheLife. - Para cada
cacheTag, pregúntate: ¿quién invalida esto, y el usuario necesita verlo al momento? La respuesta elige entreupdateTagyrevalidateTag. - 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
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.