Интернационализация (i18n) одностраничных приложений (SPA) представляет комплексную инженерную задачу, объединяющую клиентский роутинг, синхронизацию языковых словарей и соблюдение спецификаций поисковых систем.
Главная антипаттерн-ловушка в SPA — попытка определять язык пользователя исключительно на клиенте: через заголовок Accept-Language, navigator.language, куки или localStorage, оставляя URL неизменным (например, единый адрес https://example.com/catalog для всех языков). Поисковые краулеры (Googlebot, YandexBot) обходят страницы без сохраненных куки, преимущественно с IP-адресов США или региональных дата-центров, и не эмулируют переключение языковых селекторов в интерфейсе. В результате:
Фундаментальный стандарт международного SEO требует, чтобы каждая языковая и региональная версия имела собственный уникальный статический URL. При этом взаимосвязь между версиями должна быть строго подтверждена с помощью атрибутов rel='alternate' hreflang.
Второй компонент масштабируемой поисковой архитектуры — генерация карты сайта (Sitemap.xml). В отличие от классических многостраничных CMS, SPA не может генерировать sitemap на лету внутри клиентского рантайма. Карта сайта должна компилироваться изолированным процессом (Node.js/TypeScript скрипт на этапе сборки в CI/CD) путем запроса сущностей из базы данных или headless CMS, валидироваться по XML-схеме и публиковаться на CDN со связыванием через протокол мгновенной индексации IndexNow.
В мультиязычной архитектуре действует строгое правило двунаправленного подтверждения (Bidirectional Confirmation): если страница /ru/product заявляет, что ее английской версией является /en/product, то страница /en/product обязана содержать обратную ссылку на /ru/product. При отсутствии встречного подтверждения поисковый робот бракует связь во избежание захвата чужих доменов.
+-------------------------------------------------------------------------+
| Схема двунаправленной связи hreflang |
+-------------------------------------------------------------------------+
Русская версия: Английская версия:
https://example.com/ru/item https://example.com/en/item
+--------------------------+ +--------------------------+
| hreflang="ru" (self) | | hreflang="en" (self) |
| hreflang="en" ----------->=======>| hreflang="ru" ----------->=======+
| hreflang="x-default" ----+ | hreflang="x-default" ----+ |
+--------------------------+ +--------------------------+ |
| | |
+-----------------+ +-------------+ |
v v |
Глобальный шлюз (x-default): |
https://example.com/item |
+--------------------------+ |
| hreflang="x-default" | |
| hreflang="ru" <----------+========================+
| hreflang="en" |
+--------------------------+Ключевые сущности:
Скрипт сборки запрашивает актуальный список опубликованных сущностей со всеми языковыми слагами.
Для каждой страницы формируется полный набор альтернативных адресов с валидацией по стандарту BCP 47.
Массив ссылок разбивается на файлы до 45 000 строк. Генерируется корневой sitemapindex.
Дельта изменившихся за коммит URL сериализуется в JSON и отправляется в IndexNow API.
Какое ключевое правило спецификации поисковых систем нарушается, если страница '/ru/product' указывает hreflang='en' на '/en/product', но на странице '/en/product' отсутствует обратный тег hreflang='ru'?
/de/, /fr/, /es/) с корректной географической привязкой цен и валют без потери поискового веса.sitemap-products-1.xml, sitemap-products-2.xml.en-GB) и США (en-US) при одинаковом текстовом контенте.В React 19 теги link rel="alternate" поднимаются в <head> на уровне листового компонента страницы.
import React from 'react';
interface LocaleMapping {
locale: string; // 'ru', 'en-US', 'de', 'x-default'
path: string; // '/ru/catalog/shoes', '/en/catalog/shoes'
}
interface I18nHeadLinksProps {
currentLocale: string;
baseUrl: string;
alternates: readonly LocaleMapping[];
}
export function I18nHeadLinks({ baseUrl, alternates }: I18nHeadLinksProps) {
return (
<>
{/* Автоматический подъем в <head> средствами React 19 */}
{alternates.map(({ locale, path }) => (
<link
key={`hreflang-${locale}`}
rel="alternate"
hrefLang={locale}
href={`${baseUrl}${path}`}
/>
))}
</>
);
}Скрипт запускается в процессе сборки приложения (pnpm run build), запрашивает данные из API, нарезает ссылки блоками по 50 000 штук по стандарту sitemaps.org и формирует файл индекса 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; // Безопасный порог ниже лимита в 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';
// Двунаправленные связи hreflang внутри 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 указывает на базовый дефолтный язык (например, английский)
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 = ['ru', 'en', 'de'];
const totalUrls: string[] = [];
// Генерация полного пула URL для всех поддерживаемых локалей
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++;
}
// Создание 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] Сгенерирован sitemap.xml и ${sitemapFiles.length} подфайлов.`);
}Сразу после завершения деплоя CI/CD скрипт отправляет измененные URL в протокол 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] Нет обновленных URL для отправки.');
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] Успешно отправлено ${updatedUrls.length} URL.`);
} else {
const errorText = await response.text();
console.error(`[IndexNow] Ошибка API (${response.status}): ${errorText}`);
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown network error';
console.error(`[IndexNow] Сетевой сбой: ${message}`);
}
}Какое из утверждений об архитектуре генерации карт сайта для масштабных каталогов (100 000+ URL) является верным согласно спецификации sitemaps.org и поисковым протоколам?
Страница A (/ru/catalog) ссылается через hreflang на страницу B (/en/catalog), но на странице B забыли указать обратную ссылку на страницу A. Поисковые алгоритмы Google строго отбраковывают такие связки: обе страницы теряют языковую дифференциацию и начинают конкурировать друг с другом в выдаче.
Решение: Всегда автоматизируйте генерацию hreflang на уровне централизованной схемы или sitemap.xml, где взаимные ссылки генерируются детерминированно из одного кортежа данных.
Ошибочное указание кода страны вместо языка (например, hreflang="ru-RU" корректно, но hreflang="RU" невалидно; hreflang="uk" означает украинский язык, а не Соединенное Королевство United Kingdom, чей код en-GB).
Решение: Всегда следуйте спецификации BCP 47: сначала код языка ISO 639-1 (два строчных символа), затем опциональный дефис и код региона ISO 3166-1 Alpha 2 (два заглавных символа): de-AT, en-US.
Если сайт имеет версии для Германии (de-DE) и Австрии (de-AT), но не определяет x-default, пользователь из Швейцарии или США не имеет явного целевого URL, и поисковик случайным образом ранжирует любую версию.
Решение: Обязательно объявляйте x-default, ведущий на нейтральный селектор стран либо на основную международную версию страницы.
Помещение 60 000 ссылок в один файл sitemap.xml или превышение размера файла в 50 МБ (до сжатия) приводит к полному отказу краулеров Google и Яндекс парсить этот файл.
Решение: Нарезайте карты сайта на чанки до 45 000 URL и объединяйте их через родительский файл <sitemapindex>.