Інтернаціоналізація (i18n) односторінкових додатків (SPA) становить комплексну інженерну задачу, що об'єднує клієнтський роутинг, синхронізацію мовних словників та дотримання специфікацій пошукових систем.
Головна антипатерн-пастка в SPA — спроба визначати мову користувача виключно на клієнті: через заголовок Accept-Language, navigator.language, кукі або localStorage, залишаючи URL незмінним (наприклад, єдина адреса https://agenticlab.dev/catalog для всіх мов). Пошукові краулери (Googlebot, Bingbot) обходять сторінки без збережених кукі, переважно з IP-адрес США або регіональних дата-центрів, і не емулюють перемикання мовних селекторів в інтерфейсі. У результаті:
Фундаментальний стандарт міжнародного SEO вимагає, щоб кожна мовна та регіональна версія мала власну унікальну статичну адресу URL. При цьому взаємозв'язок між версіями має бути суворо підтверджений за допомогою атрибутів rel='alternate' hreflang.
Другий компонент масштабованої пошукової архітектури — генерація карти сайту (Sitemap.xml). На відміну від класичних багатосторінкових CMS, SPA не може генерувати sitemap на льоту всередині клієнтського рантайму. Карта сайту повинна компілюватися ізольованим процесом (Node.js/TypeScript скрипт на етапі збирання в CI/CD) шляхом запиту сутностей з бази даних або headless CMS, валідуватися за XML-схемою та публікуватися на CDN зі зв'язуванням через протокол миттєвої індексації IndexNow.
У мультимовній архітектурі діє суворе правило двонапрямленого підтвердження (Bidirectional Confirmation): якщо сторінка /uk/product заявляє, що її англійською версією є /en/product, то сторінка /en/product зобов'язана містити зворотне посилання на /uk/product. За відсутності зустрічного підтвердження пошуковий робот бракує зв'язок для уникнення маніпуляцій з чужими доменами.
+-------------------------------------------------------------------------+
| Схема двонапрямленого зв'язку hreflang |
+-------------------------------------------------------------------------+
Українська версія: Англійська версія:
https://agenticlab.dev/uk/item https://agenticlab.dev/en/item
+--------------------------+ +--------------------------+
| hreflang="uk" (self) | | hreflang="en" (self) |
| hreflang="en" ----------->=======>| hreflang="uk" ----------->=======+
| hreflang="x-default" ----+ | hreflang="x-default" ----+ |
+--------------------------+ +--------------------------+ |
| | |
+-----------------+ +-------------+ |
v v |
Глобальний шлюз (x-default): |
https://agenticlab.dev/item |
+--------------------------+ |
| hreflang="x-default" | |
| hreflang="uk" <----------+========================+
| hreflang="en" |
+--------------------------+Ключові сутності:
/es/, /uk/, /de/) з коректною географічною прив'язкою цін та валют без втрати пошукової ваги.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; // 'en', 'uk', 'es', 'x-default'
path: string; // '/en/catalog/shoes', '/uk/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, нарізає посилання блоками по 45 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 = ['en', 'uk', 'es'];
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}`);
}
}Сторінка A (/uk/catalog) посилається через hreflang на сторінку B (/en/catalog), але на сторінці B забули вказати зворотне посилання на сторінку A. Пошукові алгоритми Google суворо відбраковують такі зв'язки: обидві сторінки втрачають мовну диференціацію та починають конкурувати одна з одною у видачі.
Рішення: Завжди автоматизуйте генерацію hreflang на рівні централізованої схеми або sitemap.xml, де взаємні посилання генеруються детерміновано з одного кортежу даних.
Помилкова вказівка коду країни замість мови (наприклад, hreflang="uk-UA" коректно, але hreflang="UA" невалідно; hreflang="uk" означає українську мову, а не Сполучене Королівство United Kingdom, чий код en-GB).
Рішення: Завжди дотримуйтесь специфікації BCP 47: спочатку код мови ISO 639-1 (два рядкові символи), потім опціональний дефіс та код регіону ISO 3166-1 Alpha 2 (два заголовні символи): uk, en-US, es-ES.
Якщо сайт має версії для Німеччини (de-DE) та Австрії (de-AT), але не визначає x-default, користувач зі Швейцарії або США не має явного цільового URL, і пошуковик випадковим чином ранжує будь-яку версію.
Рішення: Обов'язково оголошуйте x-default, що веде на нейтральний селектор країн або на основну міжнародну версію сторінки.
Розміщення 60 000 посилань в один файл sitemap.xml або перевищення розміру файлу в 50 МБ (до стиснення) призводить до повної відмови краулерів Google та Bing парсити цей файл.
Рішення: Нарізайте карти сайту на чанки до 45 000 URL та об'єднуйте їх через батьківський файл <sitemapindex>.
Скрипт збирання запитує актуальний список опублікованих сутностей з усіма мовними слагами.
Для кожної сторінки формується повний набір альтернативних адрес з валідацією за стандартом BCP 47.
Масив посилань розбивається на файли до 45 000 рядків. Генерується кореневий sitemapindex.
Дельта оновлених за комміт URL серіалізується в JSON і відправляється в IndexNow API.