Разработка · 6 мин чтения

Многоязычный лендинг на Next.js: настройка i18n с нуля

Многоязычный лендинг на Next.js с нормальной i18n-архитектурой - это не пара строк в next.config.js, а решение о роутинге, хранении переводов и SEO, которое потом сложно переделать, не потеряв позиции в поиске. За последний год собирал такие лендинги для онлайн-курсов, SaaS-продуктов и одного B2B-сервиса с аудиторией в СНГ и Европе - ниже раскладываю процесс так, как настраиваю сам: от структуры папок до hreflang в метаданных и переключателя языка, который не сбрасывает выбор пользователя при каждом переходе.

Зачем лендингу мультиязычность и какой роутинг выбрать

Лендинг редко нужен на одном языке, если аудитория хоть немного шире одной страны. Продаёте курс для русскоязычных и англоязычных студентов, тестируете продукт на европейском рынке параллельно с СНГ, готовите сайт под инвестора, который не читает по-русски - в каждом случае проще завести нормальную i18n-структуру сразу, чем потом резать на живую готовую вёрстку под второй язык.

В Next.js на выбор два подхода. Pages Router с библиотекой next-i18next - рабочий вариант для проектов, которые уже на нём живут, но для новых лендингов я его не беру: слишком много ручной настройки namespace через serverSideTranslations и getStaticProps под каждую страницу. App Router вместе с next-intl закрывает роутинг по локали, серверные и клиентские переводы и метаданные одним набором конфигов - именно этот стек описываю ниже.

Критерий Pages Router + next-i18next App Router + next-intl
Роутинг по локали Настраивается вручную через i18n в next.config.js Встроенный middleware с defineRouting
Серверные компоненты Не поддерживаются архитектурно Переводы работают прямо в Server Components
Метаданные и hreflang Ручные head-теги на каждой странице generateMetadata с alternates.languages
Порог входа Ниже, если проект уже живёт на Pages Router Требует App Router, зато меньше кода на новом проекте

Структура проекта и настройка next-intl с нуля

Разворачиваю новый лендинг обычно так: создаю папку app/[locale] и переношу туда все страницы, добавляю i18n/routing.ts со списком локалей и middleware.ts в корень проекта, затем оборачиваю next.config.js плагином. На пустом проекте вся эта база собирается за 3-4 часа вместе с проверкой сборки под обе локали - дальше время уходит на переводы и на вёрстку, которая должна выдерживать разную длину текста (английский обычно на 15-20% короче русского).

npm install next-intl

import {defineRouting} from 'next-intl/routing';
import {createNavigation} from 'next-intl/navigation';

export const routing = defineRouting({
  locales: ['ru', 'en'],
  defaultLocale: 'ru'
});

export const {Link, redirect, usePathname, useRouter} =
  createNavigation(routing);

import createMiddleware from 'next-intl/middleware';
import {routing} from './i18n/routing';

export default createMiddleware(routing);

export const config = {
  matcher: ['/', '/(ru|en)/:path*']
};

next.config.js оборачиваю плагином, иначе next-intl не подхватит серверные сообщения на билде:

import createNextIntlPlugin from 'next-intl/plugin';

const withNextIntl = createNextIntlPlugin();

export default withNextIntl({
  reactStrictMode: true
});

Сам layout проверяет, что локаль из URL входит в список разрешённых, и прокидывает сообщения в клиентские компоненты через провайдер:

import {NextIntlClientProvider} from 'next-intl';
import {getMessages} from 'next-intl/server';
import {routing} from '@/i18n/routing';
import {notFound} from 'next/navigation';

export function generateStaticParams() {
  return routing.locales.map((locale) => ({locale}));
}

export default async function LocaleLayout({children, params}) {
  const {locale} = await params;
  if (!routing.locales.includes(locale)) notFound();

  const messages = await getMessages();

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

generateStaticParams здесь не для галочки: без него обе языковые версии рендерятся динамически вместо статики, и это первое, что ловлю при аудите чужих лендингов на next-intl.

Бесплатный материал

🎁 Полезный скрипт в подарок

Подпишитесь на Telegram - пришлю готовый скрипт по этой теме.

Без спама. Отписка в 1 клик.

Переводы: как раскладывать JSON, чтобы не запутаться через полгода

Раскладываю переводы по namespace, которые совпадают с секциями лендинга - hero, pricing, faq, footer, а не одним общим файлом на 300 строк. На лендинге с 40+ текстовыми блоками для одного из SaaS-проектов плоский common.json превратился в мешанину за неделю: правки перевода одной кнопки требовали поиска по всему файлу. Переезд на секционные файлы занял ещё день, зато после этого правка перевода занимала 5 минут, а не пятнадцать минут поиска нужной строки.

{
  "hero": {
    "title": "Учим React за 8 недель",
    "subtitle": "Практика с первого занятия, разбор кода в эфире"
  },
  "pricing": {
    "cta": "Оставить заявку"
  },
  "faq": {
    "q1": "Нужен ли опыт программирования?"
  }
}

В серверном компоненте достаю нужный namespace через getTranslations и обращаюсь к ключам без магических строк по всему JSX:

import {getTranslations} from 'next-intl/server';

export default async function HomePage() {
  const t = await getTranslations('hero');

  return (
    <section>
      <h1>{t('title')}</h1>
      <p>{t('subtitle')}</p>
    </section>
  );
}

Если не хочется собирать конфиг заново на каждом новом лендинге, в библиотеке готовых скриптов у меня лежит шаблон структуры next-intl для App Router с уже разложенными namespace - беру его за основу и адаптирую под конкретный проект вместо настройки с нуля.

SEO многоязычного лендинга: hreflang, sitemap и метаданные

Без alternates.languages в метаданных Google иногда индексирует не ту языковую версию для нужного региона. На одном лендинге без hreflang прямые заходы из США месяц подряд попадали на русскую версию, потому что поисковик не понимал, что /en - не дубль, а перевод. После добавления hreflang и canonical на саму себя трафик разошёлся по локалям за 2-3 недели переиндексации.

export async function generateMetadata({params}) {
  const {locale} = await params;

  return {
    alternates: {
      canonical: `/${locale}`,
      languages: {
        ru: '/ru',
        en: '/en',
        'x-default': '/ru'
      }
    }
  };
}

В sitemap.xml каждая языковая версия страницы идёт отдельным url с блоком xhtml:link rel=“alternate” на все остальные локали - иначе часть языковых страниц вообще не попадает в индекс, даже если технически доступна по прямой ссылке. Для лендинга из 5-6 страниц проще собрать sitemap вручную через app/sitemap.ts, чем тащить генератор под одну статическую структуру.

Переключатель языка и автоопределение локали браузера

next-intl умеет определять язык по заголовку Accept-Language прямо в middleware и редиректить на нужную локаль при первом заходе. Но принудительный редирект при каждом визите раздражает: пользователь один раз переключился на английский, а через день заголовок браузера снова кидает его на русскую версию. Решаю это через cookie NEXT_LOCALE, которую next-intl сам выставляет после явного переключения - второй визит уважает выбор, а не заголовок браузера.

'use client';

import {usePathname, useRouter} from '@/i18n/routing';
import {useLocale} from 'next-intl';

export function LanguageSwitcher() {
  const pathname = usePathname();
  const router = useRouter();
  const locale = useLocale();

  function switchTo(nextLocale) {
    router.replace(pathname, {locale: nextLocale});
  }

  return (
    <select value={locale} onChange={(e) => switchTo(e.target.value)}>
      <option value="ru">RU</option>
      <option value="en">EN</option>
    </select>
  );
}

usePathname и useRouter здесь беру не из next/navigation, а из своего i18n/routing - они уже знают про локали и не потеряют текущий путь при переключении языка на внутренней странице лендинга.

Частые ошибки при настройке многоязычности на Next.js лендинге

  • Забыли generateStaticParams для локалей - билд собирается, но страницы рендерятся динамически вместо статики, и это всплывает только на проде под нагрузкой
  • Текст захардкожен прямо в JSX вместо ключа перевода - потом это ищешь по всему проекту руками, когда нужно добавить третий язык
  • Общий sitemap без locale-путей и alternate-ссылок - часть языковых версий не попадает в индекс месяцами
  • Нет defaultLocale и localeDetection в middleware - прямой заход на / без указания языка отдаёт 404 вместо редиректа на локаль по умолчанию
  • Наполовину смешаны Pages Router legacy файлы (_app.tsx, _document.tsx) с App Router после недоделанной миграции - часть переводов работает, часть нет, и непонятно почему

Быстрый SEO-лендинг под продукт

Лендинг на Next.js

от 80 000 ₽

Подробнее →

Частые вопросы

Сколько стоит сделать многоязычный лендинг на Next.js?

Сам каркас i18n без дизайна и контента собирается за несколько часов, но лендинг под ключ на Next.js с версткой, переводами и SEO-настройкой беру от 80 000 ₽ - цена растёт с числом языков и сложностью интеграций вроде форм заявки и CRM.

next-intl или next-i18next - что выбрать для нового проекта?

Для нового проекта на App Router беру next-intl без вариантов. next-i18next оставляю только для лендингов, которые уже живут на Pages Router и миграция на App Router пока не запланирована - переписывать рабочий роутинг ради библиотеки смысла нет.

Нужны ли отдельные поддомены под каждый язык или хватит /en/, /ru/?

Для лендинга обычно хватает path-based роутинга через /ru/ и /en/. Поддомены усложняют деплой, DNS и сертификаты без ощутимого выигрыша в SEO - беру их только когда у языковых версий разные юрлица или полностью раздельная контент-стратегия.

Как быть с SEO, если контент на разных языках частично совпадает?

hreflang в связке с canonical на саму себя для каждой локали снимает проблему дублей - поисковик трактует это как разные версии одной страницы под разные регионы, а не как копипаст контента, и не занижает страницы за дублирование.

Есть задача?

Обсудим в мессенджере

Расскажите, что нужно сделать — отвечу в течение 4 часов в рабочее время. Первая консультация бесплатно.

Самозанятый Калинкин Н. А. · работаю с физлицами и юрлицами

Продолжая пользование настоящим сайтом Вы выражаете своё согласие на обработку Ваших персональных данных (файлов куки) с использованием Yandex.Metrika.
Понятно