El sitemap automático: filter, customPages y serialize
Generar el mapa del sitio sin mantenerlo a mano: instalar @astrojs/sitemap, reconocer el índice y los ficheros por lote que produce, y afinarlo con filter para excluir rutas, customPages para añadir URLs ajenas al árbol de ficheros y serialize para fijar changefreq, priority y lastmod, con sus diferencias entre SSG y SSR.
Un sitemap es el índice que tu sitio ofrece a los buscadores: la lista completa de sus URLs, en un XML que los rastreadores leen para descubrir cada página sin tener que adivinarla siguiendo enlaces. Escribirlo a mano es condenarse a que se desactualice al primer post nuevo. @astrojs/sitemap lo deriva del manifiesto de rutas en cada build, así que el mapa nunca miente sobre el territorio. Y con tres opciones —filter, customPages y serialize— pasas de un listado bruto a un mapa afinado que dice qué importa y cada cuánto cambia.
- Instalar
@astrojs/sitemapy reconocer los ficheros que genera. - Excluir rutas del mapa con la opción
filter. - Añadir URLs ajenas al árbol de ficheros con
customPages. - Ajustar cada entrada con
serialize, y distinguir SSG de SSR.
Instalar y qué genera
La integración se añade con npx astro add sitemap, que la registra en el array integrations de la configuración. Como el RSS, exige site: sin una URL base no hay direcciones absolutas que listar. Durante el build, la integración enumera todas las páginas conocidas y escribe un sitemap-index.xml que apunta a uno o varios sitemap-0.xml, troceados por lote cuando el sitio supera el límite de entradas por fichero.
// astro.config.mjs
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://misitio.com',
integrations: [sitemap()],
});
El índice existe porque el estándar recomienda no meter decenas de miles de URLs en un solo fichero; la integración parte el listado en lotes y publica un índice que los agrupa. A ti te basta con declarar site y añadir la integración: el mapa aparece en /sitemap-index.xml tras el build, y solo queda enlazarlo desde el robots.txt o la cabecera para que los rastreadores lo encuentren.
Por defecto, la integración es deliberadamente parca: no inventa lastmod, changefreq ni priority para cada entrada, porque un valor fabricado engaña peor que su ausencia. El troceado se gobierna con entryLimit —45000 URLs por fichero, el techo que fija el estándar—, y esa es la razón de que hasta un sitio de tres páginas reciba un índice: la forma viene preparada para crecer a millones de URLs sin que reescribas nada.
filter y customPages
Dos opciones controlan qué URLs entran. filter recibe cada dirección como cadena y devuelve un booleano: true la conserva, false la descarta. Es la vía para sacar del mapa lo que no quieres indexar —un panel de administración, una carpeta de borradores, las páginas de agradecimiento tras un formulario—. customPages, al revés, añade URLs absolutas que no existen en el árbol de ficheros: páginas generadas por otro sistema, o rutas que el build no puede enumerar solo.
sitemap({
filter: (page) => !page.includes('/borradores/'),
customPages: [
'https://misitio.com/externa/uno/',
'https://misitio.com/externa/dos/',
],
}),
Conviene ver el reparto: filter decide si una URL que ya existe entra en el mapa, mientras que customPages añade direcciones que el build jamás vería. Uno resta de lo conocido; el otro suma lo desconocido. Juntos tapan los dos huecos por los que un mapa automático se queda corto: rutas reales que no quieres indexar y rutas indexables que no nacen del árbol de ficheros.
filter
Un predicado por URL. Devuelve false y la ruta desaparece del mapa. Para excluir secciones enteras.
customPages
Un array de URLs absolutas que el build no conoce. Para páginas externas o rutas dinámicas de SSR.
serialize
Transforma cada entrada: fija changefreq, priority y lastmod, o la descarta devolviendo nada.
serialize y la personalización por entrada
Cuando necesitas algo más fino que incluir o excluir, serialize recibe cada entrada del mapa —un objeto con url, changefreq, priority, lastmod y links— y devuelve la versión modificada. Ahí subes la prioridad de las páginas clave, marcas el índice del blog como de cambio diario, o derivas el lastmod de la fecha del contenido. Devolver undefined desde serialize descarta la entrada, lo que lo convierte también en un filtro con acceso al objeto completo.
sitemap({
changefreq: 'weekly',
priority: 0.7,
serialize(item) {
if (item.url.endsWith('/blog/')) {
item.changefreq = 'daily';
item.priority = 1.0;
}
return item;
},
}),
Las opciones changefreq, priority y lastmod a nivel de integración fijan los valores por defecto de todas las entradas; serialize los sobrescribe caso por caso. Conviene no obsesionarse con estos campos: los buscadores modernos los toman como pistas, no como órdenes, y un priority inflado en todas las páginas no engaña a nadie. Su valor real es relativo —señalar qué páginas importan más dentro de tu propio sitio—, no absoluto.
Un uso especialmente valioso de serialize es fijar lastmod con una fecha real tomada del frontmatter —la última edición de cada post—, para que el mapa diga cuándo cambió de verdad cada página y no una fecha global sin sentido. La regla es la de todo el nivel: deriva el dato de su fuente verdadera en vez de inventar un valor plausible.
No hacen falta dos mecanismos para excluir. Si dentro de serialize decides que una entrada no debe aparecer, devuelve undefined en vez del objeto y la integración la omite. La diferencia con filter es el nivel de detalle: filter solo ve la URL como texto, mientras que serialize ve la entrada entera —fecha, frecuencia, prioridad— y puede decidir con esa información. Uno criba por dirección; el otro, por contenido de la entrada.
Si tu sitio es multilingüe, el campo links de cada entrada lleva las alternativas por idioma que los buscadores usan para servir la versión correcta a cada usuario. La opción i18n de la integración lo rellena a partir de tu configuración de localización, de modo que el mapa no solo enumera páginas sino que declara qué traducciones son equivalentes. Otra pista derivada: nace de tu configuración de idiomas, no de una tabla que mantengas aparte.
SSG frente a SSR
El sitemap se construye en el build, y eso marca su alcance. En un sitio estático todas las páginas se conocen al compilar, así que la integración las enumera todas sin esfuerzo. En SSR solo se conocen las rutas prerenderizadas; las que se resuelven por petición —un [id] que sale de una base de datos en tiempo real— no existen aún cuando el build corre, y por tanto no entran en el mapa automáticamente. La solución es dárselas explícitas con customPages, o construir un endpoint de sitemap propio que las consulte.
Ese endpoint propio es el patrón de la lección anterior aplicado al XML: un src/pages/sitemap-extra.xml.ts que consulta tu fuente dinámica y emite el mapa a mano, del mismo modo que emites JSON. La integración cubre lo que el árbol de ficheros conoce; tu endpoint cubre lo que solo existe en tiempo de ejecución. Nada impide que ambos convivan, referenciados desde el mismo robots.txt, cada uno a cargo de la mitad del sitio que mejor conoce.
// src/pages/sitemap-extra.xml.ts
import type { APIRoute } from 'astro';
export const GET: APIRoute = async ({ site }) => {
const ids = await cargarIdsDinamicos();
const urls = ids.map((id) => {
const loc = new URL('/p/' + id + '/', site).href;
return '<url><loc>' + loc + '</loc></url>';
});
const xml =
'<?xml version="1.0" encoding="UTF-8"?>' +
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' +
urls.join('') +
'</urlset>';
return new Response(xml, {
headers: { 'Content-Type': 'application/xml' },
});
};
flowchart LR MAN[manifiesto de rutas] --> INT[integracion sitemap] INT --> FIL[filter descarta rutas] FIL --> SER[serialize ajusta entradas] SER --> OUT[sitemap 0 xml e indice] CP[customPages] --> INT style MAN fill:#89b4fa,color:#11111b style CP fill:#f9e2af,color:#11111b style OUT fill:#a6e3a1,color:#11111b
Una ruta catch-all servida bajo demanda puede tener un número indefinido de URLs, y ninguna de ellas existe hasta que llega la petición que la genera. La integración no puede enumerar lo que aún no ha nacido, así que esas páginas quedan fuera del mapa salvo que las declares. Si tu contenido dinámico debe indexarse, enuméralo con customPages a partir de tu fuente de datos, o genera el sitemap desde un endpoint que recorra esa fuente en el build.
La virtud profunda de un sitemap generado no es que te ahorre teclear URLs, sino que elimina de raíz la clase entera de errores que nacen de mantener a mano una copia de algo que ya existe. Un sitemap escrito a mano es una segunda representación de tu sitio que empieza a mentir en cuanto añades una página y olvidas registrarla: dos fuentes de verdad que divergen calladamente hasta que un buscador indexa páginas muertas o ignora páginas vivas. Un sitemap derivado no puede divergir porque no es una copia sino una función del original: cada build lo recalcula desde el manifiesto de rutas real, de modo que el mapa y el territorio se rehacen juntos y por construcción coinciden. Esta es una de las ideas más subestimadas de la ingeniería: cuando un dato puede computarse de otro, computarlo siempre es infinitamente más fiable que copiarlo, porque el cómputo se repite y la copia se pudre. El sitemap es solo el ejemplo visible; la misma lógica gobierna el feed que ya viste y los endpoints, el canonical y las etiquetas Open Graph que verás después. Todos son artefactos derivados, y todos comparten la misma garantía: mientras se generen de la fuente en cada build, no hay forma de que contradigan a la fuente. La lección que trasciende Astro es que la automatización de un artefacto derivado no es una comodidad, sino una política de integridad: lo que se deriva no miente, y lo que se copia, tarde o temprano, sí.
- Instala
@astrojs/sitemap, fijasite, compila e inspeccionasitemap-index.xmly susitemap-0.xml. - Excluye una sección entera con
filtery comprueba que sus URLs desaparecen del mapa. - Añade dos
customPagesexternas y sube laprioritydel índice del blog conserialize. - Cambia a un adaptador SSR, crea una ruta dinámica servida bajo demanda y razona por qué no aparece en el mapa salvo que la declares en
customPages.