Next.js

Revalidación On-Demand en Next.js: Invalidar Caché con revalidateTag y revalidatePath

Autorangel cruz
Actualizado
Publicado
Lectura10 min de lectura
Revalidación On-Demand en Next.js: Invalidar Caché con revalidateTag y revalidatePath

Cuando trabajas con Next.js y aplicaciones que consumen datos de APIs externas, mantener el contenido actualizado se convierte en un desafío. Tienes contenido que cambia en tu backend, pero tu frontend sigue mostrando datos en caché que ya no son relevantes.

La solución tradicional sería reducir el tiempo de caché o deshabilitarlo completamente, pero eso afecta significativamente el rendimiento. La alternativa es implementar revalidación on-demand o revalidación bajo demanda, un mecanismo que permite invalidar el caché de forma programática cuando el contenido cambia.

Lo que sigue es un sistema de revalidación de caché en Next.js que se actualiza automáticamente cuando cambias contenido en tu backend, sin sacrificar el rendimiento.

¿Qué es la revalidación de caché?

La revalidación de caché es un mecanismo que permite invalidar el caché de Next.js de forma programática, en lugar de esperar a que expire el tiempo de revalidación configurado.

Next.js ofrece dos formas principales de hacer esto:

  1. revalidatePath: Invalida el caché de una ruta específica (por ejemplo, /post/mi-articulo)
  2. revalidateTag: Invalida el caché basado en etiquetas que asignas a tus peticiones fetch

La segunda opción es más flexible y potente, ya que puedes etiquetar múltiples peticiones con la misma etiqueta y invalidarlas todas de una vez.

El problema y la estrategia

Imagina este escenario:

  • Tienes un blog con artículos que se publican desde un CMS o backend
  • Los artículos se muestran en varias páginas: la página principal, listado de posts, página individual, sitemap, feed RSS
  • Quieres cachear todo agresivamente para mejorar el rendimiento (30 días, por ejemplo)
  • Pero cuando publicas un nuevo artículo, quieres que aparezca inmediatamente

Sin revalidación, tendrías que esperar 30 días o reducir el tiempo de caché a minutos, lo cual no es ideal.

¿Por qué un caché de 30 días?

Puede parecer contradictorio usar un caché tan largo cuando necesitas contenido actualizado, pero tiene sentido por varias razones.

Un caché largo reduce drásticamente la cantidad de peticiones a tu API backend, lo que protege tu servidor de sobrecarga, sobre todo en sitios con alto tráfico. Sin caché, cada visita generaría múltiples peticiones a la API.

Con un caché de 30 días, Next.js puede servir contenido desde su caché interno sin consultar la API en cada solicitud, y los tiempos de respuesta son muy rápidos.

El caché largo se combina con revalidación on-demand: cuando el contenido cambia, el webhook invalida el caché inmediatamente y Next.js obtiene datos frescos en la próxima solicitud. Así tienes rendimiento y actualización inmediata a la vez.

Menos peticiones a la API también significan menos costo de infraestructura y mejor escalabilidad. Tu backend puede manejar más tráfico sin escalar recursos.

Implementación

Paso 1: Configurar tags de caché en tus fetches

Primero, necesitas etiquetar todas tus peticiones fetch con tags que puedas referenciar después. Esto es crítico: si una función fetch no tiene tags, no se invalidará cuando el webhook revalide el caché.

Aquí tienes ejemplos de cómo etiquetar tus fetches:

// lib/api.ts
export async function getPosts(): Promise<Article[]> {
  const data = await fetchAPI<ApiPostsResponse>('/post', {
    next: {
      revalidate: 2592000, // 30 días
      tags: ['posts', 'posts-list'], // Etiquetas para revalidación
    },
  })
 
  return data.data.map(mapApiPostToArticle)
}
 
export const getPostBySlug = cache(async (slug: string): Promise<Article | null> => {
  const data = await fetchAPI<ApiPostResponse>(`/post/${slug}`, {
    next: {
      revalidate: 2592000, // 30 días
      tags: ['posts', `post-${slug}`], // Etiqueta general + específica
    },
  })
 
  return mapApiPostToArticle(data.data)
})
 
// IMPORTANTE: Todas las funciones de fetch deben estar envueltas con React.cache()
// para deduplicación por request y deben tener tags para poder ser invalidadas
 
// IMPORTANTE: No olvides agregar tags a TODAS las funciones que usan caché
export async function getAllPostSlugs(): Promise<string[]> {
  const data = await fetchAPI<ApiPostsResponse>('/post', {
    next: {
      revalidate: 2592000,
      tags: ['posts', 'posts-list'], // Sin esto, no se invalidará el caché
    },
  })
 
  return data.data.map(post => post.slug)
}
 
export const getCategories = cache(async (): Promise<Category[]> => {
  const data = await fetchAPI<ApiCategoriesResponse>('/categories', {
    next: {
      revalidate: 2592000,
      tags: ['categories'], // Tags para categorías
    },
  })
 
  return data.data.map(mapApiCategoryToCategory)
})

Un error común es olvidar agregar tags a funciones como getAllPostSlugs() o getCategories(). Si una función no tiene tags, el webhook no podrá invalidar su caché, y seguirás viendo datos antiguos durante 30 días.

Asegúrate también de que tu función fetchAPI acepte correctamente las opciones next. Si estás usando TypeScript, necesitarás extender el tipo:

// lib/api-client.ts
interface NextFetchOptions {
  next?: {
    revalidate?: number | false
    tags?: string[]
  }
}
 
type FetchAPIOptions = RequestInit & NextFetchOptions
 
export async function fetchAPI<T>(
  url: string,
  options?: FetchAPIOptions
): Promise<T> {
  // ... tu implementación
  const response = await fetch(fullUrl, {
    ...options, // Esto incluye las opciones 'next'
    headers: {
      'Content-Type': 'application/json',
      ...options?.headers,
    },
  })
  // ...
}

Paso 2: Crear el endpoint de revalidación

Ahora crea un endpoint de API que reciba las notificaciones de tu backend:

// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'
 
type ResourceType = 'post' | 'category' | 'page'
type ResourceAction = 'created' | 'updated' | 'deleted' | 'published'
 
interface ResourceMetadata {
  type: ResourceType
  id: number
  action: ResourceAction
  timestamp: number
  slug?: string
  categorySlug?: string
}
 
interface RevalidatePayload {
  token: string
  paths?: string[]
  resource?: ResourceMetadata
}
 
export async function POST(request: NextRequest) {
  try {
    const body: RevalidatePayload = await request.json()
 
    // 1. Validar el token de seguridad
    const expectedToken = process.env.REVALIDATE_TOKEN
    if (!expectedToken || body.token !== expectedToken) {
      return NextResponse.json(
        { message: 'Invalid token' },
        { status: 401 }
      )
    }
 
    // 2. Generar tags y paths a revalidar
    let tagsToRevalidate: string[] = []
    const pathsToRevalidate: string[] = []
 
    if (body.resource) {
      const { type, slug, action } = body.resource
 
      // Generar tags según el tipo de recurso
      switch (type) {
        case 'post':
          tagsToRevalidate.push('posts', 'posts-list', 'posts-paginated')
          if (slug) {
            tagsToRevalidate.push(`post-${slug}`)
          }
          break
        case 'category':
          tagsToRevalidate.push('categories') // Revalidar tags de categorías
          tagsToRevalidate.push('posts', 'posts-list', 'posts-paginated') // Categorías afectan posts
          break
      }
 
      // Generar paths según el tipo de recurso
      pathsToRevalidate.push('/') // Siempre revalidar home
 
      switch (type) {
        case 'post':
          if (slug) {
            pathsToRevalidate.push(`/post/${slug}`)
          }
          pathsToRevalidate.push('/post', '/categorias')
          if (body.resource.categorySlug) {
            pathsToRevalidate.push(`/categorias/${body.resource.categorySlug}`)
          }
          pathsToRevalidate.push('/sitemap.xml', '/feed.xml')
          break
        case 'category':
          if (slug) {
            pathsToRevalidate.push(`/categorias/${slug}`)
          }
          pathsToRevalidate.push('/categorias', '/post')
          break
      }
    }
 
    // Si no hay resource pero hay paths relacionados con posts, inferir tags
    // Esto asegura que el caché se invalide incluso si el backend no envía metadata
    if (tagsToRevalidate.length === 0) {
      const paths = body.paths || pathsToRevalidate
      const hasPostPaths = paths.some(path =>
        path === '/post' ||
        path.startsWith('/post/') ||
        path === '/' ||
        path.includes('sitemap') ||
        path.includes('feed')
      )
 
      const hasCategoryPaths = paths.some(path =>
        path === '/categorias' ||
        path.startsWith('/categorias/')
      )
 
      if (hasPostPaths) {
        tagsToRevalidate = ['posts', 'posts-list', 'posts-paginated']
        console.log('[Revalidate] No resource provided, inferring post tags from paths')
      }
 
      if (hasCategoryPaths) {
        if (!tagsToRevalidate.includes('categories')) {
          tagsToRevalidate.push('categories')
        }
        // Las categorías afectan posts
        if (!tagsToRevalidate.includes('posts')) {
          tagsToRevalidate.push('posts', 'posts-list', 'posts-paginated')
        }
      }
    }
 
    // 3. Revalidar tags (crítico para invalidar el caché de 30 días)
    const revalidatedTags: string[] = []
    const failedTags: string[] = []
 
    if (tagsToRevalidate.length === 0) {
      console.warn('[Revalidate] No cache tags to revalidate - fetch cache may not be invalidated!')
    }
 
    for (const tag of tagsToRevalidate) {
      try {
        // IMPORTANTE: Usar { expire: 0 } para invalidación inmediata
        // 'max' solo marca como stale pero no invalida inmediatamente
        // expire: 0 fuerza la expiración inmediata para que se obtengan datos frescos en la próxima solicitud
        revalidateTag(tag, { expire: 0 })
        revalidatedTags.push(tag)
        if (process.env.NODE_ENV === 'development') {
          console.log(`[Revalidate] Successfully revalidated tag: ${tag}`)
        }
      } catch (error) {
        console.error(`[Revalidate] Error revalidating tag ${tag}:`, error)
        failedTags.push(tag)
      }
    }
 
    // 4. Revalidar paths (si se proporcionaron explícitamente o se generaron)
    // Son paths literales (/post/mi-articulo, /feed.xml): van sin el segundo
    // argumento. 'page' o 'layout' solo se pasan con un patrón como /post/[slug]
    const paths = body.paths || pathsToRevalidate
    const revalidatedPaths: string[] = []
    for (const path of paths) {
      try {
        revalidatePath(path)
        revalidatedPaths.push(path)
      } catch (error) {
        console.error(`Error revalidating path ${path}:`, error)
      }
    }
 
    return NextResponse.json({
      revalidated: revalidatedPaths.length > 0 || revalidatedTags.length > 0,
      paths: revalidatedPaths,
      tags: revalidatedTags.length > 0 ? revalidatedTags : undefined,
      failedTags: failedTags.length > 0 ? failedTags : undefined,
    })
  } catch (error) {
    console.error('[Revalidate] Error:', error)
    return NextResponse.json(
      { message: 'Error revalidating', error: String(error) },
      { status: 500 }
    )
  }
}

Paso 3: Configurar el token de seguridad

Crea una variable de entorno para el token:

REVALIDATE_TOKEN=tu_token_secreto_super_seguro_aqui // .env.local

Este token debe ser el mismo que uses en tu backend para autenticar las peticiones al endpoint de revalidación.

Cómo funciona el flujo completo

Una vez que tienes configurado el endpoint de revalidación, el flujo es el siguiente:

  1. Tu backend/CMS dispara un webhook a /api/revalidate cuando el contenido cambia
  2. El endpoint de Next.js valida el token de seguridad
  3. Se procesan los datos del recurso y se generan las tags y paths a revalidar
  4. Se invalidan las tags y paths correspondientes usando revalidateTag() y revalidatePath()
  5. En la próxima solicitud, Next.js detecta que el caché fue invalidado y obtiene datos frescos de la API
  6. Los usuarios ven el contenido actualizado sin esperar a que expire el tiempo de caché

El formato del payload que espera el endpoint es:

{
  "token": "tu_token_secreto",
  "resource": {
    "type": "post",
    "id": 123,
    "action": "published",
    "timestamp": 1734567890,
    "slug": "mi-articulo",
    "categorySlug": "laravel"
  }
}

También puedes proporcionar paths explícitos si prefieres tener control total:

{
  "token": "tu_token_secreto",
  "paths": ["/", "/post", "/post/mi-articulo"],
  "resource": {
    "type": "post",
    "id": 123,
    "action": "published"
  }
}

Mejores prácticas

1. Usa tags específicos y generales

Combina tags generales con tags específicos:

tags: ['posts', 'posts-list', `post-${slug}`]

Esto te permite:

  • Invalidar todos los posts con revalidateTag('posts')
  • Invalidar solo un post específico con revalidateTag('post-mi-articulo')

2. Genera paths y tags automáticamente

En lugar de tener que especificar manualmente todos los paths en cada webhook, genera los paths automáticamente basándote en el tipo de recurso. Además, el webhook puede inferir tags desde los paths si el backend no envía metadata:

function generateRevalidationPaths(resource: ResourceMetadata): string[] {
  const paths: string[] = ['/'] // Siempre revalidar home
 
  if (resource.type === 'post' && resource.slug) {
    paths.push(`/post/${resource.slug}`)
    paths.push('/post', '/categorias')
    if (resource.categorySlug) {
      paths.push(`/categorias/${resource.categorySlug}`)
    }
    paths.push('/sitemap-posts.xml', '/feed.xml', '/sitemap.xml')
  }
 
  return paths
}
 
// Si no hay resource, inferir tags desde paths
if (tagsToRevalidate.length === 0) {
  const hasPostPaths = paths.some(path =>
    path === '/post' || path.startsWith('/post/') || path === '/'
  )
 
  if (hasPostPaths) {
    tagsToRevalidate = ['posts', 'posts-list', 'posts-paginated']
  }
}

Esto asegura que el caché se invalide incluso si el backend no envía el campo resource en el webhook.

3. Maneja los errores

El webhook puede fallar por varias razones (red, timeout, etc.). En tu endpoint, asegúrate de manejar errores y devolver respuestas claras:

try {
  // ... revalidación ...
} catch (error) {
  console.error('[Revalidate] Error:', error)
  return NextResponse.json(
    {
      message: 'Error revalidating',
      error: error instanceof Error ? error.message : String(error)
    },
    { status: 500 }
  )
}

También es buena práctica que tu backend tenga un sistema de reintentos en caso de que el webhook falle.

4. Logging para depurar

Agrega logging estructurado para poder debuggear problemas. Usa after() de Next.js para hacer el logging de forma asíncrona y no bloquear la respuesta:

import { after } from 'next/server'
 
// ... después de revalidar ...
 
after(async () => {
  const logData = {
    paths: revalidatedPaths,
    tags: revalidatedTags.length > 0 ? revalidatedTags : undefined,
    failed: failedPaths.length > 0 ? failedPaths : undefined,
    failedTags: failedTags.length > 0 ? failedTags : undefined,
    resource: resource?.type || undefined,
    action: resource?.action || undefined,
  }
 
  const logMessage = { event: 'cache_revalidated', ...logData }
  if (process.env.NODE_ENV === 'production') {
    console.log(JSON.stringify(logMessage))
  } else {
    console.log('[Revalidate] Cache revalidated:', JSON.stringify(logMessage, null, 2))
  }
})

Esto te ayudará a identificar si las tags se están revalidando correctamente y si hay algún problema con el webhook.

Invalidación de Vercel Edge Cache

Vercel Edge Cache es la capa de caché CDN de Vercel que se encuentra delante de tu aplicación Next.js. Es importante entender su relación con el caché de Next.js:

  • Caché de Next.js: Es el caché interno de Next.js que se invalida con revalidateTag() y revalidatePath(). Este es el caché principal que controla qué datos se obtienen de tu API.

  • Vercel Edge Cache: Es el caché del CDN de Vercel que almacena respuestas completas de páginas. Este caché está delante de Next.js y puede servir contenido sin llegar a tu aplicación.

¿Es necesario invalidar Vercel Edge Cache?

Si tu app Next.js corre en Vercel, no hace falta para lo que invalidas con next/cache. Según la documentación de purga de caché de Vercel, purgar por tag limpia a la vez el caché del CDN, el Runtime Cache y el Data Cache, y en Next.js 16 revalidateTag() y revalidatePath() ya hacen esa purga: revalidateTag(tag, 'max') marca el contenido como stale y revalidateTag(tag, { expire: 0 }) o revalidatePath(path) lo borran.

La llamada a la API de Vercel sirve en otros casos: cuando la invalidación sale de un sistema que no corre dentro de tu app Next.js, o para respuestas que se cachearon en el CDN con tags propios (por ejemplo, con la cabecera Vercel-Cache-Tag). Ten en cuenta que el endpoint invalidate-by-tags marca las entradas como stale: la siguiente solicitud recibe la copia vieja mientras se regenera en segundo plano.

Implementación

// Invalidar por tag en el CDN de Vercel desde la API REST
// Útil para tags propios del CDN o para invalidar desde fuera de la app
if (revalidatedTags.length > 0 && process.env.VERCEL_TOKEN && process.env.VERCEL_PROJECT_ID) {
  try {
    const projectId = process.env.VERCEL_PROJECT_ID
    const teamId = process.env.VERCEL_TEAM_ID
    const vercelApiUrl = teamId
      ? `https://api.vercel.com/v1/edge-cache/invalidate-by-tags?projectIdOrName=${projectId}&teamId=${teamId}`
      : `https://api.vercel.com/v1/edge-cache/invalidate-by-tags?projectIdOrName=${projectId}`
 
    const response = await fetch(vercelApiUrl, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${process.env.VERCEL_TOKEN}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        tags: revalidatedTags,
        target: 'production',
      }),
    })
 
    if (!response.ok) {
      const errorText = await response.text()
      console.error('[Revalidate] Failed to invalidate Vercel Edge Cache:', errorText)
    }
  } catch (error) {
    // No fallar toda la revalidación si falla la invalidación del edge cache
    console.error('[Revalidate] Error invalidating Vercel Edge Cache:', error)
  }
}

Variables de entorno necesarias:

  • VERCEL_TOKEN: Token de API de Vercel
  • VERCEL_PROJECT_ID: ID del proyecto en Vercel
  • VERCEL_TEAM_ID: (Opcional) ID del equipo si el proyecto pertenece a un equipo

Problemas comunes y soluciones

"La revalidación no funciona - el caché de 30 días no se invalida"

Causa 1: Las tags no coinciden exactamente.

Solución: Asegúrate de que las tags que usas en revalidateTag() sean exactamente las mismas que usas en tus fetches. Un espacio extra o una diferencia de mayúsculas hará que no funcione.

Causa 2: Alguna función fetch no tiene tags de caché.

Solución: Revisa que todas tus funciones que usan revalidate: 2592000 tengan tags. Si una función como getAllPostSlugs() o getCategories() no tiene tags, el webhook no podrá invalidar su caché.

Causa 3: El backend no envía el campo resource en el webhook.

Solución: El webhook ahora infiere tags automáticamente desde los paths que se están revalidando. Si revalidas /post o paths relacionados, automáticamente revalidará las tags ['posts', 'posts-list', 'posts-paginated']. Sin embargo, es mejor que el backend siempre envíe el campo resource con el slug para revalidar tags específicas como post-${slug}.

Causa 4: Estás usando revalidateTag(tag, 'max') en lugar de revalidateTag(tag, { expire: 0 }).

Solución: 'max' solo marca el caché como stale pero no lo invalida inmediatamente. Usa { expire: 0 } para forzar la invalidación inmediata del caché.

Causa 5: La página está cacheada en el CDN con tags que Next.js no conoce.

Solución: En Vercel, revalidateTag y revalidatePath ya purgan el caché del CDN de lo que Next.js cacheó. Si además cacheas respuestas en el CDN con tags propios (cabecera Vercel-Cache-Tag), esas se invalidan con la API de Vercel, usando VERCEL_TOKEN y VERCEL_PROJECT_ID.

"Los componentes del cliente no se actualizan"

Causa: Los componentes del cliente hacen fetch directamente desde el navegador, no desde el servidor.

Solución:

  • Los Server Components se actualizarán automáticamente
  • Para Client Components, puedes implementar polling o usar router.refresh() después de detectar cambios

"El webhook tarda mucho en responder"

Causa: Estás haciendo operaciones síncronas pesadas en el endpoint.

Solución: Usa after() de Next.js para hacer el logging de forma asíncrona:

import { after } from 'next/server'
 
// ... revalidación ...
 
after(async () => {
  // Logging asíncrono que no bloquea la respuesta
  console.log('Revalidation completed')
})

Conclusión

La revalidación de caché on-demand permite combinar un caché agresivo para rendimiento con actualizaciones inmediatas cuando el contenido cambia.

Resumen de la estrategia

Un caché de 30 días puede parecer excesivo, pero combinado con revalidación on-demand funciona bien:

  • Protege tu API: Reduce drásticamente las peticiones al backend, mejorando la escalabilidad y reduciendo costos.
  • Rendimiento óptimo: Next.js puede servir contenido desde caché sin consultar la API en cada solicitud.
  • Actualización inmediata: El webhook invalida el caché cuando el contenido cambia, forzando a Next.js a obtener datos frescos en la próxima solicitud.

Checklist de implementación

  1. Etiqueta todas tus fetches con tags para poder referenciarlos después (no olvides ninguna función)
  2. Envuelve tus funciones de fetch con React.cache() para deduplicación por request
  3. Crea un endpoint /api/revalidate que reciba webhooks y valide el token
  4. Genera automáticamente las tags y paths a revalidar basándote en el tipo de recurso
  5. Incluye lógica de inferencia para revalidar tags incluso si el backend no envía metadata completa
  6. Invalida el caché usando revalidateTag(tag, { expire: 0 }) para invalidación inmediata (no uses 'max')
  7. Revalida los paths literales con revalidatePath(path); el segundo argumento ('page' o 'layout') solo va con patrones como /post/[slug]
  8. Usa la API de Vercel solo para lo que cacheaste en el CDN con tags propios: lo de next/cache ya se purga en Vercel
  9. Agrega logging estructurado para facilitar el debugging

Puntos clave

  • Si una función fetch no tiene tags, el webhook no podrá invalidar su caché. Asegúrate de revisar todas tus funciones que usan revalidate: 2592000 y agregarles tags correspondientes.
  • Usa { expire: 0 } en revalidateTag, no 'max', para invalidación inmediata del caché.
  • Con un path literal, revalidatePath(path) va sin 'page' ni 'layout'. Ojo con revalidatePath('/', 'layout'): invalida todo el sitio.
  • En Vercel, revalidateTag y revalidatePath ya purgan el CDN; la API de Vercel queda para tags propios del CDN.

Todo lo de arriba resuelve la invalidación dentro de Next.js. Cuando el que manda los cambios es un backend aparte, hace falta además que ese backend sepa avisar: el caso de Laravel disparando la revalidación por eventos lo desarrollo en sincronización de caché en arquitecturas híbridas.

Y si lo que quieres cachear es del lado del navegador, las estrategias de Service Worker (cache-first, network-first, stale-while-revalidate) van en esta guía práctica.

El otro problema de Next.js que se paga en Search Console y no en la app es el noindex que se queda pegado en las páginas estáticas: lo cuento en noindex, Next.js y Google.

Recursos adicionales

¿Tienes un proyecto en mente?

Trabajo con Laravel, WordPress, SEO técnico y servidores MCP. El primer paso es una llamada de descubrimiento, sin costo ni compromiso, donde me cuentas qué necesitas y te digo con honestidad si puedo ayudarte.

Hablemos