StackPractices
intermediate Por Mathias Paulenko

Generar Sitemaps XML Dinámicamente

Construye y sirve sitemaps XML desde los datos vivos de tu aplicación — paginación más allá del límite de 50k URLs, anotaciones hreflang, lastmod reales y los trucos de caché que lo hacen barato de servir.

Temas: devops

Visión General

Un sitemap XML es la lista que le entregas a los motores de búsqueda: qué páginas existen, cuándo cambiaron y cuánto importa cada una. Un archivo estático funciona mientras el sitio es pequeño; en cuanto el contenido cambia a diario — blogs, tiendas, páginas de usuarios — quieres el sitemap generado desde datos vivos. Abajo va una versión funcional en tres lenguajes: XML construido desde una consulta a la base de datos o una API de contenido, paginación para cuando superas el límite de 50.000 URLs por archivo, y anotaciones hreflang para sitios multi-idioma.

Cuándo Usar

Usa este recurso cuando:

  • Tu sitio tiene miles de páginas que cambian con regularidad y editar un sitemap estático a mano dejó de ser viable.
  • Mantienes un sitio multi-idioma y necesitas anotaciones xhtml:link en el sitemap para hreflang.
  • Quieres lastmod, changefreq y priority derivados de timestamps reales del contenido en lugar de valores fijos.
  • Estás en — o camino a — las 50.000 URLs y necesitas un índice de sitemaps que referencie archivos paginados.

Solución

Python (Flask)

from flask import Flask, Response
from datetime import datetime
import math
import xml.etree.ElementTree as ET

app = Flask(__name__)

def generate_sitemap(urls):
    urlset = ET.Element("urlset", xmlns="http://www.sitemaps.org/schemas/sitemap/0.9")

    for url_data in urls:
        url_elem = ET.SubElement(urlset, "url")
        ET.SubElement(url_elem, "loc").text = url_data["loc"]
        ET.SubElement(url_elem, "lastmod").text = url_data["lastmod"]
        ET.SubElement(url_elem, "changefreq").text = url_data.get("changefreq", "weekly")
        ET.SubElement(url_elem, "priority").text = str(url_data.get("priority", "0.5"))

    return ET.tostring(urlset, encoding="unicode")

@app.route("/sitemap.xml")
def sitemap():
    urls = [
        {"loc": "https://example.com/", "lastmod": "2024-06-01", "priority": "1.0"},
        {"loc": "https://example.com/blog/post-1", "lastmod": "2024-06-10", "priority": "0.8"},
        {"loc": "https://example.com/products/item-1", "lastmod": "2024-06-05", "priority": "0.6"},
    ]

    xml = '<?xml version="1.0" encoding="UTF-8"?>\n'
    xml += generate_sitemap(urls)

    return Response(xml, mimetype="application/xml")

# Índice de sitemap para sitios grandes
@app.route("/sitemap-index.xml")
def sitemap_index():
    sitemap_count = math.ceil(total_url_count / 50000)  # p.ej. len(urls) desde tu DB
    sitemapindex = ET.Element("sitemapindex", xmlns="http://www.sitemaps.org/schemas/sitemap/0.9")

    for i in range(1, sitemap_count + 1):
        sitemap_elem = ET.SubElement(sitemapindex, "sitemap")
        ET.SubElement(sitemap_elem, "loc").text = f"https://example.com/sitemap-{i}.xml"
        ET.SubElement(sitemap_elem, "lastmod").text = datetime.now().strftime("%Y-%m-%d")

    xml = '<?xml version="1.0" encoding="UTF-8"?>\n'
    xml += ET.tostring(sitemapindex, encoding="unicode")
    return Response(xml, mimetype="application/xml")

JavaScript (Express)

const express = require("express");
const app = express();

function buildSitemap(urls) {
  const lines = [
    '<?xml version="1.0" encoding="UTF-8"?>',
    '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
  ];

  for (const url of urls) {
    lines.push("  <url>");
    lines.push(`    <loc>${escapeXml(url.loc)}</loc>`);
    lines.push(`    <lastmod>${url.lastmod}</lastmod>`);
    lines.push(`    <changefreq>${url.changefreq || "weekly"}</changefreq>`);
    lines.push(`    <priority>${url.priority || "0.5"}</priority>`);
    lines.push("  </url>");
  }

  lines.push("</urlset>");
  return lines.join("\n");
}

function escapeXml(str) {
  return str.replace(/&/g, "&amp;")
            .replace(/</g, "&lt;")
            .replace(/>/g, "&gt;")
            .replace(/"/g, "&quot;")
            .replace(/'/g, "&apos;");
}

app.get("/sitemap.xml", (req, res) => {
  const urls = [
    { loc: "https://example.com/", lastmod: "2024-06-01", priority: "1.0" },
    { loc: "https://example.com/blog/post-1", lastmod: "2024-06-10", priority: "0.8" }
  ];

  res.set("Content-Type", "application/xml");
  res.send(buildSitemap(urls));
});

// Sitemap paginado con hreflang
app.get("/sitemap-products.xml", (req, res) => {
  const products = getProducts(); // Desde DB
  const lines = [
    '<?xml version="1.0" encoding="UTF-8"?>',
    '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"',
    '        xmlns:xhtml="http://www.w3.org/1999/xhtml">'
  ];

  for (const product of products) {
    lines.push("  <url>");
    lines.push(`    <loc>${escapeXml(product.url)}</loc>`);
    lines.push(`    <lastmod>${product.updatedAt}</lastmod>`);

    // Anotaciones hreflang
    for (const lang of ["en", "es", "de"]) {
      lines.push(`    <xhtml:link rel="alternate" hreflang="${lang}"`);
      lines.push(`                href="${escapeXml(product.url)}?lang=${lang}" />`);
    }

    lines.push("  </url>");
  }

  lines.push("</urlset>");
  res.set("Content-Type", "application/xml");
  res.send(lines.join("\n"));
});

Java (Spring Boot)

import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.w3c.dom.Document;
import org.w3c.dom.Element;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.transform.OutputKeys;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import java.io.StringWriter;
import java.time.LocalDate;
import java.util.List;

@RestController
public class SitemapController {

  record UrlEntry(String loc, String lastmod, String changefreq, String priority) {}

  @GetMapping(value = "/sitemap.xml", produces = MediaType.APPLICATION_XML_VALUE)
  public String sitemap() {
    List<UrlEntry> urls = List.of(
      new UrlEntry("https://example.com/", "2024-06-01", "daily", "1.0"),
      new UrlEntry("https://example.com/blog/post-1", "2024-06-10", "weekly", "0.8"),
      new UrlEntry("https://example.com/products/item-1", "2024-06-05", "weekly", "0.6")
    );

    return buildSitemap(urls);
  }

  private String buildSitemap(List<UrlEntry> urls) {
    try {
      DocumentBuilder builder = DocumentBuilderFactory.newInstance().newDocumentBuilder();
      Document doc = builder.newDocument();

      Element urlset = doc.createElement("urlset");
      urlset.setAttribute("xmlns", "http://www.sitemaps.org/schemas/sitemap/0.9");
      doc.appendChild(urlset);

      for (UrlEntry entry : urls) {
        Element url = doc.createElement("url");
        urlset.appendChild(url);

        Element loc = doc.createElement("loc");
        loc.setTextContent(entry.loc());
        url.appendChild(loc);

        Element lastmod = doc.createElement("lastmod");
        lastmod.setTextContent(entry.lastmod());
        url.appendChild(lastmod);

        Element changefreq = doc.createElement("changefreq");
        changefreq.setTextContent(entry.changefreq());
        url.appendChild(changefreq);

        Element priority = doc.createElement("priority");
        priority.setTextContent(entry.priority());
        url.appendChild(priority);
      }

      Transformer transformer = TransformerFactory.newInstance().newTransformer();
      transformer.setOutputProperty(OutputKeys.INDENT, "yes");
      transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2");

      StringWriter writer = new StringWriter();
      transformer.transform(new DOMSource(doc), new StreamResult(writer));
      return writer.toString();
    } catch (Exception e) {
      throw new RuntimeException("Failed to generate sitemap", e);
    }
  }
}

Explicación

  • Estructura XML sigue el protocolo sitemaps.org: una raíz <urlset> con entradas <url>, cada una con <loc>, <lastmod>, <changefreq> y <priority>. Todo debe ser UTF-8 y estar bien escapado — un & sin escapar en un query string rompe el archivo entero para el crawler.
  • Generación en vivo significa consultar tu base de datos, CMS o sistema de archivos en tiempo de request y renderizar XML fresco. En un sitio con tráfico, no reconstruyas en cada hit — cachea la salida una o dos horas (ver Tips de Rendimiento).
  • Paginación vía índice de sitemaps — un archivo de sitemap toca techo en 50.000 URLs o 50MB sin comprimir. Pasado ese punto, sirve un sitemapindex que apunte a sitemap-1.xml, sitemap-2.xml, y así sucesivamente.
  • Anotaciones hreflang van dentro de cada <url> como elementos <xhtml:link>, y cada URL debe listarse a sí misma más todas sus alternativas — la canónica incluida. Hazlo mal y tu indexación multi-idioma se rompe en silencio.

Variantes

EnfoqueFuenteHreflangMejor Para
Consulta a base de datosSQL/ORMManualSitios con mucho contenido (blogs, CMS)
Build estáticoSistema de archivosPre-generadoSitios JAMstack/SSG (Astro, Next.js)
API-drivenREST/GraphQLDesde metadata de APICMS headless (Contentful, Strapi)
Archivo cacheadoRedis/discoEstático después del primer requestSitios de alto tráfico con contenido estable

Lo que funciona

  1. Siempre escapa XML — las URLs deben escapar XML para &, <, >, " y '. Un ampersand sin escapar en un query string rompe el parser del sitemap.
  2. Establece lastmod preciso — usa la fecha real de modificación del contenido, no la fecha actual. Google ignora valores lastmod imprecisos y puede dejar de confiar en tu sitemap.
  3. Pagina antes de alcanzar límites — comienza a generar un índice de sitemap cuando te acerques a 40.000 URLs para dejar margen de crecimiento. Cada sitemap referenciado debe estar bajo 50MB.
  4. Incluye solo URLs canónicas — excluye variantes parametrizadas, IDs de sesión y páginas 404. Los sitemaps solo deben listar páginas canónicas e indexables.
  5. Envía a motores de búsqueda — registra tu sitemap en Google Search Console y Bing Webmaster Tools. Para sitios muy grandes, usa la API de Search Console para enviar updates de sitemap programáticamente.

Errores Comunes

  1. Generar sitemaps con URLs http:// cuando el sitio usa HTTPS — los motores de búsqueda tratan estos como sitios separados y pueden ignorar la versión HTTP.
  2. Olvidar escapar XML en URLs con parámetros de query, causando errores de parser en crawlers de motores de búsqueda.
  3. Listar páginas noindex o cadenas de redirección en el sitemap, desperdiciando presupuesto de crawl y confundiendo a los motores de búsqueda.
  4. Usar la fecha actual para todos los valores lastmod, haciendo el atributo sin sentido y potencialmente ignorado por los crawlers.
  5. Omitir el índice de sitemap para sitios con 100.000+ URLs, resultando en archivos de sitemap individuales que violan el límite de 50.000 URLs / 50MB.

Configuración en Producción

Robots.txt

Apunta a los crawlers al índice, no a los sitemaps individuales — una línea Sitemap: basta:

# robots.txt
User-agent: *
Allow: /

Sitemap: https://example.com/sitemap-index.xml

Extensión News Sitemap

Para Google News, añade el namespace news: con metadatos de publicación. Las entradas no pueden tener más de 48 horas:

<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
        xmlns:news="http://www.google.com/schemas/sitemap-news/0.9">
  <url>
    <loc>https://example.com/news/article-1</loc>
    <news:news>
      <news:publication>
        <news:name>Example Times</news:name>
        <news:language>en</news:language>
      </news:publication>
      <news:publication_date>2024-06-15</news:publication_date>
      <news:title>Breaking Story</news:title>
    </news:news>
  </url>
</urlset>

Tips de Rendimiento

  1. Cachea el XML renderizado con un TTL corto. Reconstruir en cada request es trabajo desperdiciado — regenera cada 1-6 horas:
import redis
from functools import wraps
from flask import request

r = redis.Redis(host="localhost", port=6379, db=0)

def cache_sitemap(ttl_seconds=3600):
    def decorator(fn):
        @wraps(fn)
        def wrapper(*args, **kwargs):
            cache_key = f"sitemap:{request.path}"
            cached = r.get(cache_key)
            if cached:
                return Response(cached, mimetype="application/xml")
            xml = fn(*args, **kwargs)
            r.setex(cache_key, ttl_seconds, xml)
            return Response(xml, mimetype="application/xml")
        return wrapper
    return decorator

@app.route("/sitemap.xml")
@cache_sitemap(ttl_seconds=3600)
def sitemap():
    urls = query_urls_from_db()
    return generate_sitemap(urls)
  1. Genera los sitemaps en un background job para sitios grandes. Escribe los archivos a disco con un cron y deja que el servidor web los sirva como estáticos — coste de consulta cero por request:
# tarea en segundo plano
def generate_sitemap_files():
    urls = query_all_urls()
    for i, batch in enumerate(split_sitemaps(urls)):
        xml = build_sitemap_xml(batch)
        with open(f"/var/www/sitemap-{i+1}.xml", "w") as f:
            f.write(xml)
  1. Sírvelos comprimidos con gzip. El XML comprime un 70-90%; apunta robots.txt a un .xml.gz o genéralo junto al normal:
import gzip
from flask import Response

@app.route("/sitemap.xml.gz")
def sitemap_gzipped():
    xml = generate_sitemap(urls)
    compressed = gzip.compress(xml.encode("utf-8"))
    return Response(compressed, mimetype="application/gzip")
# o genera y comprime por adelantado
gzip -c sitemap-1.xml > sitemap-1.xml.gz

See Also

Preguntas frecuentes

¿Con qué frecuencia debo regenerar el sitemap?

Ajusta la respuesta a la velocidad a la que se mueve tu contenido: contenido que cambia a diario, regenera a diario; un sitio casi estático, reconstruye dentro del pipeline de despliegue; algo muy vivo (un foro, un marketplace), genera bajo demanda detrás de un caché corto — una hora suele equilibrar frescura y carga.

¿Puedo incluir imágenes y videos en el sitemap?

Sí — añade las extensiones de Google para imágenes y video como elementos <image:image> / <video:video> dentro de cada <url>. Así es como Google encuentra medios que tu HTML nunca enlaza.

¿Necesito un sitemap separado por idioma?

Normalmente no — un solo sitemap puede llevar todos los idiomas vía <xhtml:link rel="alternate" hreflang="..."> en cada URL. Dividir por idioma solo compensa a gran escala, cuando el tracking de lastmod por idioma o el tamaño de archivo empiezan a molestar.

¿Los campos changefreq y priority sirven para algo?

Apenas — Google ha dicho que en gran parte los ignora; lastmod es el campo que sí lee. Déjalos si no te cuestan nada, pero no esperes que dirijan al crawler.