Next.js App Router caching: revalidate, dynamic y no-store sin folklore
Cometí el error clásico: agregué export const dynamic = 'force-dynamic' a una ruta que tardaba 800ms en responder y me quedé conforme porque "al menos era fresca". No medí nada. No entendí qué dato necesitaba esa frescura. Solo apliqué el flag que resolvía el síntoma visible — datos desactualizados — sin preguntarme si el costo valía la pena. Meses después, revisando la arquitectura, me di cuenta de que el 70% de esas rutas servían datos que cambiaban una vez por hora. Las estaba regenerando en cada request sin ninguna razón técnica válida.
No lo cuento para mortificarme. Lo cuento porque ese error es casi universal en equipos que están aprendiendo App Router.
Mi tesis: el problema no es memorizar las opciones de cache. Es decidir la frescura que necesita cada dato antes de tocar una sola configuración. Los flags son consecuencia de esa decisión, no el punto de partida.
Qué dice la documentación oficial — y qué no dice
La documentación de caching de Next.js describe cuatro capas: Request Memoization, Data Cache, Full Route Cache y Router Cache. Es una referencia técnica sólida. Lo que no hace — y no tiene por qué hacer — es decirte qué dato merece qué capa.
La doc explica el mecanismo. La decisión de diseño es tuya.
Algunos puntos que la doc deja en claro y que vale reforzar:
fetchcon cache habilitado por defecto (antes de Next.js 15) almacenaba respuestas en el Data Cache indefinidamente salvo que indicaras lo contrario. En Next.js 15 esto cambió: el comportamiento por defecto parafetchen Route Handlers y Server Components pasó ano-store. No des por sentado el comportamiento sin revisar la versión.revalidateaplica tiempo de vida al dato en Data Cache y al segmento en Full Route Cache. Cuando el tiempo expira, el próximo request regenera en background (ISR) y el usuario recibe la versión anterior mientras tanto.dynamic = 'force-dynamic'opta todo el segmento fuera del Full Route Cache. Equivale acache: 'no-store'en cada fetch del segmento, más la señal de que la ruta no puede ser prerenderizada.no-storeen un fetch individual excluye ese dato del Data Cache. No necesitás forzar toda la ruta dinámica si solo un fetch necesita datos frescos.
La distinción entre "excluir un fetch" y "excluir toda la ruta" es exactamente donde se rompe la lógica de quien aprende los flags de memoria.
Leer el cache como contrato de datos
Cada opción de cache es una promesa implícita sobre la frescura del dato que estás sirviendo. Si la pensás así, la decisión se vuelve más clara:
| Opción | Promesa al usuario | Costo operativo |
|---|---|---|
cache: 'force-cache' (default pre-15) | "Este dato puede tener cualquier edad hasta que revalides manualmente" | Mínimo — se sirve desde cache |
revalidate: N | "Este dato tiene a lo sumo N segundos de antigüedad" | Build en background cada N seg, un request paga el costo de regeneración |
cache: 'no-store' | "Este dato es siempre el más reciente posible" | Fetch externo en cada request |
dynamic = 'force-dynamic' | "Esta ruta entera no puede pre-renderizarse; todo va al origen" | Ningún segmento en Full Route Cache |
Antes de escribir cualquier flag, la pregunta útil es: ¿cuántos segundos de antigüedad en este dato cambian la experiencia del usuario o la correctitud del sistema?
Para un blog personal, 3600 segundos es perfectamente aceptable. Para un precio de producto, quizás 60 segundos sea razonable dependiendo del caso de uso. Para el carrito de compras de un usuario, no-store es la respuesta correcta — no porque sea el flag "seguro", sino porque ese dato tiene que ser exacto en el momento del render.
// Correcto: cada fetch con su propio contrato
async function BlogPost({ slug }: { slug: string }) {
// El contenido del post cambia raramente — revalidamos cada hora
const post = await fetch(`/api/posts/${slug}`, {
next: { revalidate: 3600 }
})
// Los comentarios cambian más seguido — cada 5 minutos
const comments = await fetch(`/api/posts/${slug}/comments`, {
next: { revalidate: 300 }
})
// El estado de sesión del usuario nunca va a cache
const session = await fetch('/api/session', {
cache: 'no-store'
})
// ...
}Esto es lo que la doc hace posible pero no prescribe: granularidad por dato, no por ruta.
Dónde se equivoca la gente — y el costo que no ven
Error 1: force-dynamic como solución por defecto
Cuando algo "no funciona" con cache, el instinto es desactivarlo todo. El problema es que force-dynamic en una ruta pública de alto tráfico significa que cada request va al origen — sin ningún beneficio del Full Route Cache. En Vercel y plataformas equivalentes, eso se traduce en tiempo de ejecución de función en cada visita. No es gratis.
Error 2: revalidate: 0 como "lo mismo que no-store"
No son equivalentes. revalidate: 0 tiene comportamiento no especificado en versiones antiguas del framework. Si querés datos frescos en cada request, usá cache: 'no-store' explícitamente. La intención importa para quien lee el código.
Error 3: mezclar revalidate de segmento y de fetch sin entender la precedencia
Si un segmento tiene export const revalidate = 60 y un fetch dentro tiene next: { revalidate: 3600 }, el tiempo efectivo del fetch está limitado por el valor más bajo entre ambos. La doc lo aclara, pero es fácil pasarlo por alto cuando configurás el segmento globalmente y después agregás fetches individuales.
// archivo: app/dashboard/page.tsx
// Este revalidate de segmento actúa como techo para todos los fetches
export const revalidate = 60
async function DashboardPage() {
// Aunque pedís 3600, el segmento lo limita a 60 segundos
const data = await fetch('/api/dashboard', {
next: { revalidate: 3600 } // efectivo: 60 por el segmento
})
// ...
}Error 4: no considerar revalidatePath y revalidateTag como alternativa
Para datos que cambian por evento — un post que se publica, un precio que se actualiza — ISR por tiempo es un mecanismo subóptimo. revalidateTag en una Server Action o Route Handler permite invalidar el cache exactamente cuando el dato cambia, sin esperar un timeout. La doc cubre esto en detalle. Es la opción correcta cuando el dominio tiene eventos claros de mutación, algo que también conecta con los patrones de Server Actions que ya revisé en el post sobre Prisma Server Actions en Next.js.
Matriz de decisión: qué preguntarle a cada dato
Antes de configurar cache en cualquier segmento o fetch, pasá por estas preguntas:
1. ¿Este dato es específico por usuario?
→ Sí: no-store o cookies/headers que ya opt-out del Full Route Cache automáticamente.
→ No: continuar.
2. ¿Cuándo cambia este dato?
→ Por evento conocido (publicación, actualización): revalidateTag en la mutación + fetch con tag.
→ Por tiempo: revalidate: N con N razonable para el dominio.
→ Nunca (o rara vez): force-cache explícito o ISR con revalidate alto.
3. ¿Qué pasa si el usuario ve un dato con 60 segundos de antigüedad?
→ Nada crítico: ISR con revalidate: 60 es perfectamente válido.
→ Algo incorrecto o confuso: no-store.
4. ¿Es una ruta pública de alto tráfico?
→ Sí: el Full Route Cache es valioso. Evitá force-dynamic salvo que sea estrictamente necesario.
→ No (dashboard autenticado, por ejemplo): la penalización de force-dynamic es menor.
// Patrón con revalidateTag — útil cuando el dato cambia por evento
// app/blog/[slug]/page.tsx
async function BlogPostPage({ params }: { params: { slug: string } }) {
const post = await fetch(`/api/posts/${params.slug}`, {
next: {
tags: [`post-${params.slug}`] // tag para invalidación explícita
}
})
// ...
}
// app/actions/publish-post.ts (Server Action)
'use server'
import { revalidateTag } from 'next/cache'
export async function publishPost(slug: string) {
// Publicar el post en la base de datos...
revalidateTag(`post-${slug}`) // invalida exactamente ese dato
}Límites honestos: qué no podés concluir sin datos propios
La doc oficial describe el comportamiento del framework. No prescribe métricas de performance, costos por plataforma ni umbrales de revalidate para casos de uso específicos.
Algunas cosas que no podés decidir solo con la documentación:
- El tiempo de revalidate "correcto" para tu dominio. Eso depende de la frecuencia real de cambio de los datos, algo que solo los logs de producción propios pueden decirte.
- Si ISR por tiempo o por evento es más eficiente en tu caso. Depende del volumen de mutaciones vs. el tráfico de lectura.
- Si el costo de
force-dynamicen una ruta pública es significativo. Depende de la plataforma de deploy, el tráfico y el tiempo de ejecución de la función. Vercel tiene su propio modelo de costos; Railway tiene otro.
Lo que sí podés hacer antes de tener datos de producción: definir el contrato de frescura por dato durante el diseño, y luego ajustar el valor numérico de revalidate cuando tengás información real. Empezar con un número razonable y cambiarlo es menos costoso que arrancaron con force-dynamic global y nunca revisarlo.
Si trabajás con monorepos y CI, el tema del cache se extiende más allá del runtime — algo que exploré desde otro ángulo en el post sobre pnpm workspaces y caché de CI. Y si estás pensando en cómo proteger las rutas dinámicas que sí necesitan no-store, el modelo de rate limiting por ruta es el siguiente paso lógico.
FAQ
¿dynamic = 'force-dynamic' y cache: 'no-store' son lo mismo?
No exactamente. cache: 'no-store' en un fetch excluye ese dato del Data Cache. force-dynamic en un segmento excluye toda la ruta del Full Route Cache y señala que no puede prerenderizarse. El primero es granular por fetch; el segundo es una decisión de segmento completo. Podés tener fetches con no-store dentro de una ruta que sí está en Full Route Cache si esos fetches no afectan la renderización completa.
¿En Next.js 15 el cache por defecto cambió?
Sí. En Next.js 15, el comportamiento por defecto de fetch en Route Handlers y Server Components pasó a no-store (sin cache), revertiendo el default agresivo de versiones anteriores. Si estás migrando o revisando código de Next.js 13/14, este cambio puede explicar comportamientos distintos. La documentación oficial cubre los defaults por versión.
¿Cuándo tiene sentido usar revalidateTag en lugar de revalidate: N?
Cuando el dato tiene eventos de mutación bien definidos. Si publicás un artículo, actualizás un precio o cambiás configuración, revalidateTag invalida exactamente ese dato en ese momento. revalidate: N es útil cuando no controlás cuándo cambia el dato externo — una API de terceros, por ejemplo — y necesitás un mecanismo de "caducidad garantizada".
¿Qué pasa si mezclo revalidate de segmento con revalidate de fetch individual?
El segmento actúa como techo. Si el segmento tiene revalidate: 60 y un fetch tiene revalidate: 3600, el dato se revalida cada 60 segundos, no cada hora. El valor más bajo entre segmento y fetch gana. Esto está documentado en la referencia oficial.
¿no-store garantiza que el dato nunca se sirva desde cache en ninguna capa?
Excluye el dato del Data Cache de Next.js. No tiene control sobre cachés intermedias — CDN, proxy, headers HTTP del origen externo. Si el fetch externo devuelve Cache-Control: max-age=300, ese dato puede quedar en una capa que no es de Next.js. Para garantías absolutas de frescura, el origen también tiene que cooperar.
¿Tiene sentido usar force-cache explícito en Next.js 15 si el default cambió?
Sí, y es una buena práctica de legibilidad. Si el contrato del dato es "puede ser cacheado indefinidamente hasta invalidación manual", declararlo explícitamente con force-cache hace que la intención sea visible para quien lea el código después. No dependas del comportamiento por defecto para comunicar decisiones de diseño.
Cierre: la decisión antes que el flag
No hay una configuración de cache correcta para App Router. Hay configuraciones que coinciden o no con el contrato de frescura que cada dato necesita.
Mi recomendación práctica: antes de escribir dynamic, revalidate o no-store, escribí en un comentario la respuesta a "¿cuántos segundos de antigüedad en este dato son aceptables y por qué?". Si no podés responderlo, no tenés suficiente información para elegir el flag — y cualquier cosa que pongas va a ser folklore.
El próximo paso concreto: abrí la documentación oficial de caching en App Router, buscá la sección de tu versión de Next.js y verificá el default de fetch para esa versión. Es el cambio más silencioso entre Next.js 14 y 15, y el más fácil de pasar por alto.
Fuente original:
- Next.js Caching Documentation: https://nextjs.org/docs/app/building-your-application/caching
Artículos Relacionados
Prisma query logging y PostgreSQL: dónde termina el ORM y empieza la base
Los query logs de Prisma ayudan a detectar patrones, pero si el problema vive adentro de PostgreSQL, el ORM no va a mostrártelo. Acá separo cuándo alcanza con Prisma logging y cuándo necesitás instrumentar la base directamente.
Rate limiting en aplicaciones web: qué proteger antes de elegir una librería
Antes de instalar cualquier middleware de rate limiting en Next.js, necesitás definir qué activo protegés, qué abuso esperás y qué cuesta un falso positivo. La librería es lo último. La política es lo primero.
Por qué dejé de usar useEffect para sincronizar estado y qué uso ahora
useEffect no está roto — el modelo mental con el que lo enseñamos sí lo está. Revisé cada useEffect de una codebase en React 19 y encontré 4 categorías concretas donde era un antipatrón. Acá están los patrones que los reemplazaron: derived state, event handlers, use(), y Server Actions.
Comentarios (0)
¿Qué pensás de esto?
Dejá tu comentario en 10 segundos.
Usamos tu login solo para mostrar tu nombre y avatar. Nada de spam.