light

i18n: Интернационализация

Меняем языки интерфейса без сторонних библиотек. Лучшее решение для SEO

Опубликовано: 20 июля 2024

Внимание: Статья была обновлена более года назад - 20 июля 2024. Информация может быть не актуальной

Что получится в итоге?

Next.js позволяет настроить маршрутизацию и рендеринг контента для поддержки нескольких языков. Адаптация вашего сайта к различным языкам включает перевод контента (локализацию) и интернационализацию маршрутов.
У нас будет полный контроль над i18n, так как мы пишем всё без сторонних библиотек. Для этой цели нам достаточно будет middleware.ts.
Не нужно передавать язык в getDictionary(lang), как это реализовано в
документации Next.js
:
1- getDictionary(lang)
2+ getDictionary()
Создадим аналог usePathname() для серверных компонентов - getPathname().
Мы сможем получать переводы и выбранный язык не прокидывая их в каждый компонент:
1-  export const Navbar = ({ dict }: { dict: Dictionary['ui'] }) => {...}
2
3+  export const Navbar = async () => {
4+    const dictionary = await getDictionary();
Просто используем в любом серверном компоненте const dict = await getDictionary();, прокидываем в клиентский компонент как props { dict }: { dict: Dictionary['ui'] } или используем useDictionary()
Пример использования:
1export const Component = async () => {
2  const dictionary = await getDictionary();
3  const lang = await getLang();
4
5  return (
6    <>
7      <h1>{dictionary.ui['title']}</h1>
8      <p>Выбранный язык: {lang}</p>
9    </>
10  );
11};

Реализация основного функционала

Конфигурация i18n

Создадим конфиг i18n shared/config/i18n/i18n.config.ts:
i18n.config.ts
1export const i18n = {
2  defaultLocale: 'ru',
3  locales: ['en', 'ru'],
4} as const;
5
6export type Locale = (typeof i18n)['locales'][number];
Создадим файлы с переводами ru.json и en.json в shared/config/i18n/dictionaries.
Пример:
ru.json
1{
2  "home-page": {
3    "note": "Главная страница",
4    "title": "👋 Привет, я Mark Melior",
5    "description": "Ищу компанию, в которой буду совершенствовать свои навыки до сильного Frontend разработчика (:"
6  }
7}

Middleware.ts

Весь функционал интернационализации находится здесь!
В middleware.ts реализуем передачу url в headers для будущего getPathname() + функционал:
middleware.ts
1import { i18n } from '@/shared/config/i18n';
2import { NextRequest, NextResponse } from 'next/server';
3
4export function middleware(request: NextRequest) {
5  const { pathname } = request.nextUrl;
6
7  // Передаём в headers url текущей страницы
8  // Это нужно для будущего хука getPathname()
9  const requestHeaders = new Headers(request.headers);
10  requestHeaders.set('x-url', request.url);
11
12  // `/_next/" и "/api/" игнорируются наблюдателем, но нам нужно вручную игнорировать файлы в `public`
13  const ignoredPaths = [
14    /^\/images\/.*$/,
15    /^\/videos\/.*$/,
16    /^\/files\/.*$/,
17    /^\/favicon\.ico$/,
18  ];
19  if (ignoredPaths.some((regex) => regex.test(pathname))) {
20    return;
21  }
22
23  if (
24    pathname.startsWith(`/${i18n.defaultLocale}/`) ||
25    pathname === `/${i18n.defaultLocale}`
26  ) {
27    // Если входящий запрос предназначен для /ru/whatever, тогда мы перенаправим его на /whatever
28    const response = NextResponse.redirect(
29      new URL(
30        pathname.replace(
31          `/${i18n.defaultLocale}`,
32          pathname === `/${i18n.defaultLocale}` ? '/' : '',
33        ),
34        request.url,
35      ),
36      {
37        headers: requestHeaders,
38      },
39    );
40    return response;
41  }
42
43  const pathnameIsMissingLocale = i18n.locales.every(
44    (locale) =>
45      !pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}`,
46  );
47
48  if (pathnameIsMissingLocale) {
49    // Теперь для /en или /ru мы собираемся указать Next.js, что запрос предназначен для /ru/whatever
50    const response = NextResponse.rewrite(
51      new URL(
52        `/${i18n.defaultLocale}${pathname}${request.nextUrl.search}`,
53        request.nextUrl.href,
54      ),
55      {
56        request: {
57          headers: requestHeaders,
58        },
59      },
60    );
61    return response;
62  }
63
64  const response = NextResponse.next({
65    request: {
66      headers: requestHeaders,
67    },
68  });
69  return response;
70}
71
72export const config = {
73  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
74};

getPathname()

Создаём getPathname() для получения пути на стороне сервера. Некий аналог хука usePathname().
shared/lib/get-pathname/get-pathname.ts:
get-pathname.ts
1'use server';
2
3import { i18n, Locale } from '@/shared/config/i18n';
4import { headers } from 'next/headers';
5
6interface GetPathnameProps {
7  withoutLang?: boolean;
8}
9
10export const getPathname = async ({ withoutLang }: GetPathnameProps = {}) => {
11  const headersList = headers();
12  const fullUrl = headersList.get('x-url') || '';
13
14  const url = new URL(fullUrl);
15  const pathname = url.pathname;
16
17  if (withoutLang) {
18    const segment = pathname.split('/')[1] as Locale;
19
20    if (i18n.locales.includes(segment)) {
21      return `/${pathname.split('/').slice(2).join('/')}`;
22    }
23  }
24
25  return pathname;
26};

getLang()

const lang = await getLang() возвращает выбранный язык en | ru для серверного компонента.
shared/config/i18n/get-lang.ts:
get-lang.ts
1'use server';
2
3import { getPathname } from '@/shared/lib';
4import { Locale, i18n } from './i18n.config';
5
6export const getLang = async (): Promise<Locale> => {
7  const pathname = await getPathname();
8
9  const segment = pathname.split('/')[1] as Locale;
10
11  // Если сегмент URL валиден, возвращаем его
12  if (i18n.locales.includes(segment)) return segment;
13
14  // В противном случае возвращаем язык по умолчанию
15  return i18n.defaultLocale;
16};

useLang()

const lang = useLang() возвращает выбранный язык en | ru для клиентского компонента.
shared/config/i18n/use-lang.ts:
use-lang.ts
1'use client';
2
3import { usePathname } from 'next/navigation';
4import { Locale, i18n } from './i18n.config';
5
6export const useLang = (): Locale => {
7  const pathname = usePathname();
8
9  const segment = pathname.split('/')[1] as Locale;
10
11  if (i18n.locales.includes(segment)) return segment;
12
13  return i18n.defaultLocale;
14};

getDictionaries()

const dict = await getDictionaries() возвращает объект с переводами "ключ-значение" для серверного компонента.
shared/config/i18n/dictionaries.ts:
dictionaries.ts
1'use server';
2
3import { getLang } from './get-lang';
4import { Locale } from './i18n.config';
5
6const dictionaries = {
7  en: () => import('./dictionaries/en.json').then((module) => module.default),
8  ru: () => import('./dictionaries/ru.json').then((module) => module.default),
9};
10
11export const getDictionary = async (locale?: Locale) => {
12  const lang = await getLang();
13
14  return dictionaries[locale || lang]?.() ?? dictionaries.ru();
15};
16
17export type Dictionary = Awaited<ReturnType<typeof getDictionary>>;

useDictionaries()

const dict = useDictionaries() возвращает объект с переводами "ключ-значение" для клиентского компонента.
shared/config/i18n/use-dictionary.ts:
use-dictionary.ts
1'use client';
2
3import { useEffect, useState } from 'react';
4import { Dictionary } from './dictionaries';
5import { Locale } from './i18n.config';
6import { useLang } from './use-lang';
7
8export const useDictionary = (locale?: Locale): Dictionary | undefined => {
9  const dictionaries = {
10    en: () => import('./dictionaries/en.json').then((module) => module.default),
11    ru: () => import('./dictionaries/ru.json').then((module) => module.default),
12  };
13
14  const [dict, setDict] = useState<Dictionary>();
15
16  const lang = useLang();
17
18  useEffect(() => {
19    dictionaries[locale || lang]?.().then(setDict);
20  }, [lang, locale]);
21
22  return dict as Dictionary;
23};
На этом почти всё готово, но при переходе на другие ссылки у нас сбрасывается выбранный язык.
Чтобы это исправить, создадим свой компонент <Link /> в shared/config/i18n/link.tsx:
link.tsx
1'use client';
2
3import NextLink, { LinkProps as NextLinkProps } from 'next/link';
4import { FC } from 'react';
5import { UrlObject } from 'url';
6import { i18n } from './i18n.config';
7import { useLang } from './use-lang';
8
9interface LinkProps extends NextLinkProps {
10  children: React.ReactNode;
11}
12
13const isExternalLink = (href: string | UrlObject): boolean => {
14  return (
15    typeof href === 'string' &&
16    (href.startsWith('http://') || href.startsWith('https://'))
17  );
18};
19
20const getLocalizedHref = (
21  href: string | UrlObject,
22  lang: string,
23  defaultLocale: string,
24): string | UrlObject => {
25  if (isExternalLink(href) || typeof href !== 'string') {
26    return href;
27  }
28  return lang === defaultLocale ? href : `/${lang}${href}`;
29};
30
31export const Link: FC<
32  Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, keyof LinkProps> &
33    LinkProps & {
34      children?: React.ReactNode;
35    } & React.RefAttributes<HTMLAnchorElement>
36> = ({ children, href, ...props }) => {
37  const lang = useLang();
38
39  const localizedHref = getLocalizedHref(href, lang, i18n.defaultLocale);
40
41  return (
42    <NextLink {...props} href={localizedHref}>
43      {children}
44    </NextLink>
45  );
46};
Все компоненты Link должны экспортироваться из @/shared/config/i18n! Иначе при переходе по ссылкам на сайте, выбранный язык будет сбрасываться.

Заключение

Как вы видите, реализовать интернационализацию можно буквально одним middleware.ts. Получаем переводы через const dict = await getDictionaries() и всё!
Остальные хуки нужны больше для удобства, чем для функциональности i18n.
Ещё вместо хранения языка в роуте можете попробовать на Cookie переписать конечно. Но это будет плохо для SEO
Спасибо за прочтение! Надеюсь, помог тебе (: