Мультиязычность — задача, где техническая часть составляет меньшую половину работы. Настроить next-intl, разложить переводы по файлам и сделать переключатель языка — вечер. Дальше начинается то, что определяет успех: перевод динамического контента, корректная разметка для поисковиков и процесс поддержания переводов в актуальном состоянии.
Разберём настройку — и в конце то, что обычно недооценивают при планировании.
Подходы к i18n в Next.js
В App Router есть несколько стратегий:
| Подход | URL | Пример |
|---|---|---|
| Субдомен | ru.example.com | Сложная настройка DNS |
| Путь | example.com/ru/about | Рекомендуется |
| Query | example.com/about?lang=ru | Плохо для SEO |
Мы используем подход с путём — он лучше для SEO и проще в реализации.
Установка next-intl
Самая популярная библиотека для i18n в App Router:
npm install next-intlСтруктура проекта
app/
├── [locale]/
│ ├── layout.tsx
│ ├── page.tsx
│ ├── about/
│ │ └── page.tsx
│ └── blog/
│ └── page.tsx
├── globals.css
messages/
├── ru.json
├── en.json
middleware.ts
i18n.tsКонфигурация
i18n.ts
// i18n.ts
import { notFound } from 'next/navigation';
import { getRequestConfig } from 'next-intl/server';
export const locales = ['ru', 'en'] as const;
export const defaultLocale = 'ru' as const;
export type Locale = (typeof locales)[number];
export default getRequestConfig(async ({ locale }) => {
// Validate locale
if (!locales.includes(locale as Locale)) {
notFound();
}
return {
messages: (await import(`./messages/${locale}.json`)).default,
};
});middleware.ts
// middleware.ts
import createMiddleware from 'next-intl/middleware';
import { locales, defaultLocale } from './i18n';
export default createMiddleware({
locales,
defaultLocale,
localePrefix: 'as-needed', // Убирает /ru для дефолтной локали
});
export const config = {
matcher: [
// Пропускаем внутренние пути Next.js и статику
'/((?!api|_next|_vercel|.*\\..*).*)',
],
};next.config.ts
// next.config.ts
import createNextIntlPlugin from 'next-intl/plugin';
const withNextIntl = createNextIntlPlugin();
const nextConfig = {
// Ваши настройки
};
export default withNextIntl(nextConfig);Файлы переводов
messages/ru.json
{
"common": {
"home": "Главная",
"about": "Обо мне",
"blog": "Блог",
"services": "Услуги",
"contact": "Контакты"
},
"home": {
"hero": {
"title": "Пишу о {topic}, делюсь опытом",
"description": "Технические статьи о 1С-Битрикс, NextJS и веб-разработке"
},
"cta": {
"readMore": "Читать далее",
"contactMe": "Связаться"
}
},
"blog": {
"title": "Все статьи",
"readTime": "{minutes} мин чтения",
"publishedAt": "Опубликовано {date}"
},
"contact": {
"title": "Свяжитесь со мной",
"form": {
"name": "Ваше имя",
"email": "Email",
"message": "Сообщение",
"submit": "Отправить"
},
"success": "Сообщение отправлено!",
"error": "Произошла ошибка"
}
}messages/en.json
{
"common": {
"home": "Home",
"about": "About",
"blog": "Blog",
"services": "Services",
"contact": "Contact"
},
"home": {
"hero": {
"title": "Writing about {topic}, sharing experience",
"description": "Technical articles about 1C-Bitrix, NextJS and web development"
},
"cta": {
"readMore": "Read more",
"contactMe": "Get in touch"
}
},
"blog": {
"title": "All articles",
"readTime": "{minutes} min read",
"publishedAt": "Published {date}"
},
"contact": {
"title": "Contact me",
"form": {
"name": "Your name",
"email": "Email",
"message": "Message",
"submit": "Send"
},
"success": "Message sent!",
"error": "An error occurred"
}
}Компоненты
Layout с локалью
// app/[locale]/layout.tsx
import { NextIntlClientProvider } from 'next-intl';
import { getMessages, getTranslations } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { locales, type Locale } from '@/i18n';
import { Header } from '@/components/Header';
export function generateStaticParams() {
return locales.map((locale) => ({ locale }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: Locale }>;
}) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'common' });
return {
title: {
template: `%s | ${t('home')}`,
default: t('home'),
},
};
}
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: Locale }>;
}) {
const { locale } = await params;
if (!locales.includes(locale)) {
notFound();
}
const messages = await getMessages();
return (
<html lang={locale}>
<body>
<NextIntlClientProvider messages={messages}>
<Header />
{children}
</NextIntlClientProvider>
</body>
</html>
);
}Server Component с переводами
// app/[locale]/page.tsx
import { getTranslations } from 'next-intl/server';
import { Link } from '@/components/Link';
export default async function HomePage() {
const t = await getTranslations('home');
return (
<main>
<section className="hero">
<h1>
{t('hero.title', { topic: 'веб-разработке' })}
</h1>
<p>{t('hero.description')}</p>
<Link href="/contact">
{t('cta.contactMe')} →
</Link>
</section>
</main>
);
}Client Component с переводами
// components/ContactForm.tsx
'use client';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
export function ContactForm() {
const t = useTranslations('contact.form');
const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle');
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
// ...отправка формы
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
name="name"
placeholder={t('name')}
required
/>
<input
type="email"
name="email"
placeholder={t('email')}
required
/>
<textarea
name="message"
placeholder={t('message')}
required
/>
<button type="submit">{t('submit')}</button>
</form>
);
}Переключатель языка
// components/LocaleSwitcher.tsx
'use client';
import { useLocale } from 'next-intl';
import { usePathname, useRouter } from 'next/navigation';
import { locales } from '@/i18n';
const localeNames: Record<string, string> = {
ru: 'Русский',
en: 'English',
};
export function LocaleSwitcher() {
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
const switchLocale = (newLocale: string) => {
// Заменяем текущую локаль в пути
const segments = pathname.split('/');
segments[1] = newLocale;
router.push(segments.join('/'));
};
return (
<div className="locale-switcher">
{locales.map((loc) => (
<button
key={loc}
onClick={() => switchLocale(loc)}
className={locale === loc ? 'active' : ''}
aria-current={locale === loc ? 'true' : undefined}
>
{localeNames[loc]}
</button>
))}
</div>
);
}Локализованные ссылки
// components/Link.tsx
import NextLink from 'next/link';
import { useLocale } from 'next-intl';
interface LinkProps extends React.ComponentProps<typeof NextLink> {
href: string;
}
export function Link({ href, ...props }: LinkProps) {
const locale = useLocale();
// Добавляем локаль к внутренним ссылкам
const localizedHref = href.startsWith('/')
? `/${locale}${href}`
: href;
return <NextLink href={localizedHref} {...props} />;
}Файлы переводов попадают в бандл, и это стоит контролировать. По мере роста сайта messages/ru.json разрастается до сотен ключей, и если он загружается целиком на каждую страницу, вы платите за это временем загрузки — причём на всех языках сразу, если не настроено разделение.
Проверьте на реальной сборке, что клиенту уезжают только нужные пространства имён и только текущая локаль. Для крупных проектов переводы разделяют по разделам сайта, а не держат одним файлом.
Форматирование
Даты
import { useFormatter } from 'next-intl';
function ArticleDate({ date }: { date: Date }) {
const format = useFormatter();
return (
<time dateTime={date.toISOString()}>
{format.dateTime(date, {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</time>
);
}
// Результат:
// ru: "14 августа 2026"
// en: "August 14, 2026"Числа и валюта
import { useFormatter } from 'next-intl';
function Price({ value }: { value: number }) {
const format = useFormatter();
return (
<span>
{format.number(value, {
style: 'currency',
currency: 'RUB',
})}
</span>
);
}
// ru: "80 000 ₽"
// en: "RUB 80,000"Относительное время
import { useFormatter } from 'next-intl';
function RelativeTime({ date }: { date: Date }) {
const format = useFormatter();
return (
<span>
{format.relativeTime(date)}
</span>
);
}
// ru: "2 дня назад"
// en: "2 days ago"SEO для мультиязычности
Hreflang теги
// app/[locale]/layout.tsx
import { locales, defaultLocale } from '@/i18n';
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return {
alternates: {
canonical: `https://example.com/${locale}`,
languages: Object.fromEntries(
locales.map((l) => [
l,
`https://example.com/${l === defaultLocale ? '' : l}`,
])
),
},
};
}Sitemap с локалями
// app/sitemap.ts
import { MetadataRoute } from 'next';
import { locales, defaultLocale } from '@/i18n';
const SITE_URL = 'https://example.com';
export default function sitemap(): MetadataRoute.Sitemap {
const pages = ['', '/about', '/blog', '/services', '/contact'];
return pages.flatMap((page) =>
locales.map((locale) => ({
url: `${SITE_URL}${locale === defaultLocale ? '' : `/${locale}`}${page}`,
lastModified: new Date(),
changeFrequency: 'weekly' as const,
priority: page === '' ? 1 : 0.8,
alternates: {
languages: Object.fromEntries(
locales.map((l) => [
l,
`${SITE_URL}${l === defaultLocale ? '' : `/${l}`}${page}`,
])
),
},
}))
);
}Типизация переводов
// types/i18n.d.ts
import ru from '../messages/ru.json';
type Messages = typeof ru;
declare global {
interface IntlMessages extends Messages {}
}Теперь TypeScript подскажет ключи переводов:
const t = useTranslations('home');
t('hero.title'); // ✅ Работает
t('hero.nonexistent'); // ❌ Ошибка типовИтоги
Чеклист мультиязычного сайта:
- next-intl настроен
- Middleware для определения локали
- Файлы переводов структурированы
- Переключатель языка
- Локализованные ссылки
- Форматирование дат/чисел
- Hreflang теги
- Sitemap с локалями
- Типизация переводов
Что обычно недооценивают
Динамический контент — это не «отдельная задача», это основная задача. Интерфейсных строк на сайте сотни, а товаров и статей — тысячи. Переводить их через файлы локализации невозможно: нужна поддержка на уровне модели данных (перевод как поле сущности), интерфейс для контент-менеджеров и понимание, что делать со страницей, для которой перевода ещё нет. Показать оригинал? Отдать 404? Скрыть из меню? Ответ разный для разных проектов, и решать это нужно до начала работы, а не когда половина каталога окажется без перевода.
hreflang должен быть взаимным и полным. Каждая языковая версия страницы ссылается на все остальные, включая саму себя, плюс x-default. Односторонние ссылки поисковик игнорирует, и весь смысл разметки пропадает. Проверять нужно на реальных страницах, а не в шаблоне: обычно ошибка находится там, где для конкретной страницы перевода нет, а ссылка на него всё равно выводится.
Автоопределение языка по заголовку браузера — спорное решение. Оно кажется удобным, но ломает ожидания: пользователь открывает присланную коллегой ссылку на английскую страницу и попадает на русскую версию. И поисковый робот, приходящий без языковых предпочтений, видит не то, что нужно. Рабочий компромисс — предлагать переключение баннером, а не выполнять редирект принудительно.
Переводы устаревают. Текст на основном языке правят, переводы забывают — и через полгода английская версия рассказывает про прошлогодние условия доставки. Здесь помогает не код, а процесс: проверка полноты переводов в CI (ключ есть в основном языке, но отсутствует в других — ошибка сборки) снимает половину проблемы автоматически.
Планируя мультиязычность, закладывайте не только разработку. Перевод контента, его вычитка и последующее поддержание — постоянная работа, которую делают люди. Технически безупречный мультиязычный сайт с машинным переводом каталога выглядит хуже, чем его отсутствие.
Частые вопросы
Какой способ разделения языков выбрать в Next.js?
Локаль в пути: example.com/ru/about. Субдомены вроде ru.example.com требуют сложной настройки DNS, а query-параметр lang плох для SEO. Путь реализуется сегментом [locale] в структуре app и лучше индексируется. Параметр localePrefix в значении as-needed убирает префикс для языка по умолчанию.
Как настроить hreflang для мультиязычного сайта?
Через alternates.languages в generateMetadata: каждая языковая версия страницы должна ссылаться на все остальные, включая саму себя, плюс x-default. Односторонние ссылки поисковик игнорирует, и весь смысл разметки пропадает. Проверять нужно на реальных страницах, а не в шаблоне: типовая ошибка находится там, где для конкретной страницы перевода нет, а ссылка на него всё равно выводится.
Как переводить динамический контент — товары и статьи?
Не через файлы локализации: интерфейсных строк сотни, а товаров и статей тысячи. Нужна поддержка на уровне модели данных — перевод как поле сущности, интерфейс для контент-менеджеров и заранее принятое решение, что делать со страницей без перевода: показать оригинал, отдать 404 или скрыть из меню. Ответ разный для разных проектов, и решать это нужно до начала работы, а не когда половина каталога окажется без перевода.
Стоит ли определять язык автоматически по заголовку браузера?
Это спорное решение. Оно кажется удобным, но ломает ожидания: пользователь открывает присланную коллегой ссылку на английскую страницу и попадает на русскую версию. И поисковый робот, приходящий без языковых предпочтений, видит не то, что нужно. Рабочий компромисс — предлагать переключение баннером, а не выполнять редирект принудительно.
Как не дать файлам переводов раздуть бандл?
Проверить на реальной сборке, что клиенту уезжают только нужные пространства имён и только текущая локаль. По мере роста сайта файл переводов разрастается до сотен ключей, и если он загружается целиком на каждую страницу, вы платите за это временем загрузки, причём на всех языках сразу, если не настроено разделение. Для крупных проектов переводы разделяют по разделам сайта, а не держат одним файлом.
Как поддерживать переводы в актуальном состоянии?
Здесь помогает не код, а процесс. Текст на основном языке правят, переводы забывают — и через полгода английская версия рассказывает про прошлогодние условия доставки. Проверка полноты переводов в CI, когда ключ есть в основном языке и отсутствует в других и это считается ошибкой сборки, снимает половину проблемы автоматически. Остальное — перевод, вычитка и поддержание, которые делают люди: это постоянная работа, и закладывать её нужно наравне с разработкой.