Revalidación - Actualizando el Cache

La revalidación es el proceso de limpiar el cache y obtener datos frescos. Es como actualizar el número de teléfono de la pizzería cuando cambian de local.

El problema

Tienes cache activado para que tu sitio sea rápido:

export const revalidate = 3600 // Cache por 1 hora

export default async function ProductosPage() {
  const productos = await fetch('https://api.mitienda.com/productos')
    .then(r => r.json())
  
  return <div>{/* productos */}</div>
}

Pero... ¿qué pasa si:

  • Agregas un producto nuevo
  • Cambias el precio de un producto
  • Un producto se agota

Los usuarios seguirán viendo datos viejos por 1 hora. Necesitas revalidar el cache.

Dos tipos de revalidación

1. Time-based (basada en tiempo)

Revalida automáticamente después de X segundos.

2. On-demand (bajo demanda)

Revalidas manualmente cuando TÚ decides.


Revalidación Time-Based

Con revalidate en la página

La forma más simple: define cada cuánto tiempo quieres actualizar:

// app/productos/page.tsx

// Revalida cada 10 minutos (600 segundos)
export const revalidate = 600

export default async function ProductosPage() {
  const productos = await fetch('https://api.mitienda.com/productos')
    .then(r => r.json())
  
  return (
    <div>
      <h1>Productos</h1>
      {productos.map(p => (
        <div key={p.id}>{p.nombre} - ${p.precio}</div>
      ))}
    </div>
  )
}

Cómo funciona:

  1. Primera visita (0:00): NextJS obtiene datos, genera HTML, cachea
  2. Visitas 0:00 - 9:59: Todos ven el HTML cacheado (instantáneo)
  3. Visita a las 10:00: El visitante ve HTML viejo (rápido), PERO NextJS regenera en background
  4. Visita a las 10:01: Ya todos ven el HTML nuevo

Esto se llama Stale-While-Revalidate (SWR): sirves contenido viejo mientras generas el nuevo.

Con next.revalidate en fetch

Puedes configurar revalidación por cada fetch individual:

export default async function DashboardPage() {
  // Productos: actualiza cada 10 minutos
  const productos = await fetch('https://api.mitienda.com/productos', {
    next: { revalidate: 600 }
  }).then(r => r.json())
  
  // Usuarios: actualiza cada 1 hora
  const usuarios = await fetch('https://api.mitienda.com/usuarios', {
    next: { revalidate: 3600 }
  }).then(r => r.json())
  
  // Ventas: actualiza cada 30 segundos
  const ventas = await fetch('https://api.mitienda.com/ventas', {
    next: { revalidate: 30 }
  }).then(r => r.json())
  
  return (
    <div>
      <div>Productos: {productos.length}</div>
      <div>Usuarios: {usuarios.length}</div>
      <div>Ventas: ${ventas.total}</div>
    </div>
  )
}

Cada fetch tiene su propio timer de revalidación independiente.

Valores comunes de revalidate

export const revalidate = 0        // Nunca cachear (siempre fresh)
export const revalidate = 60       // 1 minuto - datos muy dinámicos
export const revalidate = 300      // 5 minutos - feeds, noticias
export const revalidate = 600      // 10 minutos - catálogos de productos
export const revalidate = 3600     // 1 hora - páginas informativas
export const revalidate = 86400    // 24 horas - contenido casi estático
export const revalidate = false    // Cachear para siempre (requiere revalidación manual)
💡
¿Qué valor usar?

Pregúntate: ¿Qué tan viejo puede ser el dato antes de que sea un problema?

  • Precio de productos: 5-10 minutos está bien
  • Stock disponible: 1-2 minutos
  • Blog posts: 1 hora o más
  • Términos y condiciones: 24 horas o manual

Balance: Muy bajo = muchas regeneraciones (más lento). Muy alto = datos desactualizados.


Revalidación On-Demand

Para cuando necesitas actualizar el cache INMEDIATAMENTE, sin esperar el timer.

Con revalidatePath()

Invalida el cache de una ruta específica:

// app/actions.ts
'use server'

import { revalidatePath } from 'next/cache'
import { z } from 'zod'

// Define el schema para validar datos
const ProductoSchema = z.object({
  nombre: z.string().min(3, 'Nombre muy corto'),
  precio: z.number().positive('Precio debe ser positivo'),
})

export async function agregarProducto(formData: FormData) {
  // 1. Validar datos antes de usar
  const datos = ProductoSchema.parse({
    nombre: formData.get('nombre'),
    precio: Number(formData.get('precio')),
  })
  
  // 2. Agregar producto a la base de datos
  await db.producto.create({ data: datos })
  
  // 3. Invalidar cache de la página de productos
  revalidatePath('/productos')
  
  // 4. NextJS regenerará la página en la próxima visita
}
💡
Valida antes de guardar

Siempre valida datos del usuario antes de guardar en la base de datos. Herramientas como Zod te ayudan a validar y obtener tipos de TypeScript automáticamente.

Uso desde un componente:

'use client'

import { agregarProducto } from '@/app/actions'

export default function FormularioProducto() {
  async function handleSubmit(formData: FormData) {
    await agregarProducto(formData)
    // Cache ya invalidado, próxima visita verá el nuevo producto
  }
  
  return (
    <form action={handleSubmit}>
      <input name="nombre" placeholder="Nombre" />
      <input name="precio" placeholder="Precio" type="number" />
      <button type="submit">Agregar</button>
    </form>
  )
}

Invalidar layouts

import { revalidatePath } from 'next/cache'

// Solo invalida la página
revalidatePath('/productos')

// Invalida la página Y el layout (todo el segmento)
revalidatePath('/productos', 'layout')

// Invalida TODO el sitio
revalidatePath('/', 'layout')

Con revalidateTag()

Para invalidación más selectiva y granular:

Paso 1: Etiquetar los fetch

// app/productos/page.tsx
export default async function ProductosPage() {
  const productos = await fetch('https://api.mitienda.com/productos', {
    next: { 
      tags: ['productos']  // Etiqueta este fetch
    }
  }).then(r => r.json())
  
  return <div>{/* productos */}</div>
}

// app/productos/[id]/page.tsx
export default async function ProductoPage({ params }) {
  const producto = await fetch(`https://api.mitienda.com/productos/${params.id}`, {
    next: { 
      tags: ['productos', `producto-${params.id}`]  // Múltiples tags
    }
  }).then(r => r.json())
  
  return <div>{producto.nombre}</div>
}

Paso 2: Invalidar por tag

'use server'

import { revalidateTag } from 'next/cache'

export async function actualizarProducto(id: string, datos: any) {
  // Actualizar en DB
  await db.producto.update({
    where: { id },
    data: datos
  })
  
  // Invalida SOLO los fetch con este tag
  revalidateTag(`producto-${id}`)
  
  // O invalida TODOS los productos
  revalidateTag('productos')
}

Ventaja: No necesitas saber qué páginas usan ese dato. Solo invalidas el tag y NextJS limpia todo lo relacionado.

Ejemplo completo: Sistema de gestión de productos

// app/actions/productos.ts
'use server'

import { revalidateTag } from 'next/cache'
import { db } from '@/lib/db'
import { z } from 'zod'

// Schemas de validación
const ProductoSchema = z.object({
  nombre: z.string().min(3).max(100),
  precio: z.number().positive(),
  descripcion: z.string().max(500),
  categoriaId: z.string().uuid(),
})

const ActualizarProductoSchema = ProductoSchema.partial()

export async function crearProducto(formData: FormData) {
  // Validar datos
  const datos = ProductoSchema.parse({
    nombre: formData.get('nombre'),
    precio: Number(formData.get('precio')),
    descripcion: formData.get('descripcion'),
    categoriaId: formData.get('categoriaId'),
  })
  
  const producto = await db.producto.create({ data: datos })
  
  // Invalida lista de productos
  revalidateTag('productos')
  
  return producto
}

export async function actualizarProducto(id: string, formData: FormData) {
  // Validar datos parciales (todos los campos son opcionales)
  const datos = ActualizarProductoSchema.parse({
    nombre: formData.get('nombre'),
    precio: formData.get('precio') ? Number(formData.get('precio')) : undefined,
    descripcion: formData.get('descripcion'),
    categoriaId: formData.get('categoriaId'),
  })
  
  await db.producto.update({
    where: { id },
    data: datos
  })
  
  // Invalida este producto específico Y la lista
  revalidateTag(`producto-${id}`)
  revalidateTag('productos')
}

export async function eliminarProducto(id: string) {
  await db.producto.delete({ where: { id } })
  
  revalidateTag(`producto-${id}`)
  revalidateTag('productos')
}

export async function actualizarPrecio(id: string, nuevoPrecio: number) {
  // Validar solo el precio
  const PrecioSchema = z.number().positive('Precio debe ser positivo')
  const precioValido = PrecioSchema.parse(nuevoPrecio)
  
  await db.producto.update({
    where: { id },
    data: { precio: precioValido }
  })
  
  // Solo invalida el producto, no toda la lista
  revalidateTag(`producto-${id}`)
}

Validación en producción: Este ejemplo usa Zod para validar datos. En aplicaciones reales, siempre valida antes de guardar en la base de datos para prevenir datos corruptos y errores de seguridad.

Componente admin:

'use client'

import { actualizarPrecio } from '@/app/actions/productos'

export default function AdminPrecio({ producto }) {
  async function handleSubmit(formData: FormData) {
    const nuevoPrecio = Number(formData.get('precio'))
    await actualizarPrecio(producto.id, nuevoPrecio)
    alert('Precio actualizado')
  }
  
  return (
    <form action={handleSubmit}>
      <p>Producto: {producto.nombre}</p>
      <p>Precio actual: ${producto.precio}</p>
      <input 
        name="precio" 
        type="number" 
        defaultValue={producto.precio}
      />
      <button>Actualizar precio</button>
    </form>
  )
}

ISR - Incremental Static Regeneration

ISR combina páginas estáticas con revalidación time-based.

¿Qué es ISR?

Imagina un blog con 10,000 artículos:

Problema con SSG (Static Site Generation) tradicional:

  • Al hacer build, generas 10,000 páginas HTML
  • Si corriges un typo en un artículo, debes hacer build completo (regenerar 10,000 páginas)
  • Demora mucho tiempo

Solución con ISR:

  • Generas solo los artículos más populares en build
  • Los demás se generan cuando alguien los visita por primera vez
  • Se regeneran automáticamente cada X tiempo
  • No necesitas rebuild completo

Ejemplo básico de ISR

// app/blog/[slug]/page.tsx

// Genera solo los 100 posts más populares en build
export async function generateStaticParams() {
  const posts = await fetch('https://api.miblog.com/posts/populares')
    .then(r => r.json())
  
  return posts.slice(0, 100).map(post => ({
    slug: post.slug
  }))
}

// Revalida cada 1 hora
export const revalidate = 3600

// Si visitan un post que no fue generado en build, NextJS lo genera on-demand
export default async function BlogPost({ params }) {
  const post = await fetch(`https://api.miblog.com/posts/${params.slug}`)
    .then(r => r.json())
  
  return (
    <article>
      <h1>{post.titulo}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.contenido }} />
    </article>
  )
}

Qué sucede:

  1. Build time: NextJS genera HTML para los 100 posts populares
  2. Usuario visita post #150: NextJS genera el HTML on-demand, lo cachea
  3. Otros usuarios visitan post #150: Ven el HTML cacheado (rápido)
  4. Después de 1 hora: NextJS regenera ese post automáticamente
  5. Usuario edita el post: Puedes invalidarlo manualmente con revalidateTag()

ISR para ecommerce

// app/productos/[id]/page.tsx

// Genera páginas solo para productos destacados
export async function generateStaticParams() {
  const destacados = await db.producto.findMany({
    where: { destacado: true },
    take: 50
  })
  
  return destacados.map(p => ({ id: p.id }))
}

// Revalida cada 10 minutos
export const revalidate = 600

// Genera páginas on-demand para productos no destacados
export const dynamicParams = true // Permitir params no generados en build

export default async function ProductoPage({ params }) {
  const producto = await fetch(`https://api.mitienda.com/productos/${params.id}`, {
    next: { 
      tags: [`producto-${params.id}`],
      revalidate: 600 
    }
  }).then(r => r.json())
  
  return (
    <div>
      <h1>{producto.nombre}</h1>
      <p>${producto.precio}</p>
      <button>Comprar</button>
    </div>
  )
}

Ventajas:

  • Build rápido (solo 50 productos)
  • Todos los productos son accesibles
  • Los más visitados están siempre cacheados
  • Se actualizan cada 10 minutos

Cache para bases de datos con unstable_cache

fetch() cachea APIs HTTP, pero ¿qué pasa con consultas directas a tu base de datos?

Sin cache (lento)

import { db } from '@/lib/db'

export default async function ProductosPage() {
  // Esta consulta se ejecuta en CADA request
  const productos = await db.producto.findMany({
    where: { disponible: true },
    orderBy: { nombre: 'asc' }
  })
  
  return <div>{/* productos */}</div>
}

Con unstable_cache

import { unstable_cache } from 'next/cache'
import { db } from '@/lib/db'

// Envuelve tu consulta en unstable_cache
const obtenerProductos = unstable_cache(
  async () => {
    return await db.producto.findMany({
      where: { disponible: true },
      orderBy: { nombre: 'asc' }
    })
  },
  ['productos-disponibles'],  // Cache key
  {
    revalidate: 600,  // Revalida cada 10 minutos
    tags: ['productos']  // Tags para invalidación
  }
)

export default async function ProductosPage() {
  const productos = await obtenerProductos()
  
  return <div>{/* productos */}</div>
}

Invalidar manualmente:

'use server'

import { revalidateTag } from 'next/cache'

export async function agregarProducto(datos: any) {
  await db.producto.create({ data: datos })
  
  // Limpia el cache de la consulta
  revalidateTag('productos')
}

Ejemplo avanzado con parámetros

import { unstable_cache } from 'next/cache'
import { db } from '@/lib/db'

// Cache con parámetros dinámicos
const obtenerProductosPorCategoria = unstable_cache(
  async (categoriaId: string) => {
    return await db.producto.findMany({
      where: { 
        categoriaId,
        disponible: true 
      }
    })
  },
  ['productos-por-categoria'],  // Base key
  {
    revalidate: 300,
    tags: ['productos']
  }
)

export default async function CategoriaPage({ params }) {
  // Cada categoría tiene su propio cache
  const productos = await obtenerProductosPorCategoria(params.id)
  
  return <div>{/* productos */}</div>
}
⚠️
unstable_cache es experimental

La API de unstable_cache puede cambiar en futuras versiones. El equipo de Next.js está trabajando en una versión estable.

Por ahora funciona bien, pero estate atento a los changelogs cuando actualices Next.js.


Patrones comunes de revalidación

Patrón 1: Revalidar después de mutaciones

Cada vez que modificas datos, invalida el cache relacionado:

'use server'

import { revalidateTag } from 'next/cache'
import { z } from 'zod'

// Schema para validar items del carrito
const OrdenItemSchema = z.object({
  productoId: z.string().uuid(),
  cantidad: z.number().int().positive(),
})

const OrdenSchema = z.object({
  items: z.array(OrdenItemSchema),
})

export async function crearOrden(formData: FormData) {
  // 1. Validar datos de la orden
  const datosOrden = OrdenSchema.parse({
    items: JSON.parse(formData.get('items') as string)
  })
  
  // 2. Crear orden
  const orden = await db.orden.create({
    data: { items: datosOrden.items }
  })
  
  // 3. Actualizar stock
  for (const item of datosOrden.items) {
    await db.producto.update({
      where: { id: item.productoId },
      data: { 
        stock: { decrement: item.cantidad }
      }
    })
  }
  
  // 4. Invalidar cache
  revalidateTag('productos')  // Lista actualizada con nuevo stock
  datosOrden.items.forEach(item => {
    revalidateTag(`producto-${item.productoId}`)  // Cada producto individual
  })
  revalidateTag('ordenes')  // Dashboard de órdenes
  
  return orden
}

Por qué validar primero:

  • Evita datos inválidos en la base de datos
  • Detecta errores antes de modificar múltiples tablas
  • Mejor experiencia para el usuario (errores claros)
  • Código más seguro y mantenible

Lee más: Aprende todo sobre validación de datos en nuestra guía completa de Zod.

Patrón 2: Revalidación jerárquica

Organiza tus tags en jerarquía:

// Estrategia de tags
const TAGS = {
  // Tags generales
  PRODUCTOS: 'productos',
  CATEGORIAS: 'categorias',
  
  // Tags específicos
  producto: (id: string) => `producto-${id}`,
  categoria: (id: string) => `categoria-${id}`,
  productosDeCategoria: (catId: string) => `productos-categoria-${catId}`
}

// Al crear producto
async function crearProducto(datos: any) {
  const producto = await db.producto.create({ data: datos })
  
  revalidateTag(TAGS.PRODUCTOS)  // Invalida lista general
  revalidateTag(TAGS.productosDeCategoria(datos.categoriaId))  // Invalida categoría específica
}

// Al actualizar producto
async function actualizarProducto(id: string, datos: any) {
  await db.producto.update({ where: { id }, data: datos })
  
  revalidateTag(TAGS.producto(id))  // Solo este producto
  
  // Si cambió de categoría, invalida ambas categorías
  if (datos.categoriaId) {
    const productoViejo = await db.producto.findUnique({ where: { id } })
    revalidateTag(TAGS.productosDeCategoria(productoViejo.categoriaId))
    revalidateTag(TAGS.productosDeCategoria(datos.categoriaId))
  }
}

Patrón 3: Revalidación en cascada

Cuando un cambio afecta múltiples páginas:

'use server'

import { revalidatePath, revalidateTag } from 'next/cache'

export async function cambiarPrecioProducto(id: string, nuevoPrecio: number) {
  // Obtener info del producto
  const producto = await db.producto.findUnique({
    where: { id },
    include: { categoria: true }
  })
  
  // Actualizar precio
  await db.producto.update({
    where: { id },
    data: { precio: nuevoPrecio }
  })
  
  // Revalidar en cascada
  revalidateTag(`producto-${id}`)                    // Página del producto
  revalidateTag('productos')                          // Lista de productos
  revalidateTag(`categoria-${producto.categoriaId}`)  // Página de categoría
  revalidatePath('/ofertas')                          // Si estaba en ofertas
  revalidatePath('/', 'layout')                       // Si está en homepage
}

Patrón 4: Revalidación con webhooks

Para revalidar desde servicios externos (CMS, Stripe, etc):

// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'

export async function POST(request: NextRequest) {
  // Verificar secret para seguridad
  const secret = request.nextUrl.searchParams.get('secret')
  
  if (secret !== process.env.REVALIDATE_SECRET) {
    return NextResponse.json({ error: 'Invalid secret' }, { status: 401 })
  }
  
  const body = await request.json()
  
  // Revalidar según el tipo de evento
  if (body.type === 'producto.actualizado') {
    revalidateTag(`producto-${body.productoId}`)
    revalidateTag('productos')
  }
  
  if (body.type === 'categoria.actualizada') {
    revalidateTag(`categoria-${body.categoriaId}`)
  }
  
  return NextResponse.json({ revalidated: true, now: Date.now() })
}

Desde tu CMS o servicio externo:

curl -X POST 'https://tuapp.com/api/revalidate?secret=tu-token' \
  -H 'Content-Type: application/json' \
  -d '{"type": "producto.actualizado", "productoId": "123"}'

Debugging de revalidación

Ver cuándo se revalida

export const revalidate = 300

export default async function ProductosPage() {
  const ahora = new Date().toLocaleTimeString()
  const productos = await fetch('https://api.mitienda.com/productos')
    .then(r => r.json())
  
  return (
    <div>
      <p className="text-xs text-gray-500">Última actualización: {ahora}</p>
      <h1>Productos</h1>
      {/* ... */}
    </div>
  )
}

Visita la página varias veces. El timestamp solo cambia después de revalidar.

Ver headers de cache

// app/api/productos/route.ts
import { NextResponse } from 'next/server'

export async function GET() {
  const productos = await fetch('https://api.mitienda.com/productos', {
    next: { revalidate: 600, tags: ['productos'] }
  }).then(r => r.json())
  
  return NextResponse.json(productos, {
    headers: {
      'Cache-Control': 'public, s-maxage=600, stale-while-revalidate=86400',
      'X-Cache-Tags': 'productos'
    }
  })
}

Logs de revalidación

'use server'

import { revalidateTag } from 'next/cache'

export async function actualizarProducto(id: string, datos: any) {
  console.log(`[REVALIDATE] Actualizando producto ${id}`)
  
  await db.producto.update({ where: { id }, data: datos })
  
  console.log(`[REVALIDATE] Invalidando tags: producto-${id}, productos`)
  revalidateTag(`producto-${id}`)
  revalidateTag('productos')
  
  console.log(`[REVALIDATE] Cache invalidado exitosamente`)
}

Revisa los logs del servidor para verificar que la revalidación ocurre.


Mejores prácticas

1. Sé conservador con los timers

// ❌ Muy agresivo - regenera cada 10 segundos
export const revalidate = 10

// ✅ Razonable - regenera cada 5 minutos
export const revalidate = 300

Regenerar muy seguido desperdicia recursos. Usa revalidación on-demand para cambios inmediatos.

2. Usa tags específicos

// ❌ Tag muy general
revalidateTag('datos')

// ✅ Tags específicos y descriptivos
revalidateTag('productos')
revalidateTag(`producto-${id}`)
revalidateTag('productos-categoria-ropa')

3. Documenta tus tags

// lib/cache-tags.ts
/**
 * Tags de cache para la aplicación
 * 
 * PRODUCTOS:
 * - 'productos': Lista completa de productos
 * - 'producto-{id}': Producto individual
 * - 'productos-categoria-{id}': Productos de una categoría
 * 
 * USUARIOS:
 * - 'usuarios': Lista de usuarios (admin)
 * - 'usuario-{id}': Usuario individual
 */
export const CACHE_TAGS = {
  productos: {
    all: 'productos',
    byId: (id: string) => `producto-${id}`,
    byCategoria: (catId: string) => `productos-categoria-${catId}`
  },
  usuarios: {
    all: 'usuarios',
    byId: (id: string) => `usuario-${id}`
  }
}

4. Invalida en el orden correcto

// ✅ Primero lo específico, luego lo general
revalidateTag(`producto-${id}`)  // Página individual
revalidateTag('productos')        // Lista general

// ❌ Si inviertes el orden, la lista se regenera con datos viejos

5. Combina time-based con on-demand

// Base: revalidación cada hora
export const revalidate = 3600

export default async function ProductosPage() {
  const productos = await fetch('https://api.mitienda.com/productos', {
    next: { tags: ['productos'] }
  }).then(r => r.json())
  
  return <div>{/* productos */}</div>
}

// Pero también on-demand cuando cambias algo
async function actualizarProducto(id: string) {
  await db.producto.update(/* ... */)
  revalidateTag('productos')  // Actualiza inmediatamente
}

Así tienes actualización automática cada hora + actualizaciones inmediatas cuando modificas datos.


Resumen

Dos tipos de revalidación:

  1. Time-based: Automática cada X segundos con revalidate
  2. On-demand: Manual con revalidatePath() o revalidateTag()

Cuándo usar cada una:

  • Time-based: Datos que cambian predictiblemente (feeds, noticias)
  • On-demand: Cuando TÚ modificas los datos (CRUD operations)
  • Combinadas: Lo mejor de ambos mundos

Herramientas principales:

// En la página
export const revalidate = 600

// En fetch
fetch(url, { next: { revalidate: 600, tags: ['productos'] }})

// Invalidar manualmente
revalidatePath('/productos')
revalidateTag('productos')

// Base de datos
unstable_cache(fn, keys, { revalidate, tags })

Próximo tema: Optimización avanzada y mejores prácticas de cache.