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:
- Primera visita (0:00): NextJS obtiene datos, genera HTML, cachea
- Visitas 0:00 - 9:59: Todos ven el HTML cacheado (instantáneo)
- Visita a las 10:00: El visitante ve HTML viejo (rápido), PERO NextJS regenera en background
- 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:
- Build time: NextJS genera HTML para los 100 posts populares
- Usuario visita post #150: NextJS genera el HTML on-demand, lo cachea
- Otros usuarios visitan post #150: Ven el HTML cacheado (rápido)
- Después de 1 hora: NextJS regenera ese post automáticamente
- 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:
- Time-based: Automática cada X segundos con
revalidate
- On-demand: Manual con
revalidatePath()
orevalidateTag()
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.