# Интернационализация (i18n) в Next.js 15

URL: https://dmeshcheryakov.ru/blog/nextjs-i18n
Раздел: NextJS
Теги: NextJS, React, i18n
Опубликовано: 2026-09-16
Обновлено: 2026-09-07
Автор: Дмитрий Мещеряков (https://dmeshcheryakov.ru)

> Мультиязычность в App Router: роутинг, переводы, форматирование дат и чисел. next-intl и альтернативы.

Мультиязычность — задача, где техническая часть составляет меньшую половину работы. Настроить `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:

```bash
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

```typescript
// 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

```typescript
// 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

```typescript
// next.config.ts
import createNextIntlPlugin from 'next-intl/plugin';

const withNextIntl = createNextIntlPlugin();

const nextConfig = {
  // Ваши настройки
};

export default withNextIntl(nextConfig);
```

## Файлы переводов

### messages/ru.json

```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

```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 с локалью

```tsx
// 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 с переводами

```tsx
// 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 с переводами

```tsx
// 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>
  );
}
```

## Переключатель языка

```tsx
// 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>
  );
}
```

## Локализованные ссылки

```tsx
// 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` разрастается до сотен ключей, и если он загружается целиком на каждую страницу, вы платите за это временем загрузки — причём на всех языках сразу, если не настроено разделение.

Проверьте на реальной сборке, что клиенту уезжают только нужные пространства имён и только текущая локаль. Для крупных проектов переводы разделяют по разделам сайта, а не держат одним файлом.

## Форматирование

### Даты

```tsx
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"
```

### Числа и валюта

```tsx
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"
```

### Относительное время

```tsx
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 теги

```tsx
// 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 с локалями

```typescript
// 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}`,
          ])
        ),
      },
    }))
  );
}
```

## Типизация переводов

```typescript
// types/i18n.d.ts
import ru from '../messages/ru.json';

type Messages = typeof ru;

declare global {
  interface IntlMessages extends Messages {}
}
```

Теперь TypeScript подскажет ключи переводов:

```tsx
const t = useTranslations('home');
t('hero.title'); // ✅ Работает
t('hero.nonexistent'); // ❌ Ошибка типов
```

## Итоги

Чеклист мультиязычного сайта:

- [x] next-intl настроен
- [x] Middleware для определения локали
- [x] Файлы переводов структурированы
- [x] Переключатель языка
- [x] Локализованные ссылки
- [x] Форматирование дат/чисел
- [x] Hreflang теги
- [x] Sitemap с локалями
- [x] Типизация переводов

## Что обычно недооценивают

**Динамический контент — это не «отдельная задача», это основная задача.** Интерфейсных строк на сайте сотни, а товаров и статей — тысячи. Переводить их через файлы локализации невозможно: нужна поддержка на уровне модели данных (перевод как поле сущности), интерфейс для контент-менеджеров и понимание, что делать со страницей, для которой перевода ещё нет. Показать оригинал? Отдать 404? Скрыть из меню? Ответ разный для разных проектов, и решать это нужно до начала работы, а не когда половина каталога окажется без перевода.

**`hreflang` должен быть взаимным и полным.** Каждая языковая версия страницы ссылается на все остальные, включая саму себя, плюс `x-default`. Односторонние ссылки поисковик игнорирует, и весь смысл разметки пропадает. Проверять нужно на реальных страницах, а не в шаблоне: обычно ошибка находится там, где для конкретной страницы перевода нет, а ссылка на него всё равно выводится.

**Автоопределение языка по заголовку браузера — спорное решение.** Оно кажется удобным, но ломает ожидания: пользователь открывает присланную коллегой ссылку на английскую страницу и попадает на русскую версию. И поисковый робот, приходящий без языковых предпочтений, видит не то, что нужно. Рабочий компромисс — предлагать переключение баннером, а не выполнять редирект принудительно.

**Переводы устаревают.** Текст на основном языке правят, переводы забывают — и через полгода английская версия рассказывает про прошлогодние условия доставки. Здесь помогает не код, а процесс: проверка полноты переводов в CI (ключ есть в основном языке, но отсутствует в других — ошибка сборки) снимает половину проблемы автоматически.

**Планируя мультиязычность, закладывайте не только разработку.** Перевод контента, его вычитка и последующее поддержание — постоянная работа, которую делают люди. Технически безупречный мультиязычный сайт с машинным переводом каталога выглядит хуже, чем его отсутствие.