Generar sitemaps XML con Claude Code
Crea sitemaps en Astro y Node con hreflang, lastmod, robots.txt y verificación en Search Console.
Un sitemap es un inventario público de URL, no una garantía de indexación
Cuando Claude Code ayuda a publicar artículos, documentación o páginas de producto, el problema no siempre está en la plantilla. Muchas veces está en si los buscadores pueden descubrir las URL correctas. Un sitemap XML indica qué URL canónicas quieres que se rastreen, cuándo tuvieron una actualización importante y cómo se relacionan sus versiones traducidas.
Conviene partir de la regla actual de Google: priority y changefreq se ignoran, y lastmod solo es útil si refleja cambios reales de forma consistente. Además, el antiguo endpoint de ping para sitemaps ya está obsoleto. En un flujo moderno debes usar robots.txt, Google Search Console y comprobaciones de despliegue, no llamadas a https://www.google.com/ping?sitemap=....
Esta guía cubre dos enfoques prácticos: la integración oficial de Astro y un generador en Node.js sin dependencias para colecciones MDX multilingües. Para encajar esto en una estrategia más amplia, revisa también optimización SEO con Claude Code y configuración CI/CD con Claude Code.
Reglas oficiales que debes respetar
| Punto | Decisión práctica |
|---|---|
| URL | Usa URL absolutas como https://example.com/blog/post/ |
| Límite | Divide si un archivo llega a 50.000 URL o 50 MB sin comprimir |
| Codificación | Guarda en UTF-8 y escapa los valores XML |
lastmod | Usa la fecha real de un cambio importante en contenido, datos estructurados o enlaces |
priority / changefreq | Puedes omitirlos para Google |
| Sitios multilingües | Cada URL debe listar sus alternativas y también a sí misma |
| Envío | Usa robots.txt y Search Console; elimina scripts de ping |
Las fuentes base son la guía de sitemaps de Google, el aviso de retirada del ping, la guía de versiones localizadas y el protocolo de sitemaps.org.
Caso 1: páginas y rutas de blog en Astro
En un sitio Astro estático, empieza con @astrojs/sitemap. Se ejecuta durante astro build y puede generar relaciones de idioma si las rutas están bien definidas.
npx astro add sitemap
// astro.config.mjs
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://claudecodelab.com',
integrations: [
sitemap({
filter: (page) => !page.includes('/draft/') && !page.includes('/preview/'),
i18n: {
defaultLocale: 'ja',
locales: {
ja: 'ja',
en: 'en',
zh: 'zh-CN',
ko: 'ko',
es: 'es',
fr: 'fr',
de: 'de',
pt: 'pt-BR',
hi: 'hi',
id: 'id',
},
},
}),
],
});
El fallo típico es dejar mal configurado site: localhost, un dominio de vista previa o una mezcla de http y https. Google intentará rastrear exactamente las URL que aparezcan en el archivo, así que el sitemap debe coincidir con tus URL canónicas.
Caso 2: generador Node.js para MDX multilingüe
Un generador propio es útil cuando el contenido vive en carpetas como blog, blog-en y blog-zh, o cuando updatedDate debe convertirse en lastmod. Este ejemplo usa solo módulos nativos de Node.js y crea public/sitemap.xml.
// scripts/generate-sitemap.mjs
import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
const SITE_URL = (process.env.SITE_URL ?? 'https://example.com').replace(/\/$/, '');
const OUT_DIR = 'public';
const OUT_FILE = path.join(OUT_DIR, 'sitemap.xml');
const collections = [
{ dir: 'site/src/content/blog', prefix: '/blog', hreflang: 'ja' },
{ dir: 'site/src/content/blog-en', prefix: '/en/blog', hreflang: 'en' },
{ dir: 'site/src/content/blog-zh', prefix: '/zh/blog', hreflang: 'zh-CN' },
{ dir: 'site/src/content/blog-ko', prefix: '/ko/blog', hreflang: 'ko' },
{ dir: 'site/src/content/blog-es', prefix: '/es/blog', hreflang: 'es' },
{ dir: 'site/src/content/blog-fr', prefix: '/fr/blog', hreflang: 'fr' },
{ dir: 'site/src/content/blog-de', prefix: '/de/blog', hreflang: 'de' },
{ dir: 'site/src/content/blog-pt', prefix: '/pt/blog', hreflang: 'pt-BR' },
{ dir: 'site/src/content/blog-hi', prefix: '/hi/blog', hreflang: 'hi' },
{ dir: 'site/src/content/blog-id', prefix: '/id/blog', hreflang: 'id' },
];
function escapeXml(value) {
return String(value).replace(/[<>&'"]/g, (char) => ({
'<': '<',
'>': '>',
'&': '&',
"'": ''',
'"': '"',
})[char]);
}
async function* walk(dir) {
let items;
try {
items = await readdir(dir, { withFileTypes: true });
} catch (error) {
if (error.code === 'ENOENT') return;
throw error;
}
for (const item of items) {
const fullPath = path.join(dir, item.name);
if (item.isDirectory()) {
yield* walk(fullPath);
} else if (/\.(md|mdx)$/.test(item.name)) {
yield fullPath;
}
}
}
function frontmatterOf(source) {
return source.match(/^---\n([\s\S]*?)\n---/)?.[1] ?? '';
}
function dateField(frontmatter, key) {
return frontmatter.match(new RegExp(`^${key}:\\s*["']?(\\d{4}-\\d{2}-\\d{2})`, 'm'))?.[1];
}
function routeSlug(collectionDir, filePath) {
return path
.relative(collectionDir, filePath)
.replace(/\\/g, '/')
.replace(/\.(md|mdx)$/, '')
.replace(/\/index$/, '');
}
function encodeRoute(slug) {
return slug.split('/').map(encodeURIComponent).join('/');
}
async function collectEntries() {
const bySlug = new Map();
for (const collection of collections) {
for await (const filePath of walk(collection.dir)) {
const source = await readFile(filePath, 'utf8');
const frontmatter = frontmatterOf(source);
if (/^draft:\s*true\s*$/m.test(frontmatter)) continue;
const info = await stat(filePath);
const slug = routeSlug(collection.dir, filePath);
const lastmod =
dateField(frontmatter, 'updatedDate') ??
dateField(frontmatter, 'pubDate') ??
info.mtime.toISOString().slice(0, 10);
const route = `${collection.prefix}/${encodeRoute(slug)}/`;
const variant = {
loc: `${SITE_URL}${route}`,
hreflang: collection.hreflang,
lastmod,
};
const variants = bySlug.get(slug) ?? [];
variants.push(variant);
bySlug.set(slug, variants);
}
}
return [...bySlug.values()].flatMap((variants) =>
variants.map((variant) => ({
...variant,
alternates: variants.map(({ hreflang, loc }) => ({ hreflang, loc })),
})),
);
}
function buildSitemap(entries) {
const urls = entries.map((entry) => ` <url>
<loc>${escapeXml(entry.loc)}</loc>
<lastmod>${entry.lastmod}</lastmod>
${entry.alternates.map((alt) => ` <xhtml:link rel="alternate" hreflang="${escapeXml(alt.hreflang)}" href="${escapeXml(alt.loc)}" />`).join('\n')}
</url>`).join('\n');
return `<?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">
${urls}
</urlset>
`;
}
const entries = await collectEntries();
if (entries.length === 0) {
throw new Error('No se encontraron URL públicas para el sitemap.');
}
await mkdir(OUT_DIR, { recursive: true });
await writeFile(OUT_FILE, buildSitemap(entries), 'utf8');
console.log(`Se escribieron ${entries.length} URL en ${OUT_FILE}.`);
Ejecuta el script así:
SITE_URL=https://claudecodelab.com node scripts/generate-sitemap.mjs
Caso 3: dividir artículos, productos y documentación
Un blog pequeño puede vivir con un solo sitemap.xml. En sitios más grandes conviene dividir por tipo de contenido para mantenerse bajo los límites oficiales y depurar mejor en Search Console.
<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap>
<loc>https://example.com/sitemap-pages.xml</loc>
<lastmod>2026-06-03</lastmod>
</sitemap>
<sitemap>
<loc>https://example.com/sitemap-blog.xml</loc>
<lastmod>2026-06-03</lastmod>
</sitemap>
<sitemap>
<loc>https://example.com/sitemap-products.xml</loc>
<lastmod>2026-06-03</lastmod>
</sitemap>
</sitemapindex>
Pide a Claude Code que registre cuántas URL contiene cada archivo, que divida antes de llegar a 50.000 URL y que el índice solo apunte a sitemaps del mismo sitio.
robots.txt, Search Console y verificación
Incluye el sitemap en robots.txt:
User-agent: *
Allow: /
Sitemap: https://claudecodelab.com/sitemap.xml
Después, envíalo una vez desde Google Search Console. En despliegues automáticos, comprueba que la URL pública responde con HTTP 200 y que el XML tiene una raíz válida.
// scripts/verify-sitemap.mjs
const sitemapUrl = process.env.SITEMAP_URL ?? 'https://example.com/sitemap.xml';
const response = await fetch(sitemapUrl);
if (!response.ok) {
throw new Error(`Falló la solicitud del sitemap: HTTP ${response.status}`);
}
const xml = await response.text();
if (!xml.includes('<urlset') && !xml.includes('<sitemapindex')) {
throw new Error('La respuesta no parece un sitemap XML.');
}
console.log(`Verificado ${sitemapUrl}. Tamaño: ${xml.length} bytes`);
Errores que debes evitar
El primero es poner la fecha del build en todos los lastmod. Si el contenido no cambió, esa fecha no debe cambiar.
El segundo es incluir borradores, páginas noindex, URL de redirección o duplicados. El sitemap debe contener solo las URL canónicas que quieres mostrar en búsqueda.
El tercero es dejar hreflang en una sola dirección. Cada versión de idioma debe apuntar a sí misma y a todas las demás versiones.
El cuarto es olvidar el escape XML. Un & en una cadena de consulta debe convertirse en &.
Monetización y resultado práctico
El sitemap no monetiza por sí solo, pero protege la capa de descubrimiento de páginas que sí generan valor: tutoriales, comparativas, recursos gratuitos y páginas de consulta. Después de corregirlo, revisa enlaces internos y CTA para que el lector pueda avanzar hacia formación en Claude Code o recursos relacionados.
En el flujo de Masa para ClaudeCodeLab, los cambios con más impacto fueron eliminar el ping antiguo, hacer que lastmod siguiera updatedDate y unir las diez versiones de idioma con hreflang recíproco. La revisión editorial quedó más clara porque el sitemap reflejaba las mismas fechas y slugs que se comprobaban en el frontmatter MDX.
PDF gratis: cheatsheet de Claude Code
Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.
Cuidamos tus datos y no enviamos spam.
Sobre el autor
Masa
Ingeniero enfocado en workflows prácticos con Claude Code.
Artículos relacionados
Workflow de Obsidian a CLAUDE.md con Claude Code
Convierte notas de trabajo de Obsidian en notas operativas de CLAUDE.md para no repetir contexto.
Claude Code Revenue CTA Routing: de artículos a PDF, Gumroad y consulta
Un flujo con Claude Code para dirigir lectores a PDF gratis, Gumroad o consulta según intención.
Reglas de handoff para equipos con Claude Code: evidencia, permisos, rollback e ingresos
Formato práctico para entregar trabajo de Claude Code con pruebas, permisos, rollback, PDF gratis, Gumroad y consulta.