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

30 июля 2026
Автор

Антон Ларичев

Что такое i18n и зачем это нужно

Интернационализация (i18n — сокращение от «internationalization», где 18 — количество букв между «i» и «n») — это процесс проектирования приложения таким образом, чтобы оно могло адаптироваться к разным языкам и регионам без изменения исходного кода.

Для веб-приложений i18n включает:

  • отображение текстов на языке пользователя;
  • форматирование дат, чисел и валют по региональным стандартам;
  • правильную маршрутизацию с учётом локали (например, /ru/about и /en/about);
  • SEO-оптимизацию для каждого языка через hreflang.

Next.js предоставляет встроенную поддержку i18n через App Router, которая основана на сегменте [locale] в структуре папок и middleware для автоматического определения языка пользователя.

Курсы PurpleSchool

Учитесь у практикующих разработчиков — без воды

40 000+ студентов · рейтинг 4.8 · гарантия возврата 30 дней

Структура проекта для мультиязычного приложения

В App Router подход к i18n строится на вложении всех страниц в динамический сегмент [locale]:

app/
  [locale]/
    layout.tsx
    page.tsx
    about/
      page.tsx
    blog/
      [slug]/
        page.tsx
middleware.ts
dictionaries/
  ru.json
  en.json

Каждая страница получает доступ к текущей локали через параметр params.locale, а middleware перехватывает запросы и перенаправляет пользователя на нужную версию.

Настройка middleware

Middleware — ключевой компонент i18n-системы. Он определяет предпочтительный язык пользователя из заголовка Accept-Language и редиректит на корректный URL.

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';

const LOCALES = ['ru', 'en'] as const;
const DEFAULT_LOCALE = 'ru';

type Locale = typeof LOCALES[number];

function getLocaleFromRequest(request: NextRequest): Locale {
  const acceptLanguage = request.headers.get('Accept-Language');
  if (!acceptLanguage) return DEFAULT_LOCALE;

  const preferred = acceptLanguage
    .split(',')
    .map((lang) => lang.split(';')[0].trim().slice(0, 2))
    .find((lang) => LOCALES.includes(lang as Locale));

  return (preferred as Locale) ?? DEFAULT_LOCALE;
}

export function middleware(request: NextRequest) {
  const pathname = request.nextUrl.pathname;

  // Пропускаем статические файлы и API-роуты
  if (
    pathname.startsWith('/_next') ||
    pathname.startsWith('/api') ||
    pathname.includes('.')
  ) {
    return NextResponse.next();
  }

  // Проверяем, есть ли уже локаль в пути
  const pathnameHasLocale = LOCALES.some(
    (locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
  );

  if (pathnameHasLocale) return NextResponse.next();

  // Редиректим на путь с локалью
  const locale = getLocaleFromRequest(request);
  const newUrl = new URL(`/${locale}${pathname}`, request.url);
  return NextResponse.redirect(newUrl);
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};

Middleware проверяет каждый входящий запрос: если в URL уже есть префикс локали — пропускает его, если нет — определяет язык из заголовков браузера и выполняет редирект.

Создание словарей переводов

Словари — это JSON-файлы, которые хранят переведённые строки для каждого языка:

// dictionaries/ru.json
{
  "common": {
    "home": "Главная",
    "about": "О нас",
    "contact": "Контакты",
    "loading": "Загрузка..."
  },
  "home": {
    "title": "Добро пожаловать",
    "description": "Это мультиязычное приложение на Next.js",
    "cta": "Начать работу"
  },
  "blog": {
    "title": "Блог",
    "readMore": "Читать далее",
    "postedOn": "Опубликовано"
  }
}
// dictionaries/en.json
{
  "common": {
    "home": "Home",
    "about": "About",
    "contact": "Contact",
    "loading": "Loading..."
  },
  "home": {
    "title": "Welcome",
    "description": "This is a multilingual Next.js application",
    "cta": "Get started"
  },
  "blog": {
    "title": "Blog",
    "readMore": "Read more",
    "postedOn": "Posted on"
  }
}

Утилита для загрузки словаря

Чтобы не дублировать логику загрузки, создаём вспомогательную функцию:

// lib/dictionaries.ts
import type { Locale } from './types';

const dictionaries = {
  ru: () => import('../dictionaries/ru.json').then((m) => m.default),
  en: () => import('../dictionaries/en.json').then((m) => m.default),
};

export async function getDictionary(locale: Locale) {
  const loader = dictionaries[locale];
  if (!loader) {
    throw new Error(`Dictionary not found for locale: ${locale}`);
  }
  return loader();
}
// lib/types.ts
export type Locale = 'ru' | 'en';
export const LOCALES: Locale[] = ['ru', 'en'];
export const DEFAULT_LOCALE: Locale = 'ru';

Динамический import() позволяет Next.js подгружать только нужный языковой файл, не увеличивая бандл без необходимости.

Layout с поддержкой локали

Корневой layout для [locale] устанавливает атрибут lang на теге <html> и передаёт словарь дочерним компонентам через props или Context:

// app/[locale]/layout.tsx
import { LOCALES } from '@/lib/types';
import type { Locale } from '@/lib/types';

interface LayoutProps {
  children: React.ReactNode;
  params: Promise<{ locale: Locale }>;
}

export async function generateStaticParams() {
  return LOCALES.map((locale) => ({ locale }));
}

export default async function LocaleLayout({ children, params }: LayoutProps) {
  const { locale } = await params;

  return (
    <html lang={locale}>
      <body>{children}</body>
    </html>
  );
}

Функция generateStaticParams сообщает Next.js, какие локали нужно статически сгенерировать при сборке.

Использование переводов на страницах

Серверные компоненты получают словарь напрямую через await:

// app/[locale]/page.tsx
import { getDictionary } from '@/lib/dictionaries';
import type { Locale } from '@/lib/types';

interface PageProps {
  params: Promise<{ locale: Locale }>;
}

export default async function HomePage({ params }: PageProps) {
  const { locale } = await params;
  const dict = await getDictionary(locale);

  return (
    <main>
      <h1>{dict.home.title}</h1>
      <p>{dict.home.description}</p>
      <a href={`/${locale}/about`}>{dict.home.cta}</a>
    </main>
  );
}

Клиентские компоненты и переводы

Для клиентских компонентов словарь нужно передать через props, так как они не могут вызывать async-функции напрямую:

// components/Navigation.tsx
'use client';

import Link from 'next/link';
import { usePathname } from 'next/navigation';
import type { Locale } from '@/lib/types';

interface NavDict {
  home: string;
  about: string;
  contact: string;
}

interface NavigationProps {
  locale: Locale;
  dict: NavDict;
}

export function Navigation({ locale, dict }: NavigationProps) {
  const pathname = usePathname();

  const links = [
    { href: `/${locale}`, label: dict.home },
    { href: `/${locale}/about`, label: dict.about },
    { href: `/${locale}/contact`, label: dict.contact },
  ];

  return (
    <nav>
      <ul>
        {links.map(({ href, label }) => (
          <li key={href}>
            <Link
              href={href}
              aria-current={pathname === href ? 'page' : undefined}
            >
              {label}
            </Link>
          </li>
        ))}
      </ul>
    </nav>
  );
}

Родительский серверный компонент загружает словарь и передаёт его клиентскому компоненту:

// app/[locale]/layout.tsx (дополненная версия)
import { getDictionary } from '@/lib/dictionaries';
import { Navigation } from '@/components/Navigation';
import { LOCALES } from '@/lib/types';
import type { Locale } from '@/lib/types';

export async function generateStaticParams() {
  return LOCALES.map((locale) => ({ locale }));
}

export default async function LocaleLayout({
  children,
  params,
}: {
  children: React.ReactNode;
  params: Promise<{ locale: Locale }>;
}) {
  const { locale } = await params;
  const dict = await getDictionary(locale);

  return (
    <html lang={locale}>
      <body>
        <Navigation locale={locale} dict={dict.common} />
        {children}
      </body>
    </html>
  );
}

Переключение языка

Компонент переключения языка должен переводить пользователя на ту же страницу, но с другой локалью:

// components/LocaleSwitcher.tsx
'use client';

import { usePathname, useRouter } from 'next/navigation';
import type { Locale } from '@/lib/types';
import { LOCALES } from '@/lib/types';

const LOCALE_LABELS: Record<Locale, string> = {
  ru: 'Русский',
  en: 'English',
};

interface LocaleSwitcherProps {
  currentLocale: Locale;
}

export function LocaleSwitcher({ currentLocale }: LocaleSwitcherProps) {
  const pathname = usePathname();
  const router = useRouter();

  function switchLocale(newLocale: Locale) {
    // Заменяем текущую локаль в пути на новую
    const segments = pathname.split('/');
    segments[1] = newLocale;
    router.push(segments.join('/'));
  }

  return (
    <div>
      {LOCALES.map((locale) => (
        <button
          key={locale}
          onClick={() => switchLocale(locale)}
          disabled={locale === currentLocale}
          aria-label={`Switch to ${LOCALE_LABELS[locale]}`}
        >
          {LOCALE_LABELS[locale]}
        </button>
      ))}
    </div>
  );
}

SEO: метаданные и hreflang

Для корректной индексации поисковыми системами необходимо добавить hreflang-теги и локализованные метаданные:

// app/[locale]/page.tsx с метаданными
import { getDictionary } from '@/lib/dictionaries';
import type { Locale } from '@/lib/types';
import type { Metadata } from 'next';
import { LOCALES } from '@/lib/types';

interface PageProps {
  params: Promise<{ locale: Locale }>;
}

const BASE_URL = 'https://example.com';

export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
  const { locale } = await params;
  const dict = await getDictionary(locale);

  const alternates: Record<string, string> = {};
  LOCALES.forEach((loc) => {
    alternates[loc] = `${BASE_URL}/${loc}`;
  });

  return {
    title: dict.home.title,
    description: dict.home.description,
    alternates: {
      languages: alternates,
      canonical: `${BASE_URL}/${locale}`,
    },
  };
}

export default async function HomePage({ params }: PageProps) {
  const { locale } = await params;
  const dict = await getDictionary(locale);

  return (
    <main>
      <h1>{dict.home.title}</h1>
      <p>{dict.home.description}</p>
    </main>
  );
}

Next.js автоматически рендерит теги <link rel="alternate" hreflang="..."> в <head> на основе объекта alternates.languages.

Форматирование дат и чисел

Локализация — это не только переводы. Даты, числа и валюты тоже нужно форматировать по региональным стандартам:

// lib/formatters.ts
import type { Locale } from './types';

export function formatDate(date: Date, locale: Locale): string {
  return new Intl.DateTimeFormat(locale, {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
  }).format(date);
}

export function formatPrice(amount: number, locale: Locale): string {
  const currency = locale === 'ru' ? 'RUB' : 'USD';
  return new Intl.NumberFormat(locale, {
    style: 'currency',
    currency,
  }).format(amount);
}

export function formatNumber(value: number, locale: Locale): string {
  return new Intl.NumberFormat(locale).format(value);
}

Использование в компоненте:

// app/[locale]/blog/[slug]/page.tsx
import { getDictionary } from '@/lib/dictionaries';
import { formatDate } from '@/lib/formatters';
import type { Locale } from '@/lib/types';

export default async function BlogPostPage({
  params,
}: {
  params: Promise<{ locale: Locale; slug: string }>;
}) {
  const { locale, slug } = await params;
  const dict = await getDictionary(locale);

  // Имитация получения данных поста
  const post = { title: 'Заголовок', publishedAt: new Date('2024-06-15') };

  return (
    <article>
      <h1>{post.title}</h1>
      <time dateTime={post.publishedAt.toISOString()}>
        {dict.blog.postedOn} {formatDate(post.publishedAt, locale)}
      </time>
    </article>
  );
}

Обработка переменных в переводах

Часто в строках нужно вставлять динамические значения. Для этого используется простое соглашение с плейсхолдерами:

// dictionaries/ru.json
{
  "user": {
    "greeting": "Привет, {name}!",
    "itemsCount": "У вас {count} товаров в корзине"
  }
}
// lib/t.ts
export function t(template: string, variables: Record<string, string | number>): string {
  return Object.entries(variables).reduce(
    (result, [key, value]) => result.replace(`{${key}}`, String(value)),
    template
  );
}
// Использование
import { t } from '@/lib/t';

const greeting = t(dict.user.greeting, { name: 'Антон' });
// Результат: "Привет, Антон!"

Распространённые ошибки

Жёсткие пути без локали

Использование <Link href="/about"> вместо <Link href={/${locale}/about}> приведёт к потере локали при переходах. Всегда включайте текущую локаль в ссылки.

Отсутствие fallback для отсутствующих ключей

Если ключ перевода отсутствует в одном из словарей, приложение может упасть с ошибкой. Добавьте защитную функцию:

export function safeGet<T extends Record<string, unknown>>(
  obj: T,
  path: string,
  fallback = ''
): string {
  const value = path.split('.').reduce<unknown>((acc, key) => {
    if (acc && typeof acc === 'object') return (acc as Record<string, unknown>)[key];
    return undefined;
  }, obj);
  return typeof value === 'string' ? value : fallback;
}

Кэширование словарей

При большом числе запросов функция getDictionary будет вызываться на каждый запрос. Next.js автоматически кэширует результаты fetch, но для import() кэширование выполняется на уровне модульной системы Node.js — повторные вызовы не создают лишних запросов к файловой системе.

Итог

Мультиязычность в Next.js App Router строится на трёх компонентах: структура папок с [locale], middleware для перехвата и перенаправления запросов, и словари переводов. Такой подход полностью статически типизирован, хорошо масштабируется и не требует сторонних библиотек для базовых сценариев.

Для сложных проектов с множеством языков, плюрализацией (одно яблоко / два яблока / пять яблок) и форматированием рекомендуется рассмотреть библиотеку next-intl, которая надстраивается поверх описанного подхода и решает эти задачи декларативно.

Освоить Next.js с нуля до уверенного уровня, включая работу с роутингом, серверными компонентами и деплоем, можно на курсе Next.js от PurpleSchool.

Стрелочка влевоПерехват маршрутов (Intercepting Routes) в Next.js

Все гайды по Next-js

Открыть базу знаний

Отправить комментарий