У розподіленій веб-архітектурі один і той самий контент часто стає доступним за безліччю різних URL-адрес. Це трапляється через маркетингові UTM-мітки (?utm_source=telegram), параметри фасетної фільтрації та сортування (?sort=price_asc&filter=instock), ідентифікатори сесій користувача чи системні розбіжності протоколів (http проти https, www проти non-www). Якщо пошукові роботи (Googlebot, Bingbot, YandexBot) виявляють кілька ідентичних документів за різними адресами, виникає загроза потрапити під Duplicate Content Penalty.
Для однозначного визначення першоджерела стандарт RFC 6596 ввів директиву rel="canonical". Канонічний тег повідомляє пошуковим системам, яка саме адреса є еталонною, консолідуючи всі показники авторитетності та посилальної ваги (Link Equity / PageRank) на єдиному master-URL.
Водночас міжнародні платформи стикаються із задачею регіональної кластеризації контенту: користувачі з різних країн повинні отримувати інтерфейс рідною мовою без канібалізації пошукового трафіку між мовними версіями того самого домену. Цей сценарій стандартизовано директивою hreflang (стандарти ISO 639-1 для мови та ISO 3166-1 Alpha-2 для регіону).
У Next.js 15 обидві задачі вирішуються декларативно через властивість alternates у Metadata API, яка безпосередньо взаємодіє зі структурою маршрутизації app/[locale]/... та системними налаштуваннями нормалізації URL у next.config.ts.
Metadata API абстрагує створення низькорівневих тегів <link rel="canonical"> та <link rel="alternate" hreflang="..."> через типізовану властивість alternates.
При генерації багатомовної матриці локалей у Next.js 15 кожен регіональний маршрут зобов'язаний згенерувати повний граф двосторонніх посилань між усіма доступними мовами та резервним посиланням x-default.
+-----------------------------------------------------------------------------------+
| Матриця перехресних зв'язків hreflang |
+-----------------------------------------------------------------------------------+
│ │
▼ (Канонічний URL: /uk-ua/courses) ▼ (Канонічний URL: /en-us/courses)
+------------------------------------------+ +------------------------------------------+
| Сторінка локалі: UK-UA | | Сторінка локалі: EN-US |
| | | |
| <link rel="canonical" | | <link rel="canonical" |
| href="https://.../uk-ua/courses"> | | href="https://.../en-us/courses"> |
| | | |
| <link rel="alternate" hreflang="uk-UA" |◄─────►| <link rel="alternate" hreflang="uk-UA" |
| href="https://.../uk-ua/courses"> | | href="https://.../uk-ua/courses"> |
| | | |
| <link rel="alternate" hreflang="en-US" |◄─────►| <link rel="alternate" hreflang="en-US" |
| href="https://.../en-us/courses"> | | href="https://.../en-us/courses"> |
| | | |
| <link rel="alternate" |◄─────►| <link rel="alternate" |
| hreflang="x-default" | | hreflang="x-default" |
| href="https://.../en-us/courses"> | | href="https://.../en-us/courses"> |
+------------------------------------------+ +------------------------------------------+app/[locale]/... з адресною доставкою інтерфейсу для мовних кластерів (uk-UA, en-US, es-ES, de-DE) та запобіганням канібалізації у видачі./catalog?price_min=100&sort=asc) на базову URL-адресу категорії (/catalog), що усуває мільйони дублікатів.canonical: '/blog?page=2') для кожної сторінки пагінації відповідно до вимог Google Search Central (замість склеювання всієї пагінації на першу сторінку).trailingSlash у next.config.ts для запобігання нескінченним циклам 308-редиректів.next.config.ts)#У next.config.ts встановлюється політика обробки завершальних слешів. Якщо вказано trailingSlash: false, Next.js автоматично здійснює HTTP 308 Permanent Redirect з /catalog/ на /catalog. Канонічні посилання повинні суворо відповідати цим правилам.
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
// false: нормалізація адрес БЕЗ кінцевого слеша (/blog/post)
// true: нормалізація адрес ІЗ кінцевим слешем (/blog/post/)
trailingSlash: false,
// Вимкнення службових заголовків та увімкнення суворого режиму
poweredByHeader: false,
reactStrictMode: true,
};
export default nextConfig;lib/seo/alternates.ts)#Створимо типізовану утиліту для розрахунку канонічних адрес та повної матриці hreflang:
export const SUPPORTED_LOCALES = ['uk-ua', 'en-us', 'es-es', 'de-de'] as const;
export type AppLocale = (typeof SUPPORTED_LOCALES)[number];
export const DEFAULT_LOCALE: AppLocale = 'en-us';
interface AlternatesConfigOptions {
pathname: string; // Відносний шлях без префікса локалі, наприклад: '/courses/nextjs-15'
currentLocale: AppLocale;
}
export function buildLocalizedAlternates({
pathname,
currentLocale,
}: AlternatesConfigOptions) {
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://agenticlab.dev';
// Очищення зайвих слешів на початку та в кінці рядка
const cleanPath = pathname.replace(/^\/+|\/+$/g, '');
// 1. Формування канонічної адреси для активної локалі
const canonicalPath = cleanPath ? `/${currentLocale}/${cleanPath}` : `/${currentLocale}`;
const canonicalUrl = `${baseUrl}${canonicalPath}`;
// 2. Генерація перехресних мовних зв'язків для всіх підтримуваних локалей
const languages: Record<string, string> = {};
for (const locale of SUPPORTED_LOCALES) {
const localePath = cleanPath ? `/${locale}/${cleanPath}` : `/${locale}`;
languages[locale] = `${baseUrl}${localePath}`;
}
// 3. Додавання резервного x-default (за замовчуванням глобальна англійська версія)
const defaultPath = cleanPath ? `/${DEFAULT_LOCALE}/${cleanPath}` : `/${DEFAULT_LOCALE}`;
languages['x-default'] = `${baseUrl}${defaultPath}`;
return {
canonical: canonicalUrl,
languages,
};
}app/[locale]/courses/[slug]/page.tsx)#У динамічному сегменті отримуємо поточну локаль та ідентифікатор курсу, збираючи взаємні канонічні метадані:
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import {
buildLocalizedAlternates,
SUPPORTED_LOCALES,
type AppLocale,
} from '@/lib/seo/alternates';
interface Props {
params: Promise<{
locale: string;
slug: string;
}>;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale, slug } = await params;
// Перевірка підтримки запитуваної локалі
if (!SUPPORTED_LOCALES.includes(locale as AppLocale)) {
notFound();
}
const typedLocale = locale as AppLocale;
// Побудова канонічної адреси та симетричної матриці hreflang
const alternates = buildLocalizedAlternates({
pathname: `/courses/${slug}`,
currentLocale: typedLocale,
});
return {
title: `Архітектура Next.js 15 (${typedLocale.toUpperCase()})`,
description: 'Глибокий інженерний курс: Metadata API, серверні компоненти та оптимізація Core Web Vitals.',
// Блок alternates для пошукових краулерів
alternates: {
canonical: alternates.canonical,
languages: alternates.languages,
},
openGraph: {
title: 'Курс Next.js 15: Архітектура та SEO',
url: alternates.canonical,
locale: typedLocale.replace('-', '_'),
},
};
}
export default async function CoursePage({ params }: Props) {
const { locale, slug } = await params;
return (
<article className="max-w-4xl mx-auto py-10 px-4">
<header>
<span className="text-xs uppercase font-mono text-accent-cyan">
Локаль: {locale}
</span>
<h1 className="text-3xl font-bold mt-2">Курс: {slug}</h1>
</header>
<div className="mt-6 text-text-muted leading-relaxed">
Інтерактивний навчальний модуль із детальним аналізом вихідного коду.
</div>
</article>
);
}Найпоширеніша помилка міжнародної оптимізації: сторінка /uk-ua/about посилається через hreflang="en-us" на /en-us/about, проте на сторінці /en-us/about розробники забули вказати зворотне посилання на українську версію. Алгоритми Google вимагають суворої взаємної верифікації зв'язків. Якщо сторінка A вказує на B, але B не посилається у відповідь на A, Google Search Console відхиляє обидва зв'язки як невалідні.
Якщо в next.config.ts встановлено trailingSlash: false, а в метаданих канонічну адресу згенеровано зі слешем на кінці:
alternates: { canonical: 'https://agenticlab.dev/courses/' },
виникає фатальний конфлікт: пошуковий робот переходить за вказаним URL, сервер Next.js повертає статус 308 Permanent Redirect на версію без слеша /courses, а сторінка без слеша знову заявляє, що канонічною є версія зі слешем. Такий циклічний редирект призводить до повної деіндексації сторінки.
Поширена помилка в каталогах: встановлення canonical: '/catalog' для всіх сторінок пагінації (/catalog?page=2, /catalog?page=3). Це дає сигнал пошуковому роботу сприймати наступні сторінки як повні копії першої. У результаті бот перестає обходити посилання на товари, розташовані глибше першої сторінки, і вони випадають з індексу. Кожна сторінка пагінації повинна мати власний канонічний URL: canonical: '/catalog?page=2'.
Якщо ресурс підтримує локалі для Німеччини (de-de) та Франції (fr-fr), а відвідувач заходить з Японії чи Бразилії, без атрибута hreflang="x-default" пошукова система не зможе визначити, яку саме версію показувати у видачі. Властивість x-default обов'язково повинна бути присутня в об'єкті languages і вказувати на міжнародну за замовчуванням (як правило, англомовну) сторінку.
Сервер відокремлює чистий шлях від параметрів пагінації та маркетингових міток (utm).
Здійснюється перевірка відповідності шляху політиці скісних рисок у next.config.ts із поверненням 308 редиректу.
Генератор формує повне абсолютне FQDN-посилання, очищене від непотрібних query-параметрів.
Для всіх підтримуваних мов формується повний набір перехресних зв'язків із обов'язковим x-default.
Браузер та робот отримують розмітку rel="canonical" та rel="alternate" у початковому документі.