DEV Community

Juan Torchia
Juan Torchia Subscriber

Posted on • Originally published at juanchi.dev

Next.js App Router caching: revalidate, dynamic y no-store sin folklore

¿Por qué el caching de Next.js App Router se explica siempre como una lista de flags para memorizar y casi nunca como una decisión sobre los datos? Llevo un buen tiempo viendo posts del tipo "agregá no-store y listo" o "poné revalidate = 0 si querés datos frescos" — y funcionan, en el sentido de que apagan el fuego. Pero no explican por qué había fuego.

Mi tesis es simple: el problema no es no conocer los flags. Es que la mayoría de las veces no está claro qué frescura necesita cada dato antes de escribir el componente. Y cuando eso no está claro, los flags se convierten en folklore.

El modelo de caching de App Router: qué dice la documentación oficial

La documentación oficial de Next.js describe cuatro capas de cache que operan de forma independiente:

  1. Request Memoization — deduplicación de fetch dentro de un mismo render tree
  2. Data Cache — persistencia entre requests (el que más confunde)
  3. Full Route Cache — HTML y RSC payload cacheado en el servidor
  4. Router Cache — prefetch del lado del cliente en el browser

Lo que la doc no dice explícitamente es cuándo cada capa importa para vos. Describe el mecanismo. La decisión de diseño es tuya.

El default en App Router es cachear agresivamente: los fetch dentro de Server Components se cachean por defecto en el Data Cache. Un fetch sin opciones explícitas se comporta como si dijera { cache: 'force-cache' }. Eso sorprende a mucha gente que viene de Pages Router donde el default era sin cache.

Acá está la estructura real de opciones que controlan el Data Cache:

// Opción 1: forzar cache (default en App Router si no especificás nada)
const res = await fetch('https://api.ejemplo.com/datos', {
  cache: 'force-cache', // guarda la respuesta indefinidamente hasta revalidación
})

// Opción 2: sin cache — siempre fresco, cada request va al origen
const res = await fetch('https://api.ejemplo.com/datos', {
  cache: 'no-store', // nunca persiste, nunca lee de cache
})

// Opción 3: revalidación por tiempo — fresco cada N segundos
const res = await fetch('https://api.ejemplo.com/datos', {
  next: { revalidate: 60 }, // ISR-style: refresca cada 60 segundos
})

// Opción 4: revalidación on-demand (desde Route Handler o Server Action)
// revalidatePath('/ruta') o revalidateTag('mi-tag')
// Esto invalida desde código, no por tiempo
Enter fullscreen mode Exit fullscreen mode

Y a nivel de segmento de ruta, podés definir el contrato para todo el componente con los exports de segmento:

// En cualquier page.tsx o layout.tsx

// Todo el segmento como dinámico: equivale a no-store en todos los fetch
export const dynamic = 'force-dynamic'

// Todo el segmento como estático: equivale a force-cache
export const dynamic = 'force-static'

// Revalidación por tiempo para todo el segmento
export const revalidate = 300 // 5 minutos
Enter fullscreen mode Exit fullscreen mode

La clave es que los exports de segmento son un shortcut que afecta toda la ruta. Si mezclás export const dynamic = 'force-dynamic' con fetch(..., { cache: 'force-cache' }) dentro del mismo componente, la configuración del segmento gana. Eso es una decisión de diseño de Next.js documentada, y también es la fuente de la mayoría de las confusiones.

El contrato de datos: la pregunta que hay que hacerse antes

La fricción real no es técnica. Es de diseño de producto. Antes de elegir cualquier opción de cache, la pregunta correcta es:

¿Qué tan viejo puede ser este dato antes de que el usuario se dé cuenta o se afecte algo?

Eso es el contrato de frescura. Y tiene respuestas distintas según el tipo de dato:

Dato Contrato de frescura Opción recomendada
Precio de producto en e-commerce Nunca viejo — afecta conversión no-store
Artículo de blog publicado Puede tener horas — no cambia seguido revalidate: 3600
Config del sitio (colores, copy) Puede tener días force-cache + revalidateTag on-demand
Stock disponible Nunca viejo — afecta decisión de compra no-store
Lista de categorías Puede tener días — raramente cambia force-cache + deploy como trigger
Sesión de usuario Nunca cacheable en servidor compartido no-store + cookie auth

Cuando tenés el contrato de frescura claro, el flag es consecuencia. Cuando no lo tenés, el flag es magia.

Dónde se equivoca la gente: las tres recetas que cuestan caro

Receta 1: force-dynamic en toda la app "por las dudas"

Es el equivalente a no tener cache. Entiendo el impulso — si todo es dinámico, nunca vas a tener datos viejos. El costo es que perdés Full Route Cache, perdés ISR y cada request golpea todos los orígenes. Para una página de blog con 10k visitas diarias, eso tiene impacto en latencia y en costos de infraestructura. No inventé ese número: es fácil reproducirlo con cualquier simulación de carga contra un deploy de Vercel o Railway.

Receta 2: mezclar no-store y force-cache en el mismo componente sin entender la precedencia

Si un componente hijo hace un fetch con force-cache pero el layout padre tiene export const dynamic = 'force-dynamic', el segmento dinámico gana y el fetch nunca llega al Data Cache. Esto no es un bug — está documentado. Pero si no lo sabés, pasás horas mirando por qué el cache "no funciona".

Receta 3: asumir que revalidate: 0 es lo mismo que no-store

No es lo mismo. revalidate: 0 significa revalidar en cada request, lo que en la práctica se comporta similar a no-store para el Data Cache. Pero el Full Route Cache puede seguir sirviendo el HTML cacheado dependiendo de otras condiciones del segmento. no-store en el fetch es más explícito sobre la intención. La doc distingue los dos comportamientos aunque la diferencia práctica en muchos casos es pequeña — lo correcto es usar el que describe mejor el contrato.

Checklist de decisión: cómo elegir sin memorizar

Antes de escribir el fetch o configurar el segmento, respondé estas cuatro preguntas:

1. ¿Este dato cambia entre requests de distintos usuarios?
   → SÍ: probablemente no debería estar en cache compartido → no-store

2. ¿Este dato cambia con frecuencia predecible (cada X minutos/horas)?
   → SÍ: revalidate: <segundos>

3. ¿Este dato cambia solo cuando alguien hace una acción explícita (un editor publica, un admin actualiza)?
   → SÍ: force-cache + revalidateTag() on-demand desde Server Action

4. ¿Este dato es completamente estático y nunca va a cambiar después del deploy?
   → SÍ: force-cache sin revalidación (o simplemente no hagas fetch, importá el dato)
Enter fullscreen mode Exit fullscreen mode

Este checklist no reemplaza el criterio — lo estructura. Si no podés responder la pregunta 1 con certeza, eso es una señal de que el contrato de datos está indefinido, no de que el flag esté mal elegido.

Para la revalidación on-demand, el patrón con revalidateTag es especialmente útil cuando tenés contenido gestionado (como un CMS o un panel de administración):

// En una Server Action que ejecuta cuando el editor publica
'use server'
import { revalidateTag } from 'next/cache'

export async function publicarArticulo(id: string) {
  // ...lógica de guardado...

  // Invalidar solo los fetch que tienen este tag
  revalidateTag('articulos')
  revalidateTag(`articulo-${id}`)
}

// En el componente que trae los datos:
const res = await fetch('https://api.ejemplo.com/articulos', {
  next: { tags: ['articulos'] }, // asocia el fetch al tag para invalidación selectiva
})
Enter fullscreen mode Exit fullscreen mode

Este patrón evita el martillo de revalidatePath('/') que invalida todo. Es más quirúrgico y más barato en términos de renders.

Si te interesa entender cómo este modelo de cache se relaciona con el modelo mental de Server Components, escribí sobre eso en React 19 Server Components y caching: el modelo mental que me faltaba.

Errores y gotchas que no están en los tutoriales de cinco minutos

El Data Cache sobrevive deploys en Vercel por defecto. Esto sorprende: si hacés un deploy, el Data Cache no se invalida automáticamente a menos que uses revalidatePath, revalidateTag, o configures revalidate con un tiempo corto. La doc lo menciona, pero los tutoriales lo omiten. Si actualizás contenido en un deploy, necesitás una estrategia explícita de invalidación.

Los Route Handlers tienen su propio modelo. Un GET handler en app/api/ruta/route.ts es dinámico por defecto si no definís export const dynamic = 'force-static' o export const revalidate. Esto es lo opuesto del comportamiento en page.tsx. Sí, es confuso. Está documentado, pero requiere leer con cuidado.

Request Memoization solo vive dentro de un render. Si llamás el mismo fetch en dos Server Components distintos dentro del mismo request, Next.js los deduplica automáticamente. Eso es memoization, no Data Cache. Se vacía al final de cada request. Esto significa que no podés depender de eso para compartir datos entre requests distintos.

no-store en un fetch no hace que la ruta sea dinámica automáticamente. Si el segmento tiene export const revalidate = 3600, esa configuración puede tener precedencia sobre el comportamiento esperado del fetch. La interacción entre configuraciones de segmento y opciones de fetch tiene una tabla de precedencia en la documentación oficial que vale la pena leer una vez completa.

Sobre patrones de manejo de requests en Next.js Middleware, hay un análisis separado en Next.js 16 Middleware: patrones de autorización que escalan — el caching y el middleware interactúan en escenarios de autenticación de maneras que merecen atención propia.

Límites: qué no podés concluir sin datos propios

Esto es importante. Hay cosas que este análisis no puede decirte:

  • Cuánto tiempo ahorrás con ISR versus no-store en tu app específica. Depende del tráfico, de la latencia de tus orígenes y de la infraestructura. La única forma de saberlo es medirlo con tus propios logs.
  • Si el Full Route Cache vale la pena para vos si la mayoría de tus páginas tienen datos personalizados por usuario. En ese caso probablemente no aplica — pero no puedo afirmar eso universalmente.
  • Cómo se comporta el Router Cache en el browser bajo diferentes condiciones de red. Es client-side y tiene su propia lógica de prefetch que puede hacer que los datos parezcan viejos aunque el servidor esté revalidando correctamente.

Si querés profundizar en cómo las decisiones de cache impactan herramientas que corren en el cliente y en el servidor al mismo tiempo, el post sobre Web Crypto API en el browser vs Node.js tiene un paralelo interesante sobre asumir comportamientos uniformes donde no los hay.

FAQ

¿Cuál es la diferencia entre cache: 'no-store' y export const dynamic = 'force-dynamic'?
no-store aplica a un fetch individual. force-dynamic aplica a todo el segmento de ruta (page, layout) y hace que Next.js trate toda la ruta como dinámica. Si solo necesitás un dato fresco dentro de una ruta mayormente estática, no-store en ese fetch es más preciso.

¿revalidate: 0 equivale a no-store?
En la práctica se comportan de forma similar para el Data Cache, pero no son semánticamente equivalentes. revalidate: 0 revalida en cada request; no-store never guarda en cache. Para expresar la intención de "nunca cachear", no-store es más claro. La doc oficial distingue los dos comportamientos.

¿El Data Cache se limpia con cada deploy?
En Vercel, no automáticamente. El Data Cache persiste entre deploys a menos que lo invalides explícitamente con revalidatePath, revalidateTag, o que el tiempo de revalidate expire. Esto es importante para contenido que actualizás en cada deploy.

¿Puedo mezclar force-cache y no-store en el mismo componente?
Técnicamente sí, pero la configuración del segmento tiene precedencia sobre las opciones de fetch individual. Si el segmento dice force-dynamic, todos los fetch del componente se comportan como dinámicos independientemente de lo que pidas en la opción cache. Revisá la tabla de precedencia en la documentación.

¿revalidateTag funciona si el fetch está en un componente hijo profundo?
Sí, siempre que el fetch haya usado next: { tags: ['mi-tag'] }. El tag asocia el fetch con una clave de invalidación global. La profundidad del componente en el árbol no importa para la invalidación.

¿Cuándo conviene usar revalidate a nivel de segmento versus a nivel de fetch?
A nivel de segmento conviene cuando todos los datos de la ruta tienen el mismo contrato de frescura. Si la ruta tiene datos con ciclos de vida distintos (algo estático y algo que cambia cada hora), es mejor controlar cada fetch individualmente para no invalidar más de lo necesario.

Mi postura, sin folklore

El App Router de Next.js tiene un sistema de caching genuinamente potente. También tiene suficiente complejidad de interacciones como para que "poné no-store y listo" sea la respuesta más común en Stack Overflow — y técnicamente es correcta, pero es el equivalente a apagar la calefacción cuando hace calor en lugar de abrir la ventana.

Lo que me parece valioso hacer antes de tocar cualquier flag: escribir en un comentario o en un README qué tan fresco necesita ser ese dato y por qué. Eso fuerza la decisión de diseño. Después el flag es consecuencia, no magia.

No voy a prometerte que esto va a reducir tu TTFB un porcentaje específico ni que va a escalar a X usuarios. Eso depende de demasiadas variables que solo vos podés medir en tu propio deploy. Lo que sí puedo decirte es que cuando el contrato de frescura está explícito, los problemas de cache se vuelven diagnósticos, no misterios.

El próximo paso concreto: agarrá los tres fetches más críticos de tu app en App Router y escribí al lado de cada uno cuál es el contrato de frescura. Si no podés escribirlo, esa es la fricción real que hay que resolver antes de elegir un flag.


Fuentes originales:


Este artículo fue publicado originalmente en juanchi.dev

Top comments (0)