La internacionalización (i18n) en aplicaciones de página única (SPAs) constituye un desafío de ingeniería multidimensional que articula el enrutamiento cliente, la sincronización de diccionarios por idioma y la observancia rigurosa de los estándares de rastreo e indexación.
El antipatrón más perjudicial en el desarrollo de SPAs consiste en detectar y alternar el idioma del usuario exclusivamente en el cliente: apoyándose en Accept-Language, navigator.language, cookies o localStorage, conservando una URL idéntica para todas las versiones (por ejemplo, sirviendo inglés, ucraniano y español bajo la misma dirección https://agenticlab.dev/catalog). Los rastreadores de búsqueda (Googlebot, Bingbot) ejecutan peticiones sin cookies de sesión persistidas, provienen en su mayoría de IPs estadounidenses o centros de datos en la nube y no interactúan con selectores visuales de idioma. En consecuencia:
El principio cardinal del SEO internacional exige que cada variante lingüística y regional disponga de una URL única, estática y rastreable. Asimismo, los vínculos recíprocos entre documentos traducidos deben declararse formalmente mediante atributos rel='alternate' hreflang.
El segundo pilar de una arquitectura de búsqueda escalable es la generación programada del mapa del sitio (Sitemap.xml). A diferencia de los CMS clásicos multipágina, una SPA no puede sintetizar un sitemap al vuelo en el navegador cliente. El mapa debe compilarse en un proceso aislado en el pipeline de CI/CD (script en Node.js/TypeScript durante el build) consultando la base de datos o CMS headless, validándose contra el esquema XML correspondiente y desplegándose en CDN, comunicándose mediante el protocolo de indexación acelerada IndexNow.
En las arquitecturas multilingües rige el principio inquebrantable de Confirmación Bidireccional (Bidirectional Confirmation): si la página /es/product indica que su homóloga en inglés es /en/product, la página /en/product está obligada a incluir un enlace recíproco hacia /es/product. Si se omite la correspondencia mutua, el motor de búsqueda descarta el mapeo para impedir suplantaciones o manipulaciones de tráfico.
+-------------------------------------------------------------------------+
| Esquema de Mapeo Bidireccional hreflang |
+-------------------------------------------------------------------------+
Versión en Español: Versión en Inglés:
https://agenticlab.dev/es/item https://agenticlab.dev/en/item
+--------------------------+ +--------------------------+
| hreflang="es" (self) | | hreflang="en" (self) |
| hreflang="en" ----------->=======>| hreflang="es" ----------->=======+
| hreflang="x-default" ----+ | hreflang="x-default" ----+ |
+--------------------------+ +--------------------------+ |
| | |
+-----------------+ +-------------+ |
v v |
Pasarela Global (x-default): |
https://agenticlab.dev/item |
+--------------------------+ |
| hreflang="x-default" | |
| hreflang="es" <----------+========================+
| hreflang="en" |
+--------------------------+Conceptos indispensables:
/es/, /uk/, /de/) con coherencia geográfica de precios y monedas sin canibalizar enlaces.sitemap-products-1.xml, sitemap-products-2.xml por debajo de 45.000 URLs cada uno.es-ES) y Latinoamérica (es-MX, es-AR) cuando la descripción textual es prácticamente idéntica.En React 19, los elementos link rel="alternate" se elevan automáticamente a <head> directamente desde el componente de página hoja.
import React from 'react';
interface LocaleMapping {
locale: string; // 'en', 'uk', 'es', 'x-default'
path: string; // '/en/catalog/shoes', '/es/catalog/shoes'
}
interface I18nHeadLinksProps {
currentLocale: string;
baseUrl: string;
alternates: readonly LocaleMapping[];
}
export function I18nHeadLinks({ baseUrl, alternates }: I18nHeadLinksProps) {
return (
<>
{/* React 19 eleva automáticamente estos elementos link a document.head */}
{alternates.map(({ locale, path }) => (
<link
key={`hreflang-${locale}`}
rel="alternate"
hrefLang={locale}
href={`${baseUrl}${path}`}
/>
))}
</>
);
}El script se ejecuta durante la fase de compilación (pnpm run build), consulta los endpoints de datos, trocea las URLs en paquetes de 45.000 elementos según el estándar sitemaps.org y genera el archivo raíz sitemap.xml.
import fs from 'node:fs/promises';
import path from 'node:path';
interface RouteEntry {
slug: string;
updatedAt: string;
alternates: Record<string, string>; // locale -> localizedSlug
}
const BASE_URL = 'https://agenticlab.dev';
const MAX_URLS_PER_SITEMAP = 45000; // Margen de seguridad bajo el límite de 50.000
const OUTPUT_DIR = path.resolve(process.cwd(), 'dist');
function formatXmlDate(dateStr: string): string {
return new Date(dateStr).toISOString().split('T')[0];
}
function escapeXml(unsafe: string): string {
return unsafe.replace(/[<>&'"]/g, (c) => {
switch (c) {
case '<': return '<';
case '>': return '>';
case '&': return '&';
case '\'': return ''';
case '"': return '"';
default: return c;
}
});
}
function buildUrlNode(entry: RouteEntry, locale: string): string {
const currentUrl = `${BASE_URL}/${locale}/${entry.slug}`;
let xml = ' <url>\n';
xml += ` <loc>${escapeXml(currentUrl)}</loc>\n`;
xml += ` <lastmod>${formatXmlDate(entry.updatedAt)}</lastmod>\n`;
xml += ' <changefreq>weekly</changefreq>\n';
xml += ' <priority>0.8</priority>\n';
// Enlaces hreflang bidireccionales dentro del XML Sitemap
for (const [altLocale, altSlug] of Object.entries(entry.alternates)) {
const altUrl = `${BASE_URL}/${altLocale}/${altSlug}`;
xml += ` <xhtml:link rel="alternate" hreflang="${escapeXml(altLocale)}" href="${escapeXml(altUrl)}" />\n`;
}
// x-default apunta al idioma genérico de respaldo (inglés)
const defaultUrl = `${BASE_URL}/en/${entry.alternates['en'] || entry.slug}`;
xml += ` <xhtml:link rel="alternate" hreflang="x-default" href="${escapeXml(defaultUrl)}" />\n`;
xml += ' </url>\n';
return xml;
}
export async function generateAllSitemaps(routes: readonly RouteEntry[]): Promise<void> {
const supportedLocales = ['en', 'uk', 'es'];
const totalUrls: string[] = [];
// Creación del catálogo completo de URLs para todos los idiomas admitidos
for (const route of routes) {
for (const locale of supportedLocales) {
totalUrls.push(buildUrlNode(route, locale));
}
}
const sitemapFiles: string[] = [];
let chunkIndex = 1;
for (let i = 0; i < totalUrls.length; i += MAX_URLS_PER_SITEMAP) {
const chunk = totalUrls.slice(i, i + MAX_URLS_PER_SITEMAP);
const fileName = `sitemap-content-${chunkIndex}.xml`;
const xmlContent = `<?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">
${chunk.join('')}
</urlset>`;
await fs.writeFile(path.join(OUTPUT_DIR, fileName), xmlContent, 'utf-8');
sitemapFiles.push(fileName);
chunkIndex++;
}
// Composición del índice Sitemap Index
const today = new Date().toISOString().split('T')[0];
const indexXml = `<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${sitemapFiles
.map(
(file) => ` <sitemap>
<loc>${BASE_URL}/${file}</loc>
<lastmod>${today}</lastmod>
</sitemap>`
)
.join('\n')}
</sitemapindex>`;
await fs.writeFile(path.join(OUTPUT_DIR, 'sitemap.xml'), indexXml, 'utf-8');
console.log(`[Sitemap] Generado sitemap.xml y ${sitemapFiles.length} ficheros parciales.`);
}Al finalizar el despliegue en CI/CD, el worker notifica las URLs afectadas directamente al protocolo IndexNow.
interface IndexNowPayload {
host: string;
key: string;
keyLocation: string;
urlList: string[];
}
export async function submitToIndexNow(
host: string,
apiKey: string,
updatedUrls: readonly string[]
): Promise<void> {
if (updatedUrls.length === 0) {
console.log('[IndexNow] No hay URLs modificadas para transmitir.');
return;
}
const payload: IndexNowPayload = {
host,
key: apiKey,
keyLocation: `https://${host}/${apiKey}.txt`,
urlList: [...updatedUrls],
};
try {
const response = await fetch('https://api.indexnow.org/indexnow', {
method: 'POST',
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
body: JSON.stringify(payload),
});
if (response.ok || response.status === 202) {
console.log(`[IndexNow] Transmitidas ${updatedUrls.length} URLs exitosamente.`);
} else {
const errorText = await response.text();
console.error(`[IndexNow] Error en la API (${response.status}): ${errorText}`);
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Error desconocido de red';
console.error(`[IndexNow] Incidencia de conectividad: ${message}`);
}
}La página A (/es/catalog) referencia a la página B (/en/catalog) mediante hreflang, pero la página B omite el enlace de retorno hacia la página A. Los motores de búsqueda anulan el conjunto: ambas páginas pierden su diferenciación regional y pasan a competir entre sí en los rankings.
Solución: Automatizar siempre la generación de hreflang desde un generador centralizado o mapa XML donde las relaciones recíprocas se deriven deterministamente de una sola fuente.
Declarar únicamente códigos geográficos es erróneo (por ejemplo, hreflang="ES" no es válido; hreflang="uk" identifica al idioma ucraniano y no al Reino Unido United Kingdom, que exige en-GB).
Solución: Ajustarse estrictamente a BCP 47: primero el código de idioma ISO 639-1 (dos letras minúsculas) y después, si aplica, el guion y el código de país ISO 3166-1 Alpha 2 (dos letras mayúsculas): es, en-US, uk-UA.
Si un sitio cuenta con versiones para España (es-ES) y México (es-MX), pero no define x-default, un visitante de Colombia o Estados Unidos carece de una página de aterrizaje canónica y el buscador posicionará una versión al azar.
Solución: Declarar siempre x-default apuntando hacia un selector global de regiones o hacia la versión internacional por defecto en inglés.
Incluir 60.000 enlaces en un único sitemap.xml o superar los 50 MB sin comprimir provoca el rechazo automático del documento por parte de Googlebot y Bingbot.
Solución: Dividir los mapas en lotes máximos de 45.000 URLs y agruparlos bajo un contenedor padre <sitemapindex>.
El script de compilación obtiene el censo de publicaciones activas con sus slugs en cada idioma.
Para cada página se define la matriz completa de enlaces alternativos conformes a BCP 47.
La lista de URLs se segmenta en archivos de 45.000 líneas y se genera el sitemapindex maestro.
Las URLs actualizadas se compilan en un JSON firmado y se envían a la pasarela de IndexNow.