Компонентная модель - это способ собирать интерфейс из переиспользуемых деталей: одна кнопка, один заголовок, один блок карточки описываются однажды и вставляются в сотни мест. У этого свойства две стороны. Правило, заданное в общем компоненте, мгновенно расходится по всему продукту - но точно так же расходится и ошибка. Один недоступный Button или один Link, который на самом деле не ссылка, ломает не одну страницу, а весь магазин.
Отсюда первый принцип: доступность встраивают в примитив, а не дописывают на каждом месте вызова. Примитив - это базовый компонент, поверх которого строится всё остальное. Кнопка-иконка без текста невидима для экранного диктора, поэтому её оборачивают так, чтобы имя было обязательным: атрибут aria-label несёт доступное имя, а сама иконка помечается aria-hidden, чтобы диктор не читал её дважды. Теперь ни один разработчик физически не может вставить безымянную иконку - типизация требует label.
Тот же принцип единого источника действует для метаданных и маршрутов. Компонент не должен сам угадывать свой URL или в одиночку решать, какие языковые версии у страницы есть. Иначе один шаблон проставит canonical с завершающим слэшем, другой - без, третий забудет hreflang, и поисковая система увидит хаос из противоречивых сигналов. Описание маршрута должно быть одно, серверное, и из него уже выводятся все ссылки.
В Next.js это делает функция generateMetadata - она выполняется на сервере и возвращает метаданные страницы до отправки HTML. Внутри объекта alternates поле canonical указывает на саму страницу (self-canonical), а languages перечисляет альтернативные языковые версии с их hreflang-кодами, включая x-default для читателя, чей язык не совпал ни с одним. Взаимность обязательна: если арабская версия ссылается на английскую, английская обязана ссылаться на арабскую - иначе связка распадается. В актуальном App Router params приходит как промис, поэтому его дожидаются через await.
Маршрутизация тоже строится вокруг локали. Сегмент [locale] в начале пути даёт каждому языку свой префикс - /en, /ru, /ar - и свою индексируемую страницу. Библиотека next-intl централизует конфигурацию через defineRouting (список локалей и локаль по умолчанию) и createNavigation (типобезопасные Link и навигацию), а вызов setRequestLocale в каждом layout и page позволяет странице рендериться статически, а не скатываться в динамику. Папки повторяют ту же идею: сообщения переводов, слой i18n, слой метаданных и слой доступности вынесены отдельно, чтобы правило жило в одном месте.
app/
[locale]/
layout.tsx # lang, dir, providers
page.tsx
products/
[slug]/page.tsx
sitemap.ts
robots.ts
messages/
en.json
ru.json
lib/
i18n/
metadata/
accessibility/Структурированные данные встают в тот же серверный слой. Разметку schema.org в формате JSON-LD - объект с типом Product, названием, ценой и наличием - удобно отрисовать прямо в серверном компоненте тегом script с type application/ld+json, взяв те же данные о товаре, из которых сложены заголовок и цена на странице. Так подпись, которую видит человек, и данные, которые читает робот, происходят из одного источника и не расходятся.
Провал в компонентной архитектуре всегда тиражный. Клиентский компонент сам собрал ссылку на другую локаль, ошибся в префиксе - и разом сломал hreflang на всех карточках товара. Или в один IconButton забыли передать label, и каждая кнопка Купить во всём магазине осталась для диктора просто кнопка. Лечится это не обходом страниц перед релизом, а тем, что имя, URL и метаданные заданы один раз в примитиве и в серверном описании маршрута - тогда исправление тоже расходится по всему продукту разом.
export function IconButton({
label,
children,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
label: string;
}) {
return (
<button type="button" aria-label={label} {...props}>
<span aria-hidden="true">{children}</span>
</button>
);
}export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}): Promise<Metadata> {
const { locale, slug } = await params;
const path = `/products/${slug}`;
return {
alternates: {
canonical: `https://shop.example/${locale}${path}`,
languages: {
en: `https://shop.example/en${path}`,
ru: `https://shop.example/ru${path}`,
ar: `https://shop.example/ar${path}`,
"x-default": `https://shop.example/en${path}`,
},
},
};
}