Blog
7 de agosto de 20267 min

Payload CMS + Next.js 15: de página lenta a ISR con revalidación bajo demanda

Mi página de proyectos tardaba segundos en mostrar algo: el navegador pedía los datos después de cargar. La arreglé moviendo el fetch al servidor con ISR — sin perder el cambio de idioma instantáneo, y con revalidación inmediata al publicar.

Next.jsPayload CMSISRRendimientoSEO
Payload CMS + Next.js 15: de página lenta a ISR con revalidación bajo demanda

Mi página de proyectos tardaba en aparecer. No milisegundos: segundos de esqueleto vacío antes de ver un solo proyecto. El sitio es Next.js 15 con Payload CMS, y el problema no era el CMS — era dónde estaba pidiendo los datos.

El diagnóstico

Admin de Payload con vista previa en vivo
El contenido vive en el admin de Payload. El problema nunca fue el CMS — fue dónde le pedía los datos.

La página era un componente cliente que pedía los proyectos en un useEffect. Eso encadena todo en serie:

  1. El navegador descarga el HTML (vacío).
  2. Descarga y ejecuta el bundle de JS.
  3. React hidrata y corre el efecto.
  4. Ahora sale la petición al CMS.
  5. Llega la respuesta, render.

Cinco pasos secuenciales antes del primer proyecto. Y el sitio es bilingüe, así que al cambiar de idioma se repetía la petición: otra espera.

Peor todavía para lo que no se ve: Google recibía el HTML vacío. Todo el contenido llegaba por JavaScript, después.

El arreglo, en dos partes

1. El fetch se sube al servidor

En el App Router, la página es un Server Component. Pide los datos en el servidor y se los pasa al cliente ya renderizados:

export const revalidate = 60

export default async function ProjectsPage() {
  const [projectsEn, projectsEs] = await Promise.all([getProjects('en'), getProjects('es')])
  return <ProjectsContent projectsEn={projectsEn} projectsEs={projectsEs} />
}

Tres detalles que valen más de lo que parecen:

export const revalidate = 60 convierte la página en ISR: Next la genera una vez, la sirve estática a todo el mundo, y la regenera en segundo plano si pasó más de un minuto desde la última. El visitante nunca espera al CMS — recibe HTML ya hecho.

Los dos idiomas se piden juntos, en paralelo con Promise.all. El componente cliente recibe ambos y sólo elige cuál mostrar:

const { language } = useLanguage()
const projects = language === 'es' ? projectsEs : projectsEn

Cambiar de idioma pasó de "otra petición y otra espera" a cero red. Es un if.

Costo real: duplicar los datos en el HTML. Para un portafolio con trece proyectos son unos pocos KB — barato a cambio de que el cambio de idioma sea instantáneo. Si tuvieras cientos de registros, la decisión sería otra.

2. La revalidación bajo demanda

Dashboard de colecciones en Payload
Publicas desde aquí; la ruta se refresca en un segundo en vez de esperar el minuto del ISR.

ISR con revalidate = 60 tiene una consecuencia: publicas algo y puede tardar un minuto en verse. Para eso está la revalidación bajo demanda — un endpoint que le dice a Next "esta ruta cambió, tírala":

// src/app/api/revalidate/route.ts
export async function POST(req: Request) {
  const { secret, paths } = await req.json()
  if (secret !== process.env.REVALIDATE_SECRET) {
    return Response.json({ error: 'unauthorized' }, { status: 401 })
  }
  paths.forEach((p: string) => revalidatePath(p))
  return Response.json({ revalidated: paths })
}

El secreto no es opcional: sin él, cualquiera puede tirar tu caché en bucle y convertir tu sitio estático en uno dinámico y caro.

Como el contenido lo edito por un servidor MCP, cada escritura llama a este endpoint con las rutas afectadas. Publico un post y en un segundo está en /blog, en la home y en el sitemap. Lo mejor de los dos mundos: estático para quien visita, inmediato para quien edita.

La trampa de caché que sí muerde

Un detalle de Next 15 que cuesta caro entender tarde: fetch tiene su propia caché, independiente de la de la página. Puedes revalidar la ruta y seguir viendo datos viejos si el fetch de adentro los tenía cacheados.

Por eso mi helper es explícito en las dos direcciones:

async function fetchPayload(path: string, fresh = false) {
  const res = await fetch(`${BASE}${path}`,
    fresh ? { cache: 'no-store' } : { next: { revalidate: 60 } })
  if (!res.ok) return { docs: [] }
  return res.json()
}

fresh lo usa la vista previa de borradores, donde cachear sería absurdo: quiero ver lo que el CMS tiene ahora. Todo lo demás cachea 60 segundos.

Y ese if (!res.ok) return { docs: [] } es deliberado: si el CMS se cae, la página se renderiza vacía en vez de tronar con un 500. Un portafolio con una sección vacía es mucho mejor que un portafolio caído.

Lo que ganó Google

El beneficio que no esperaba fue de SEO. Con los datos en el servidor, el HTML llega completo: títulos de proyectos, descripciones, tecnologías. Ya no hay que esperar a que un rastreador ejecute JavaScript para saber de qué trata la página.

Ya que estaba ahí, encontré un problema mucho peor. El layout raíz declaraba esto:

alternates: { canonical: '/' }

En el App Router los metadatos se heredan. Esa línea significaba que cada proyecto y cada post le decía a Google "la página real es el home" — el rel=canonical es precisamente la señal para decir "no me indexes a mí, indexa a aquel". Todo el sitio se estaba declarando duplicado de su propia portada.

La regla: nunca pongas un canonical en un layout. Va en cada página, y si es dinámica, en su generateMetadata:

export async function generateMetadata({ params }): Promise<Metadata> {
  const { slug } = await params
  const post = await getPostBySlug(slug, 'es')
  if (!post) return {}
  return {
    title: `${post.title} | Aaron Hernández`,
    description: post.excerpt,
    alternates: { canonical: `https://aaronhernandez.me/blog/${slug}` },
  }
}

De paso: sitemap.ts y robots.ts en la raíz de app/ son rutas, no archivos estáticos. El sitemap se puede generar leyendo el CMS, así un post nuevo entra solo sin que nadie se acuerde de actualizarlo.

Resumen

  • El fetch al servidor, con revalidate. El visitante recibe HTML, no un esqueleto.
  • Los dos idiomas en el mismo render: cambiar de idioma no debería tocar la red.
  • Revalidación bajo demanda con secreto, disparada desde el CMS al publicar.
  • Ojo con la caché de fetch: es distinta de la de la página.
  • Y revisa que ningún layout esté declarando un canonical por todos.

Ninguno de los cinco es difícil. Los cinco juntos son la diferencia entre una página que tarda segundos y una que ya está ahí.

¿Te sirvió, o quieres discutirlo?

Escríbeme — me interesa cómo lo estás resolviendo tú.

Hablemos
Payload CMS + Next.js 15: de página lenta a ISR con revalidación bajo demanda | Aaron Hernández