Выбор подхода к локализации
Для проекта, который изначально был рассчитан на корейскую аудиторию, необходимо было быстро расширить покрытие до США, Японии, Китая, Вьетнама и Индии. Ключевой задачей стал переход от жёстко зашитых строк к системному решению, способному обслуживать как серверные, так и клиентские компоненты. В рамках Next.js 15 с App Router оптимальным вариантом оказался пакет next‑intl, который предоставляет готовый набор хуков и интеграцию с динамическими сегментами маршрутов.
Структура проекта и конфигурация next‑intl
Организация файловой системы была построена вокруг динамического сегмента [locale], куда переместили все страницы приложения:
apps/web/
├─ app/
│ ├─ [locale]/ ← все роуты находятся здесь
│ │ ├─ page.tsx
│ │ ├─ result/
│ │ │ └─ page.tsx
│ │ └─ layout.tsx ← задаёт <html lang={locale}> и <body>
│ └─ layout.tsx ← пустой «прокси», просто передаёт children
├─ i18n/
│ ├─ config.ts ← список locales и defaultLocale
│ ├─ routing.ts ← localePrefix: "as-needed"
│ └─ navigation.ts ← компоненты Link и useRouter с учётом i18n
├─ messages/
│ ├─ ko.json
│ ├─ en.json
│ ├─ ja.json
│ ├─ zh.json
│ ├─ vi.json
│ └─ hi.json
└─ middleware.ts ← автоопределение языка по заголовку Accept‑Language
config.tsзадаёт массив поддерживаемых локалей (["ko", "en", "ja", "zh", "vi", "hi"]) и определяет корейский язык какdefaultLocale.routing.tsуправляет префиксами:localePrefix: "as-needed"означает, что для дефолтной локали префикс в URL опускается (корейский/вместо/ko/), а остальные языки получают собственный сегмент (/en/,/ja/и т.д.).- Файлы
*.jsonв каталогеmessagesсодержат переводы всех пользовательских строк. Каждый ключ используется в коде через хукuseTranslations().
Динамические сегменты и автоматическое перенаправление
Middleware читает заголовок Accept-Language из запроса и, если пользователь попадает на корневой путь /, автоматически перенаправляет его на соответствующий язык, учитывая localePrefix. Благодаря тому, что App Router поддерживает динамические сегменты, достаточно одного [locale]‑папки — Next.js сам сопоставит значение locale с параметром params.locale и передаст его в компоненты.
// middleware.ts
import { NextResponse } from 'next/server';
import { matchLocale } from './i18n/routing';
export function middleware(request) {
const { pathname } = request.nextUrl;
if (pathname === '/') {
const locale = matchLocale(request.headers.get('accept-language'));
return NextResponse.redirect(new URL(`/${locale}`, request.url));
}
}
Такой подход устраняет необходимость ручного управления редиректами в каждом роуте и гарантирует, что пользователь сразу увидит контент на своём языке.
Работа с HTML‑layout в App Router
App Router требует, чтобы корневой layout.tsx рендерил теги <html> и <body>. Первоначальная попытка разместить их одновременно в app/layout.tsx и в app/[locale]/layout.tsx привела к вложенному <html> (HTML‑inside‑HTML). Браузеры игнорируют вторую обёртку, но полученный DOM был некорректным, что могло вызвать проблемы с SEO и скриншотами.
Решение заключалось в том, чтобы убрать теги из общего layout.tsx и оставить их исключительно в локализованном layout:
// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
// Прокси без html/body
return <>{children}</>;
}
// app/[locale]/layout.tsx
export default function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: { locale: string };
}) {
const { locale } = params;
return (
<html lang={locale}>
<body>{children}</body>
</html>
);
}
Теперь Next.js получает обязательную структуру из LocaleLayout, а RootLayout служит лишь транспортным слоем, позволяющим добавлять глобальные провайдеры (например, ThemeProvider) без дублирования HTML‑тегов.
Типичные подводные камни и их решения
-
Потеря контекста при переходе между серверными и клиентскими компонентами
next‑intlпредоставляет один и тот же хукuseTranslations()для обеих сред, однако важно импортировать его изnext-intlи убедиться, чтоmessagesпередаются вNextIntlProviderтолько один раз на уровнеLocaleLayout. -
Несоответствие названий файлов локалей
Файлы JSON должны точно соответствовать кодам локалей, указанным вconfig.ts. Любой несоответствующий ключ приводит к падению при серверном рендеринге. -
Кеширование переводов
При работе с динамически генерируемыми страницами рекомендуется включитьrevalidateвgetStaticProps/generateStaticParams, чтобы новые переводы сразу становились доступными без полной пересборки. -
Обработка fallback‑языка
Если пользователь выбирает язык, который не поддерживается, middleware возвращаетdefaultLocale. Это гарантирует отсутствие 404‑ошибок из‑за неизвестных сегментов. -
Тестирование правой установки
langатрибута
Инструменты Lighthouse проверяют, чтоlangв корневом<html>соответствует текущей локали. Ошибки в этом атрибуте могут ухудшить оценку доступности.
В результате за один день удалось превратить монолитное приложение с жёстко зашитыми корейскими строками в полностью локализованную платформу, поддерживающую шесть языков, автоматическое определение языка браузера и корректную структуру HTML‑разметки. Такой подход масштабируем и легко расширяется новыми локалями без изменения бизнес‑логики.