Sitemap dinámico en Next.js: adiós a los XML estáticos

30-04-2026

¿Alguna vez has tenido que editar manualmente un archivo sitemap.xml? ¿Quizás cada vez que creas una nueva publicación en tu blog?, este artículo es para ti. Vamos a crear un sitemap que se actualiza solo, sin tocar ni una línea de XML en Next.js 15.

El contexto: archivos Markdown vs base de datos

Antes de meternos en código, déjame explicarte el escenario en el que vamos a trabajar, porque es importante que entiendas desde dónde partimos.

En este artículo, vamos a implementar el sitemap para un blog cuyos artículos viven como archivos .md (Markdown) en una carpeta de tu proyecto. Algo así como public/content/articles/mi-articulo.md. Cada archivo tiene su frontmatter con metadata (título, fecha, autores, etc.) y el contenido del artículo en Markdown.

Esta arquitectura basada en archivos es súper común en blogs técnicos y tiene muchas ventajas: tus artículos están versionados en Git, puedes editarlos con cualquier editor de texto y no necesitas una base de datos corriendo en producción. Es simple, portable y muy eficiente para contenido que no cambia constantemente.

El código que vamos a ver usa un patrón Repository para leer estos archivos del filesystem. Tenemos un FileSystemReader que accede a la carpeta de artículos y un MarkdownArticlesRepository que convierte esos archivos en objetos que podemos manejar fácilmente en nuestro código. Esto nos da una capa de abstracción bonita que mantiene el código organizado.

Ahora bien, ¿qué pasa si tu blog usa una base de datos como PostgreSQL, MongoDB o cualquier otra? La buena noticia es que los conceptos son exactamente los mismos. Solo necesitarías cambiar la implementación del repositorio. En lugar de leer archivos del filesystem, harías queries a tu base de datos. Pero el sitemap se genera igual y la estructura general permanece idéntica. El patrón Repository existe precisamente para esto: puedes cambiar de dónde vienen los datos sin tocar el resto del código.

Así que aunque el ejemplo usa archivos Markdown, si tienes una base de datos, simplemente imagina que donde dice articlesRepository.getAll(), en realidad está haciendo un SELECT * FROM articles o lo que sea que uses. El resto es copy-paste.

El problema: el sitemap que siempre está desactualizado

Imagina esto: acabas de publicar un artículo brillante en tu blog. Lo subes a producción, funciona perfecto, pero hay un problema, Google no lo encuentra. ¿Por qué? Porque tu sitemap.xml estático, ese que vive en la carpeta public, no sabe nada sobre tu nuevo artículo.

El sitemap tradicional es como una lista de compras que escribiste hace meses. Mientras tanto, has ido al supermercado cien veces, pero tu lista sigue igual. Los crawlers de Google llegan a tu sitio, leen el sitemap, y solo encuentran las URLs que escribiste a mano hace semanas o meses. Todo lo demás queda en el limbo del “ya lo encontraremos algún día”.

Sin una estrategia clara, terminas con dos opciones igual de malas: o actualizas manualmente el sitemap cada vez que publicas algo (spoiler: te vas a olvidar) o usas algún script externo que genera el XML y lo metes en tu pipeline de deploy (mejor, pero sigue siendo un paso manual que puede fallar).

La buena noticia es que Next.js 13 y versiones superiores tienen una solución elegante: sitemaps dinámicos que se generan automáticamente en build time. La mala noticia es que la documentación oficial puede ser un poco… digamos “minimalista” en los detalles prácticos.

La solución: el sitemap que se construye solo

Vamos a crear un sitemap que lea automáticamente todos tus artículos del blog. Cada vez que construyes tu aplicación, este sitemap se regenera con las URLs actualizadas de todo tu contenido. Es como tener un asistente personal que mantiene actualizada la lista de todas tus páginas sin que tú tengas que mover un dedo.

Empezando desde cero

Next.js hace que crear un sitemap sea sorprendentemente simple. Solo necesitas crear un archivo src/app/sitemap.ts y exportar una función por defecto que retorne un array de URLs. Algo así:

 

import type { MetadataRoute } from "next" const BASE_URL = "https://tudominio.com" export default async function sitemap(): Promise<MetadataRoute.Sitemap> { return [ { url: BASE_URL, lastModified: new Date(), priority: 1.0 } ] }

Este código funciona, pero seamos honestos, es un poco aburrido. Solo tienes una URL hardcodeada ahí. Necesitamos algo más inteligente, algo que lea tu contenido real y genere las URLs automáticamente.

Haciendo el sitemap verdaderamente dinámico

Aquí es donde la magia empieza a suceder. Vamos a leer todos los artículos de tu blog desde el repositorio y generar una entrada en el sitemap para cada uno. Mira esto:

 

import type { MetadataRoute } from "next" import type { Article } from "@/backend/articles/domain" import { FileSystemReader } from "@/backend/articles/infrastructure/file-system-reader" import { MarkdownArticlesRepository } from "@/backend/articles/infrastructure/markdown-articles-repository" const BASE_URL = "https://tudominio.com" export default async function sitemap(): Promise<MetadataRoute.Sitemap> { const articlesRepository = createArticlesRepository() const staticRoutes = buildStaticRoutes() const blogRoutes = await buildBlogRoutes(articlesRepository) return [...staticRoutes, ...blogRoutes] } function createArticlesRepository() { const fileSystemReader = new FileSystemReader() return new MarkdownArticlesRepository(fileSystemReader) } function buildStaticRoutes(): MetadataRoute.Sitemap { return [ { url: BASE_URL, lastModified: new Date(), priority: 1.0 } ] } async function buildBlogRoutes( articlesRepository: MarkdownArticlesRepository ): Promise<MetadataRoute.Sitemap> { const routes: MetadataRoute.Sitemap = [] const articles = await articlesRepository.getAll() for (const article of articles) { const selfUrl = `${BASE_URL}/blog/${article.slug}` const canonicalUrl = article.canonical_url ?? selfUrl // Solo incluir si la URL canónica es interna if (isInternalUrl(canonicalUrl)) { routes.push({ url: canonicalUrl, lastModified: new Date(article.date), priority: 0.7 }) } } return routes } function isInternalUrl(url: string): boolean { try { const hostname = new URL(url).hostname.replace(/^www\./, "") return hostname === "tudominio.com" } catch { return false } }

Déjame explicarte qué está pasando aquí, porque tiene varios detalles importantes que no son obvios a primera vista.

La función createArticlesRepository() es tu puerta de entrada al contenido. Usa un FileSystemReader para acceder a tus archivos Markdown y un MarkdownArticlesRepository para convertirlos en objetos que puedes manejar fácilmente. Esto sigue el patrón Repository que mantiene tu código limpio y desacoplado del file system.

Nota sobre FileSystemReader: Esta es una clase helper que creamos para encapsular todas las operaciones de lectura, escritura, etc. del sistema de archivos. ¿Por qué no usar fs directamente? Porque, al abstraerlo en una clase, nuestro código se vuelve más testeable (podemos hacer mocks fácilmente en los tests) y más mantenible (si cambiamos de filesystem a S3 o a una base de datos, solo cambiamos esta clase). Es una práctica común en arquitectura limpia: aislar las dependencias externas detrás de interfaces propias.

La función buildStaticRoutes() genera las entradas del sitemap para tu página principal. Nota que usa priority: 1.0, que es el valor más alto. Esto le dice a Google, “oye, esta es la página más importante de mi sitio, visítala primero y con frecuencia”. Es tu home, realmente es la más importante.

Luego viene la parte interesante en buildBlogRoutes(). Aquí obtenemos todos los artículos desde el repositorio. Para cada artículo, construimos su URL completa y la añadimos al sitemap. Pero hay un detalle crucial que no puedes ignorar.

El detalle de las URLs canónicas (esto es importante)

Fíjate en esta línea:

 

const canonicalUrl = article.canonical_url ?? selfUrl

¿Por qué hacemos esto? Bueno, imagina que escribiste un artículo increíble que primero publicaste en Medium y luego decidiste republicarlo en tu blog. Si simplemente pones ambas URLs en los sitemaps respectivos, Google se encuentra con contenido duplicado y tiene que decidir cuál es el “original”. A veces adivina bien, a veces no.

La solución profesional es usar URLs canónicas. En el frontmatter de tu artículo, puedes especificar un campo canonical_url que apunte al sitio donde se publicó originalmente. Entonces, en el sitemap, verificamos si esa URL canónica apunta a tu dominio o a otro sitio.

La función isInternalUrl() hace exactamente eso. Toma la URL canónica, extrae el hostname y compara si es tu dominio. Si la URL canónica apunta a Medium, por ejemplo, el artículo NO se incluye en tu sitemap porque le estás diciendo a Google “el contenido original vive en otro lugar, ve allá”. Esto evita penalizaciones por contenido duplicado y respeta la autoría original.

Es un detalle pequeño, pero que marca la diferencia entre un SEO amateur y uno profesional. Google aprecia este tipo de honestidad y tu ranking te lo agradecerá.

Normalizando URLs: el diablo está en los detalles

Ahora vamos a hablar de algo que parece súper simple, pero que puede arruinar todo tu SEO si no lo haces bien: la normalización de URLs. Fíjate en cómo aparece en el código:

 

function normalizeUrl(urlString: string): string { try { const parsed = new URL(urlString) const hostname = parsed.hostname.replace(/^www\./, "") parsed.protocol = "https:" parsed.hostname = hostname const normalized = parsed.toString() return stripTrailingSlash(normalized) } catch { return stripTrailingSlash(urlString) } } function stripTrailingSlash(urlString: string): string { return urlString.endsWith("/") && urlString !== "/" ? urlString.slice(0, -1) : urlString }

Déjame explicarte por qué cada parte de esta normalización es crucial.

La eliminación de www: Mira esa línea const hostname = parsed.hostname.replace(/^www\./, ""). Esto elimina el prefijo “www.” del hostname. ¿Por qué? Porque para Google, www.tudominio.com/blog y tudominio.com/blog son URLs diferentes. Si no tienes cuidado y algunas URLs tienen www y otras no, Google piensa que tienes contenido duplicado. Al normalizar todas las URLs para que NO tengan www, te aseguras de que solo una versión de cada página exista en el sitemap.

El protocolo HTTPS: La línea parsed.protocol = "https:" fuerza que todas las URLs usen HTTPS. Si tu sitio no usa HTTPS, tienes problemas más grandes que el SEO, pero esta normalización te protege por si acaso alguna URL canónica externa todavía usa HTTP. Le estás diciendo a Google “mi sitio es seguro, usa HTTPS”.

La barra final: La función stripTrailingSlash() elimina la barra final de las URLs excepto en la raíz. Para Google, tudominio.com/blog y tudominio.com/blog/ son técnicamente diferentes. Algunos servidores los tratan igual, otros no. Al eliminar consistentemente la barra final (excepto en / que es solo la raíz), evitas este problema de duplicación.

Fíjate en el detalle urlString !== "/", esto es importante porque no queremos convertir la URL raíz de tudominio.com/ a tudominio.com (sin la barra), que técnicamente es incorrecto. La raíz siempre debe tener su barra.

Try-catch de seguridad: Todo el código está envuelto en un try-catch porque parsear URLs puede fallar si la URL está malformada. Si falla, al menos devolvemos la URL sin www pero sin crashear. Es mejor tener una URL no perfecta en el sitemap que un sitemap que explota al construirse.

Esta normalización no solo se aplica al sitemap. La misma lógica aparece en isInternalUrl() para verificar URLs canónicas. Cuando comparas hostname === "tudominio.com", también eliminas las www primero. Entonces, si un artículo tiene canonical_url: "https://www.tudominio.com/blog/articulo", la función reconoce correctamente que es interna a tu sitio (después de quitar el www) y la incluye en el sitemap.

Es uno de esos detalles que nadie nota cuando funciona bien, pero que causa dolores de cabeza enormes cuando falta. URLs inconsistentes son la receta perfecta para contenido duplicado, rankings diluidos, y una experiencia horrible en Google Search Console donde ves dos versiones de cada página compitiendo entre sí.

Los resultados: un sitemap que vive y respira

Después de implementar todo esto, ¿qué consigues exactamente?

Primero, y más obvio, tienes un sitemap que se actualiza solo. Cada vez que escribes un nuevo artículo, cuando haces deploy de tu aplicación, ese artículo aparece automáticamente en el sitemap. No tienes que recordar actualizar nada manualmente. No hay archivos XML estáticos que mantener. Es simplemente automático. Y si borras un artículo desaparece del sitemap. Todo se mantiene sincronizado sin esfuerzo de tu parte.

Las URLs canónicas respetadas significan que no tienes problemas de contenido duplicado. Si republicaste contenido de otro sitio, has marcado claramente cuál es la fuente original. Si el contenido es tuyo y lo has republicado en otros lugares, has establecido tu sitio como la fuente canónica. En ambos casos, Google sabe exactamente cómo manejar tu contenido.

La arquitectura que has construido es súper escalable. Si mañana quieres añadir una nueva sección como “tutoriales” o “casos de estudio”, solo necesitas crear funciones similares a buildBlogRoutes(). El patrón ya está establecido y testeado.

Y desde el punto de vista de performance, todo esto se genera en build time. No hay penalización en runtime porque el sitemap se calcula cuando construyes tu aplicación, no cada vez que un usuario visita una página. Next.js es lo suficientemente inteligente para cachear todo esto eficientemente.

El siguiente paso: Google Search Console

Una vez implementado todo esto, deberías configurar Google Search Console si no lo has hecho ya. Ahí puedes enviar tu sitemap manualmente para acelerar el proceso inicial. La URL de tu sitemap será algo como https://tudominio.com/sitemap.xml - Next.js automáticamente convierte tu sitemap.ts en el XML que Google espera.

Puedes ver exactamente qué páginas está indexando Google, cuáles tienen problemas, y cómo está performando tu sitio en las búsquedas reales. Es fascinante ver cómo poco a poco todas tus páginas empiezan a aparecer en los resultados.

El SEO no es magia instantánea. Google puede tomar días o incluso semanas para rastrear e indexar todo tu contenido. Pero lo importante es que has hecho tu parte. Les has dado un mapa perfecto con todas tus páginas. El resto es cuestión de tiempo y de que los algoritmos hagan su trabajo.

Tu flujo de trabajo ahora es simple: escribe tu artículo, haz commit, deploya, y listo. El resto sucede automágicamente.

Happy coding y que Google sea generoso con tu ranking!