Разбираемся с интернационализацией в Next.js
Введение
Когда я делал этот блог, подумал, что было бы славно уйти от привязки к одному пользовательскому языку - так я решил добавить интернациональность. Первоначально я решил воспользоваться готовыми библиотеками, наподобие next-intl, но в настоящее время данная библиотека не поддерживает Partial Prerendering(PPR) - новой фишки Next js. Да и использование сторонних библиотек требует прочтения документации, осознания как работает API данной библиотеки, подстраивания под требования этой библиотеки - все это заставило меня задуматься и принять решение. Я решил, что не хочу использовать next-intl и подобное, лучше сделать что-то свое. Так что сейчас поделюсь опытом, по моему мнению весьма удачным. Многое я взял из из документации Next js https://nextjs.org/docs/app/building-your-application/routing/internationalization
Погнали
Маршрутизация для интернационализации будет осуществляться на основе подпутей.
/ru/profile
/en/profile
....
/[locale]/profile
Так что для начала нужно создать папку [locale] внутри папки app и все содержимое папки app нужно перенести в папку app/[locale]. Так мы сделаем глобальный динамический маршрут для нашего веб приложения. Также следует, для удобства, создать папку i18n в корне вашего проекта.
1. Создание LocaleMiddleware
/i18n
LocaleMiddleware.ts
Код я взял с документации Next js, дополнив его комментариями, сгенерированными ChatGPT, но для начала установим необходимые npm пакеты
npm install @formatjs/intl-localematcher negotiator @types/negotiator
Данный этап самый сложный - вам предстоит разобраться в коде LocaleMiddlware.tsx, поменять его для своих потребностей и тд.
// LocaleMiddleware.ts
import 'server-only';
import { NextRequest, NextResponse } from 'next/server';
import Negotiator from 'negotiator';
import { match } from '@formatjs/intl-localematcher';
import { defaultLocale, locales, LocaleType } from './locales';
// Папку locales создадим чуть позже
// Название cookie, в которой хранится выбранная пользователем локаль
const LOCALE_COOKIE_NAME = 'locale';
/**
* Определяет наиболее подходящую локаль для пользователя на основе заголовка Accept-Language в запросе.
* @param {NextRequest} request - Объект запроса Next.js.
* @returns {string} - Наиболее подходящая локаль (например, 'en', 'ru').
*/
function getPreferredLocale(request: NextRequest): string {
// Создаем объект, содержащий заголовки запроса в формате, понятном для Negotiator.
const negotiatorHeaders: Record<string, string> = {};
request.headers.forEach((value, key) => (negotiatorHeaders[key] = value));
// Получаем список предпочитаемых языков от пользователя на основе заголовка Accept-Language.
const languages = new Negotiator({ headers: negotiatorHeaders }).languages();
// Используем `match` для определения наиболее подходящей локали из списка поддерживаемых локалей.
// Если ни одна из предпочитаемых локалей не поддерживается, возвращается `defaultLocale`.
return match(languages, locales, defaultLocale);
}
/**
* Middleware для обработки локализации. Перенаправляет запросы на основе локали пользователя.
* @param {NextRequest} request - Объект запроса Next.js.
* @returns {NextResponse<unknown> | NextRequest} - Объект ответа Next.js (перенаправление) или оригинальный запрос.
*/
export function LocaleMiddleware(request: NextRequest): NextResponse<unknown> | NextRequest {
const { pathname } = request.nextUrl;
// Проверяем, содержится ли локаль в текущем пути (например, '/en/about').
const pathnameHasLocale = locales.some((locale) =>
pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
);
// Получаем значение куки с локалью пользователя, если она установлена.
const localeCookie = request.cookies.get(LOCALE_COOKIE_NAME)?.value as LocaleType;
// Проверяем, существует ли куки с локалью и является ли её значение валидной локалью. Куки являются приоритетом
if (localeCookie && locales.includes(localeCookie)) {
// Куки с локалью установлена, проверяем, содержится ли локаль также и в пути.
if (!pathnameHasLocale) {
// Локаль отсутствует в пути, нужно перенаправить пользователя, добавив префикс локали из куки.
const newPath = `/${localeCookie}${pathname}`;
return NextResponse.redirect(new URL(newPath, request.url));
} else {
// Локаль присутствует и в куки, и в пути. Проверяем, совпадают ли они.
const currentPathLocale = pathname.split('/')[1] as LocaleType;
if (localeCookie !== currentPathLocale) {
// Локаль в куки не совпадает с локалью в пути. Нужно перенаправить на путь с локалью из куки.
// Удаляем текущий префикс локали из пути.
const newPathWithoutLocale = pathname.slice(currentPathLocale.length + 1); // Учитываем слэш
// Формируем новый путь с локалью из куки.
const newPath = `/${localeCookie}${newPathWithoutLocale}`;
return NextResponse.redirect(new URL(newPath, request.url));
} else {
// Локаль в куки и в пути совпадают. Ничего не нужно менять, возвращаем оригинальный запрос.
return request
}
}
} else {
// Куки с локалью отсутствует.
if (!pathnameHasLocale) {
// Локаль отсутствует и в пути, и в куки. Определяем предпочитаемую локаль пользователя на основе заголовка Accept-Language.
const preferredLocale = getPreferredLocale(request);
// Формируем новый путь с предпочитаемой локалью.
const newPath = `/${preferredLocale}${pathname}`;
// Перенаправляем пользователя на новый путь.
const response = NextResponse.redirect(new URL(newPath, request.url));
// Устанавливаем куки с предпочитаемой локалью, чтобы в следующий раз использовать её.
response.cookies.set(LOCALE_COOKIE_NAME, preferredLocale, { sameSite: 'lax' });
return response;
} else {
// Локаль присутствует в пути, но отсутствует в куки. Проверяем, является ли локаль в пути валидной.
const currentPathLocale = pathname.split('/')[1] as LocaleType;
if (locales.includes(currentPathLocale)) {
// Локаль в пути является валидной. Устанавливаем куки с этой локалью.
const response = NextResponse.next();
response.cookies.set(LOCALE_COOKIE_NAME, currentPathLocale, { sameSite: 'lax' });
return response;
} else {
// Локаль в пути не является валидной. Перенаправляем на путь с локалью по умолчанию.
const newPath = `/${defaultLocale}${pathname}`;
const response = NextResponse.redirect(new URL(newPath, request.url));
response.cookies.set(LOCALE_COOKIE_NAME, defaultLocale, { sameSite: 'lax' });
return response;
}
}
}
}
Думаю, что комментарии здесь все объясняют, скажу лишь, что вы всегда можете модифицировать этот код, добавив аналитику и тп.
Конечно же, этот LocaleMiddleware нужно добавить в основной middleware.ts. Основной middleware.ts добавляется в корневом каталоге вашего проекта ( на том же уровне, что и app или pages, или внутри src )
import { NextRequest, NextResponse } from "next/server"
import { LocaleMiddleware } from "./i18n/LocaleMiddleware"
// middleware.ts
export function middleware(request: NextRequest) {
const newRequest = LocaleMiddleware(request)
if (newRequest instanceof NextResponse) {
return newRequest
}
//..... Любая ваша логика
return NextResponse.next()
}
export const config = {
matcher: [
'/',
'/((?!api|_next/static|_next/image|favicon.ico|.+[.](?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
};
После проверки if вы можете добавить любую логику: заголовки, аналитику или другие middleware. Главное следите за config.matcher и обновляется для своих потребностей.
2. Создание файлов перевода
На этом шаге нам требуется создать папку со словарями переводов. Она может находиться в любом месте, например, я создал ее в корневой папке своего проекта. Название папки любое. В этой папке у нас будут храниться словари переводов в формате json
/locales
en.json
ru.json
fr.json
es.json
Пример файла ru.json
{
"header": {
"phone": "Телефон"
},
"hello": "Привет"
}
Дальше следует создать функцию для извлечения данных из этих файлов, а также вспомогательные функции и файлы. (в папке /i18n)
/i18n
LocaleMiddleware.ts
locales.ts
getDictionary.ts
InsertValues.ts
Для начала создадим файл locales.ts, в нем у нас будут содержаться актуальные локали и локаль по умолчанию
//locales.ts
export const locales = ['ru', 'en'] as const
export const defaultLocale = 'ru'
// Для удобства пользования создадим тип
export type LocaleType = typeof locales[number]
Дальше создадим функцию для извлечения данных с папки /locales
//GetDictionary.ts
import 'server-only'
import { LocaleType } from './locales'
// Удобный тип, позволяющий легко выбирать нужную иформацию с json файлов
export type DictionaryType = Awaited<ReturnType<typeof dictionaries[keyof typeof dictionaries]>>
const dictionaries = {
en: () => import('../../locales/en.json').then((module) => module.default),
ru: () => import('../../locales/ru.json').then((module) => module.default),
}
export const getDictionary = async (locale: LocaleType): Promise<DictionaryType> =>
dictionaries[locale]()
Здесь вы можете создать любую вспомогательную функцию. Предположим, вам придётся осуществлять вставку по шаблону.
export function insertValues(text: string, data: any[], placeholder: string = '$'): string {
try {
const parts: string[] = text.split(placeholder);
let result: string = "";
if (parts.length - 1 > data.length) {
return text;
}
for (let i = 0; i < parts.length; i++) {
result += parts[i];
if (i < data.length) {
result += String(data[i]);
}
}
return result;
} catch (error) {
console.error("Ошибка при вставке значений:", error);
return text; // Возвращаем исходный текст при ошибке
}
}
// insertValues('Привет $, как ты?',['Евгений'])
// log: Привет Евгений, как ты?
Конечно же, можно дополнить эту функцию, добавив нумерацию, наподобие $1.
3. Настройка Redux
Для начала создадим папку _redux. Я создал ее в папке src. И устанавливаем react-redux и @reduxjs/toolkit.
npm install react-redux @reduxjs/toolkit
После установки react-redux и @reduxjs/toolkit настраиваем store.ts и создаем LocaleSlice.ts
/src
/_redux
store.ts
/Slices
LocaleSlice.ts
Настройка store.ts
//store.ts
import { configureStore } from '@reduxjs/toolkit'
import { useDispatch, useSelector, useStore } from 'react-redux'
import { LocaleSlice } from './Slices/LocaleSlice'
export const makeStore = () => configureStore({
reducer: {
[LocaleSlice.name]: LocaleSlice.reducer,
},
})
export type AppStore = ReturnType<typeof makeStore>
export type RootState = ReturnType<AppStore['getState']>
export type AppDispatch = AppStore['dispatch']
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()
export const useAppStore = useStore.withTypes<AppStore>()
Настройка LocaleSlice.ts
//LocaleSlice.ts
import { LocaleType } from "@/i18n/locales";
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
type InitialStateType = {
locale: LocaleType | ''
}
const initialState: InitialStateType = { locale: '' }
export const LocaleSlice = createSlice({
name: 'locale',
initialState,
reducers: {
updateLocale(state: InitialStateType, action: PayloadAction<LocaleType | "">):any {
state.locale = action.payload
}
},
})
export const { updateLocale } = LocaleSlice.actions
LocaleSlice.ts нужен для хранения текущей локали. В дальнейшем мы легко извлекаем локаль с помощью useAppSelector, созданного в store.ts
После настройки store.ts и LocaleSlice.ts остается создать StoreProvider.tsx
//StoreProvider.tsx
'use client'
import { useRef } from 'react'
import { Provider } from 'react-redux'
import { AppStore, makeStore } from './store'
import { LocaleType } from '@/i18n/locales'
import { updateLocale } from './Slices/LocaleSlice'
export default function StoreProvider({
children,
locale,
}: {
children: React.ReactNode,
locale: LocaleType,
}) {
const storeRef = useRef<AppStore>(undefined)
if (!storeRef.current) {
// Create the store instance the first time this renders
storeRef.current = makeStore()
storeRef.current.dispatch(updateLocale(locale))
}
return <Provider store={storeRef.current} >{children}</Provider>
}
Данный трюк подсмотрел в документации redux. Здесь мы создаем хранилище и сразу же вставляем в него локаль. Как итог - при инициализации клиента, redux уже будет иметь в store нужную нам локаль.
4. Создание вспомогательных компонентов навигации
Предпоследним шагом будет создание функции переключения локалей на стороне клиента, а также создание дополнительных компонентов и функций поверх API Next js для навигации.
Начнем с функции переключения локали.
// useChangeLocale.ts
'use client';
import { locales, LocaleType } from '../locales';
import { changeLocale } from '../../../serverAction/changeLocale';
import useLocalizedRouter from './LocalizedUseRouter';
//useLocalizedRouter создадим чуть позже
export function useChangeLocale() {
const router = useLocalizedRouter();
return async (locale: LocaleType) => {
if (locales.includes(locale)) {
await changeLocale(locale)
router.changeLocale(locale)
}
};
}
//-------------------------------------------------------------------------
//changeLocale.ts
'use server'
import { cookies } from 'next/headers'
import { locales, LocaleType } from '@/i18n/locales'
export async function changeLocale(newLocale: LocaleType) {
if (locales.includes(newLocale)) {
(await cookies()).set('locale', newLocale, { sameSite: 'lax', secure: true, httpOnly: true })
}
}
Данный хук возвращает функцию, которая принимает локаль, которую предпочел пользователь. Разумеется вы легко можете написать свою функцию, либо поменять логику этой. Функция changeLocale - это Серверная функция, ее создаете в удобном для вас месте. Подробнее на https://react.dev/reference/rsc/server-functions
Следующим шагом будет создание LocalizedLink.tsx, что является усовершенствованной версией компонента Link
'use client'
import { useAppSelector } from "@/_redux/store";
import Link from "next/link";
import React, { ComponentProps } from 'react'
type LocalizedLinkProps = {
href: string;
} & ComponentProps<typeof Link>
function LocalizedLink({ href, children, ...props }: LocalizedLinkProps) {
const isAbsolute = href.startsWith('http://') || href.startsWith('https://');
if (isAbsolute) {
return <Link href={href} {...props}>{children}</Link>;
}
const locale = useAppSelector(state => state.locale.locale)
let localizedHref = ''
if (href.startsWith("/")) {
localizedHref = `/${locale}${href}`
} else {
return <Link href={href} {...props}>{children}</Link>
}
if (href.startsWith('#')) {
localizedHref = href; // Возвращаем исходный href для якорей
}
return (
<Link href={localizedHref} {...props}>{children}</Link>
);
}
export default LocalizedLink;
Можно видеть, что мы получаем локаль из хранилища Redux, настроенного в прошлой главе. Важно, что вместо Link, нужно везде использовать LocalizedLink, это уберет лишние редиректы - что добавит производительности, а также убережет от нежелательных эффектов, связанных с кешированием статических страниц.
Аналогично создадим подобную версию для хука UseRouter, но сначала установим sanitizeUrl
npm install @braintree/sanitize-url
И создадим UseRouter
import { useRouter } from 'next/navigation';
import { useCallback } from 'react';
import { useAppSelector } from '@/_redux/store';
import { defaultLocale } from '../locales';
var sanitizeUrl = require("@braintree/sanitize-url").sanitizeUrl;
interface LocalizedRouter {
push: (href: string, options?: { scroll?: boolean; shallow?: boolean }) => void;
replace: (href: string, options?: { scroll?: boolean; shallow?: boolean }) => void;
prefetch: (href: string) => void;
back: () => void;
forward: () => void;
refresh: () => void;
currentLocale: string | undefined;
changeLocale: (newLocale: string, options?: { scroll?: boolean; shallow?: boolean }) => void;
}
const getLocalizedHref = (href: string, locale: string) => {
const normalizedHref = href.startsWith('/') ? href : `/${href}`;
return href.startsWith(`/${locale}/`) || href === `/${locale}`
? href
: `/${locale}${normalizedHref}`;
};
export const useLocalizedRouter = (): LocalizedRouter => {
const router = useRouter();
const cookiesLocale = useAppSelector(state => state.locale.locale);
const locale = cookiesLocale || process.env.NEXT_PUBLIC_DEFAULT_LOCALE || defaultLocale;
const createLocalizedHandler = (method: 'push' | 'replace' | 'prefetch') =>
useCallback((href: string, options?: any) => {
const hrefWithLocale = getLocalizedHref(href, locale);
return router[method](sanitizeUrl(hrefWithLocale), options);
}, [router, locale]);
const changeLocale = useCallback((newLocale: string, options?: any) => {
let currentPath = window.location.pathname;
if (locale && currentPath.startsWith(`/${locale}`)) {
currentPath = currentPath.slice(locale.length + 1) || '/';
}
const newPath = `/${newLocale}${currentPath.startsWith('/') ? currentPath : `/${currentPath}`}`;
router.push(sanitizeUrl(newPath), options);
}, [router, locale]);
return {
push: createLocalizedHandler('push'),
replace: createLocalizedHandler('replace'),
prefetch: createLocalizedHandler('prefetch'),
back: router.back,
forward: router.forward,
refresh: router.refresh,
currentLocale: locale,
changeLocale,
};
};
export default useLocalizedRouter;
5. Создание статических маршрутов
В последней главе нас ждем самый простой шаг, добавление функции generateStaticParams, чтобы добавить статику везде, где это возможно. Данную функцию рекомендую вызвать в корневом Layout.tsx, чтобы сразу добавить значение в атрибут lang тега html. А также добавить, созданный на 3 этапе, StoreProvider.
Пример корневого Layout.tsx
//Layout.tsx
import "./globals.css";
import { locales, LocaleType } from "@/i18n/locales";
import { notFound } from "next/navigation";
import StoreProvider from "@/_redux/StoreProvider";
export const dynamicParams = true
export async function generateStaticParams() {
return locales.map((locale) => ({ locale }))
}
export default async function LocaleLayout({
children,
params
}: {
children: React.ReactNode;
params: Promise<{ locale: LocaleType }>;
}) {
const { locale } = await params;
if (!locales.includes(locale)) {
notFound();
}
return (
<html lang={locale} suppressHydrationWarning>
<body>
<StoreProvider locale={locale}>
{children}
</StoreProvider>
</body>
</html>
);
}
Конечно можно поставить dynamicParams=false
и не проверять локаль, но это заблокирует возможность динамической генерации страниц, параметры которых не были переданы в generateStaticParams, для всего приложения
Теперь для извлечения данных со словарей следуем шагам:
- Считывание локаль из params
const { locale } = await params
- Передача локали в функцию getDictionary
const dict = (await getDictionary((locale))).your.path.to.data
Важный момент, не обязательно считывать параметры в странице или в layout через await, особенно если они там не нужны - можно передать params, который является Promise, в нужную нам компоненту, и в ней уже выполнить извлечение локали из params
Пример
//page.tsx
export default function Page({
params
}: {
params: Promise<{ locale: LocaleType }>;
}) {
return (
<div className={s.main}>
<Data params={params} />
</div>
)
}
//Data.tsx
export default async function Data({
params
}: {
params: Promise<{ locale: LocaleType }>;
}) {
const { locale } = await params;
//...
}
Если вы хотите считать данные на клиенте используйте useAppselector.
const locale = useAppSelector(state => state.locale.locale)
Вывод
Теперь на вашем сайте настроена очень гибкая интернационализация. Вы можете добавить свой функционал, или же полностью переписать логику. В данном примере вообще не обязательно использовать Redux, его можно убрать, заменить httpOnly cookie на обычные, что даст нам доступ к локали просто через считывание значения с cookie. Очень надеюсь, что хоть чуть-чуть, но помог вам в решении проблем, связанных с интернационализацией приложения.
Пример готового проекта можно найти на GitHub: https://github.com/zh-blog-ru/test_internationalization
Комментарии