
Fredy Acuna / October 30, 2026 / 7 min read
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.
next-sitemap puede generar un sitemap vacío sin avisar en un sitio dinámico.next/prerender-manifest.json para confirmar la causaapp/sitemap.ts nativo con alternativas hreflangNEXT_PUBLIC_*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.
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:
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.icopages del build manifest solo listaban /_appSon 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.
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.
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 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_*:
Después de actualizar la variable y volver a desplegar, el sitemap pasó a usar www.
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>'
cookies() o headers(), todas las rutas son dinámicas y las herramientas basadas en los manifiestos del build no ven nadanext-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 errorapp/sitemap.ts nativo ejecuta código, así que puedes construir la lista directamente desde tus rutas y tu contenidoNEXT_PUBLIC_* se fijan en tiempo de build: cámbialos y luego vuelve a desplegar