Fredy Acuna
  • Publicaciones
  • Proyectos
  • Herramientas
  • Contacto
LinkedInXGitHubMedium

© 2026 Fredy Acuna

Volver a publicaciones
Por Qué Tu Sitemap de Next.js Está Vacío (y la Solución Nativa)

Por Qué Tu Sitemap de Next.js Está Vacío (y la Solución Nativa)

Fredy Acuna / October 30, 2026 / 7 min read

El Síntoma: Un Sitemap Sin URLs

Abrí https://www.fredhii.com/sitemap.xml esperando unas cuantas decenas de URLs y encontré esto:

<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
</sitemapindex>

Un <sitemapindex> vacío. Ni páginas, ni publicaciones, ni proyectos. El build pasó, el despliegue pasó y next-sitemap se ejecutó en postbuild sin una sola advertencia. Los buscadores estaban recibiendo un sitemap que no les decía nada.

En este artículo te explico por qué ocurrió, cómo confirmarlo en tu propio proyecto y la solución que publiqué: quitar next-sitemap y usar los archivos de sitemap y robots que Next.js ya soporta de forma nativa.


Lo Que Vas a Aprender

  • Por qué next-sitemap puede generar un sitemap vacío sin avisar en un sitio dinámico
  • Cómo leer .next/prerender-manifest.json para confirmar la causa
  • Cómo escribir un app/sitemap.ts nativo con alternativas hreflang
  • Por qué importa el host canónico (con o sin www) en el sitemap
  • Un detalle de Vercel con las variables NEXT_PUBLIC_*

Por Qué Todas las Páginas Eran Dinámicas

Mi sitio es bilingüe. El idioma de la interfaz sale de un helper getLocale() que lee datos de la petición:

export async function getLocale(): Promise<Locale> {
  const h = await headers()
  // ...
  const cookieStore = await cookies()
  const cookieVal = cookieStore.get(LOCALE_COOKIE)?.value
  if (cookieVal === 'es' || cookieVal === 'en') return cookieVal

  const accept = h.get('accept-language') ?? ''
  return accept.toLowerCase().startsWith('es') ? 'es' : 'en'
}

El layout raíz lo llama para definir <html lang>:

const locale = await getLocale()
// ...
<html lang={locale} suppressHydrationWarning>

Llamar a cookies() o headers() hace que una ruta se renderice de forma dinámica. Como esto ocurre en el layout raíz, todas las rutas del sitio se renderizan en cada petición. En la salida del build, cada página aparece con el marcador ƒ (dinámica) en lugar de ○ (estática).

Es una decisión válida para un sitio que personaliza según una cookie. El problema es lo que provoca en las herramientas que dependen de los artefactos del build.


Cómo Encuentra next-sitemap Tus URLs

next-sitemap no rastrea tu aplicación. Lee los archivos que Next.js escribe en .next/ después del build. En su código fuente (url-set-builder.js, función createUrlSet), el conjunto de URLs sale de dos lugares:

  1. Las páginas listadas en el build manifest
  2. Las rutas listadas en el prerender manifest

En mi build con Next.js 16, esto era lo que contenían esos archivos:

  • .next/prerender-manifest.json solo listaba /_global-error y /favicon.ico
  • Las pages del build manifest solo listaban /_app

Son entradas internas, no páginas reales. No se prerenderizó nada porque nada podía prerenderizarse, así que no quedaba nada que poner en el sitemap. Sin error, solo un índice vacío.

Puedes revisarlo en tu proyecto con un comando después de next build:

cat .next/prerender-manifest.json | jq '.routes | keys'

Si tus páginas reales no están en esa lista, next-sitemap tampoco puede verlas.


La Solución: Un app/sitemap.ts Nativo

Next.js soporta desde hace tiempo un archivo sitemap.ts dentro del directorio app. Es una ruta que ejecuta tu código, así que no le importa si las páginas son estáticas o dinámicas. Construyes la lista de URLs a partir de lo que realmente conoces: tu tabla de rutas y tu contenido.

Yo ya tenía un mapa de rutas bilingües en lib/locale-routes.ts:

export const PAGE_PAIRS: Record<string, LocalePair> = {
  '/': { es: '/', en: '/' },
  '/posts': { es: '/publicaciones', en: '/posts' },
  '/publicaciones': { es: '/publicaciones', en: '/posts' },
  '/projects': { es: '/proyectos', en: '/projects' },
  '/proyectos': { es: '/proyectos', en: '/projects' },
  // contact, privacy, tools...
}

El nuevo app/sitemap.ts combina ese mapa con las publicaciones y proyectos leídos desde MDX:

import type { MetadataRoute } from 'next'
import { siteConfig } from '@/lib/config'
import { PAGE_PAIRS } from '@/lib/locale-routes'
import { getPosts } from '@/lib/posts'
import { getProjects } from '@/lib/projects'

// Built from routes + content rather than Next's prerender manifest: pages
// read the locale cookie, so they render dynamically and never appear there.

type Entry = MetadataRoute.Sitemap[number]

const url = (path: string) => `${siteConfig.siteUrl}${path === '/' ? '' : path}`

function languages(es: string, en: string) {
  return { es: url(es), en: url(en), 'x-default': url(en) }
}

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const staticPaths = [...new Set(Object.keys(PAGE_PAIRS))]
  const staticEntries: Entry[] = staticPaths.map(path => {
    const { es, en } = PAGE_PAIRS[path]
    return {
      url: url(path),
      ...(es !== en ? { alternates: { languages: languages(es, en) } } : {})
    }
  })

  const [posts, projects] = await Promise.all([getPosts(), getProjects()])

  return [
    ...staticEntries,
    ...contentEntries(posts, { es: 'publicaciones', en: 'posts' }),
    ...contentEntries(projects, { es: 'proyectos', en: 'projects' })
  ]
}

contentEntries convierte cada publicación o proyecto en una entrada y, cuando el elemento tiene traducción (enlazada con el campo translationOf del frontmatter), agrega las alternativas hreflang:

function contentEntries(items: Content[], segment: { es: string; en: string }): Entry[] {
  const bySlug = new Map(items.map(item => [item.slug, item]))
  const pathOf = (item: Content) => `/${segment[item.locale]}/${item.slug}`

  return items.map(item => {
    const pair =
      bySlug.get(item.translationOf ?? '') ??
      items.find(other => other.translationOf === item.slug)
    const entry: Entry = { url: url(pathOf(item)), lastModified: new Date(item.publishedAt) }
    if (pair && pair.locale !== item.locale) {
      const [es, en] = item.locale === 'es' ? [item, pair] : [pair, item]
      entry.alternates = { languages: languages(pathOf(es), pathOf(en)) }
    }
    return entry
  })
}

Un beneficio adicional: el antiguo next-sitemap.config.js tenía que duplicar los pares de rutas porque se ejecutaba como script CommonJS fuera de la aplicación. El archivo nativo importa PAGE_PAIRS directamente, así que hay una sola fuente de verdad.

El archivo de robots es igual de corto:

import type { MetadataRoute } from 'next'
import { siteConfig } from '@/lib/config'

export default function robots(): MetadataRoute.Robots {
  return {
    rules: { userAgent: '*', allow: '/' },
    sitemap: `${siteConfig.siteUrl}/sitemap.xml`,
    host: siteConfig.siteUrl
  }
}

Después quité next-sitemap de las dependencias y eliminé el script postbuild y el archivo de configuración. Menos código y un paso de build menos.


El Segundo Problema: El Host Equivocado

Con las URLs por fin en el sitemap, noté que todas apuntaban a https://fredhii.com. Pero ese dominio responde con una redirección 308 a https://www.fredhii.com.

Un sitemap lleno de URLs que redirigen no es lo ideal: quieres que el sitemap, el robots y las etiquetas canonical apunten a la URL final que responde 200. Así que cambié la URL por defecto del sitio en lib/config.ts:

const DEFAULT_SITE_URL = 'https://www.fredhii.com'

El Detalle de Vercel

El primer despliegue después del cambio seguía mostrando el host sin www. La razón: la URL del sitio se puede sobrescribir con una variable de entorno NEXT_PUBLIC_SITE_URL en Vercel, y esa variable todavía tenía el valor anterior.

Dos cosas que debes recordar sobre las variables NEXT_PUBLIC_*:

  • Tienen prioridad sobre el valor por defecto del código (si tu configuración las lee primero, como la mía)
  • Se incrustan en tiempo de build, así que cambiar la variable en el panel no hace nada hasta que vuelves a desplegar

Después de actualizar la variable y volver a desplegar, el sitemap pasó a usar www.


El Resultado

El sitemap ahora lista 37 URLs, todas en https://www.fredhii.com, y cada una responde 200. Las páginas y publicaciones bilingües incluyen sus alternativas es, en y x-default.

Puedes verificar el tuyo rápidamente:

curl -s https://www.fredhii.com/sitemap.xml | grep -c '<loc>'

Puntos Clave

  • Si algo en tu layout raíz lee cookies() o headers(), todas las rutas son dinámicas y las herramientas basadas en los manifiestos del build no ven nada
  • next-sitemap construye su lista de URLs a partir del build manifest y el prerender manifest; un manifiesto vacío produce un sitemap vacío, sin error
  • Un app/sitemap.ts nativo ejecuta código, así que puedes construir la lista directamente desde tus rutas y tu contenido
  • Haz que el sitemap, el robots y los canonical apunten al host que responde 200, no al que redirige
  • Los valores NEXT_PUBLIC_* se fijan en tiempo de build: cámbialos y luego vuelve a desplegar

Suscribite al newsletter

Te aviso cuando publico algo nuevo.

Cuidamos tus datos. Leé nuestra política de privacidad.