Understanding Internationalization in Next.js

Introduction

When I was building this blog, I thought it would be great to move away from being tied to a single user language - so I decided to add internationalization. Initially, I considered using ready-made libraries like next-intl, but currently, this library doesn't support Partial Prerendering (PPR) - a new feature of Next.js.

Moreover, using third-party libraries requires reading documentation, understanding how the library's API works, and adapting to its requirements - all of this made me think and make a decision. I decided that I didn't want to use next-intl or similar solutions, and it would be better to create my own. Now I'll share what I consider to be a very successful experience.

I took much of this from the Next.js documentation: https://nextjs.org/docs/app/building-your-application/routing/internationalization

Let's Go

Routing for internationalization will be based on subpaths:

/ru/profile  
/en/profile  
....  
/[locale]/profile

So first, we need to create a [locale] folder inside the app folder and move all the contents of the app folder into app/[locale]. This will create a global dynamic route for our web application. For convenience, we should also create an i18n folder in the root of your project.

1. Creating LocaleMiddleware

/i18n  
LocaleMiddleware.ts

I took the code from the Next.js documentation, supplementing it with comments generated by ChatGPT. But first, let's install the necessary npm packages:

npm install @formatjs/intl-localematcher negotiator @types/negotiator

This is the most complex step - you'll need to understand the code in LocaleMiddleware.tsx, modify it for your needs, etc.

// 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';
// We will create the locales folder later

// The name of the cookie storing the user's selected locale
const LOCALE_COOKIE_NAME = 'locale';

/**
 * Determines the most suitable locale for the user based on the Accept-Language header in the request.
 * @param {NextRequest} request - Next.js request object.
 * @returns {string} - The most suitable locale (e.g., 'en', 'ru').
 */
function getPreferredLocale(request: NextRequest): string {
  // Create an object containing the request headers in a format understandable by Negotiator.
  const negotiatorHeaders: Record<string, string> = {};
  request.headers.forEach((value, key) => (negotiatorHeaders[key] = value));

  // Get the list of preferred languages from the user based on the Accept-Language header.
  const languages = new Negotiator({ headers: negotiatorHeaders }).languages();
  
  // Use `match` to determine the most suitable locale from the list of supported locales.
  // If none of the preferred locales are supported, returns `defaultLocale`.
  return match(languages, locales, defaultLocale);
}

/**
 * Middleware for handling localization. Redirects requests based on the user's locale.
 * @param {NextRequest} request - Next.js request object.
 * @returns {NextResponse<unknown> | NextRequest} - Next.js response object (redirect) or the original request.
 */
export function LocaleMiddleware(request: NextRequest): NextResponse<unknown> | NextRequest {
  const { pathname } = request.nextUrl;

  // Check if the locale is present in the current path (e.g., '/en/about').
  const pathnameHasLocale = locales.some((locale) =>
    pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
  );

  // Get the value of the locale cookie if it's set.
  const localeCookie = request.cookies.get(LOCALE_COOKIE_NAME)?.value as LocaleType;

  // Check if the locale cookie exists and if its value is a valid locale. Cookies have priority.
  if (localeCookie && locales.includes(localeCookie)) {
    // The locale cookie is set; check if the locale is also present in the path.
    if (!pathnameHasLocale) {
      // Locale is missing from the path; redirect the user by adding the prefix from the cookie.
      const newPath = `/${localeCookie}${pathname}`;
      return NextResponse.redirect(new URL(newPath, request.url));
    } else {
      // Locale is present in both the cookie and the path. Check if they match.
      const currentPathLocale = pathname.split('/')[1] as LocaleType;

      if (localeCookie !== currentPathLocale) {
        // The locale in the cookie doesn't match the one in the path. Redirect to the path with the locale from the cookie.

        // Remove the current locale prefix from the path.
        const newPathWithoutLocale = pathname.slice(currentPathLocale.length + 1); // Account for the slash

        // Form the new path with the locale from the cookie.
        const newPath = `/${localeCookie}${newPathWithoutLocale}`;
        return NextResponse.redirect(new URL(newPath, request.url));
      } else {
        // The locale in the cookie and the path match. No changes needed; return the original request.
        return request;
      }
    }
  } else {
    // No locale cookie is set.
    if (!pathnameHasLocale) {
      // Locale is missing from both the path and the cookie. Determine the user's preferred locale based on the Accept-Language header.
      const preferredLocale = getPreferredLocale(request);

      // Form the new path with the preferred locale.
      const newPath = `/${preferredLocale}${pathname}`;

      // Redirect the user to the new path.
      const response = NextResponse.redirect(new URL(newPath, request.url));

      // Set the cookie with the preferred locale for future use.
      response.cookies.set(LOCALE_COOKIE_NAME, preferredLocale, { sameSite: 'lax' });
      return response;
    } else {
      // Locale is present in the path but missing from the cookie. Check if the path locale is valid.
      const currentPathLocale = pathname.split('/')[1] as LocaleType;

      if (locales.includes(currentPathLocale)) {
        // The path locale is valid. Set the cookie with this locale.
        const response = NextResponse.next();
        response.cookies.set(LOCALE_COOKIE_NAME, currentPathLocale, { sameSite: 'lax' });
        return response;
      } else {
        // The path locale is invalid. Redirect to the path with the default locale.
        const newPath = `/${defaultLocale}${pathname}`;
        const response = NextResponse.redirect(new URL(newPath, request.url));
        response.cookies.set(LOCALE_COOKIE_NAME, defaultLocale, { sameSite: 'lax' });
        return response;
      }
    }
  }
}

I think the comments explain everything here. I'll just add that you can always modify this code by adding analytics, etc.

Of course, this LocaleMiddleware needs to be added to the main middleware.ts. The main middleware.ts file is added in the root directory of your project (at the same level as app or pages, or inside 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)$).*)',
    ],
};

After the if check, you can add any logic: headers, analytics, or other middlewares. The main thing is to keep an eye on config.matcher and be updated for your needs.

2. Creating Translation Files

At this step, we need to create a folder with translation dictionaries. It can be located anywhere; for example, I created it in the root folder of my project. The folder name can be anything.
This folder will store translation dictionaries in json format:

/locales
  en.json
  ru.json
  fr.json
  es.json

Example of ru.json file:

{
  "header": {
    "phone": "Телефон"
  },
  "hello": "Привет"
}

Next, we need to create a function to extract data from these files, as well as helper functions and files: (in the /i18n folder)

/i18n
  LocaleMiddleware.ts
  locales.ts
  getDictionary.ts
  InsertValues.ts

First, let's create the locales.ts file, which will contain the actual locales and the default locale:

//locales.ts

export const locales = ['ru', 'en'] as const;
export const defaultLocale = 'ru';
// For convenience, let's create a type
export type LocaleType = typeof locales[number];

Next, let's create a function to extract data from the /locales folder:

//GetDictionary.ts
import 'server-only';
import { LocaleType } from './locales';

// A convenient type that makes it easy to select the needed information from json files
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]();

Here you can create any helper function. Suppose you need to perform template insertion:

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 inserting values:", error);
    return text; // Return the original text in case of error
  }
}

// insertValues('Hello $, how are you?', ['John'])
// log: Hello John, how are you?

Of course, you can enhance this function by adding numbering, like $1.

3. Setting Up Redux

First, let's create the _redux folder. I created it in the src folder. And install react-redux and @reduxjs/toolkit.

npm install react-redux @reduxjs/toolkit

After installing react-redux and @reduxjs/toolkit, we'll configure store.ts and create LocaleSlice.ts:

/src
  /_redux
    store.ts
    /Slices
      LocaleSlice.ts

Configuring 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>
// Infer the `RootState` and `AppDispatch` types from the store itself
export type RootState = ReturnType<AppStore['getState']>
// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
export type AppDispatch = AppStore['dispatch']

export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()
export const useAppStore = useStore.withTypes<AppStore>()

Configuring 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
        }
    },
})

// Extract and export each action creator by name
export const { updateLocale } = LocaleSlice.actions
// Export the reducer, either as a default or named export

LocaleSlice.ts is needed to store the current locale. Later we can easily retrieve the locale using useAppSelector created in store.ts.

After configuring store.ts and LocaleSlice.ts, we need to create 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>
}

I found this trick in the Redux documentation. Here we create the store and immediately inject the locale into it. As a result - when the client initializes, Redux will already have the locale we need in the store.

4. Creating Navigation Helper Components

The penultimate step is creating client-side locale switching functionality and additional components and functions on top of Next.js navigation API.

Let's start with the locale switching function.

// useChangeLocale.ts
'use client';
import { locales, LocaleType } from '../locales';
import { changeLocale } from '../../../serverAction/changeLocale';
import useLocalizedRouter from './LocalizedUseRouter';
//We'll create a useLocalizedRouter later

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 })
    }
}

This hook returns a function that accepts the user's preferred locale. Of course, you can easily write your own function or modify this logic. The changeLocale function is a Server function, you create it in a place convenient for you. Learn more on https://react.dev/reference/rsc/server-functions

Next, we'll create LocalizedLink.tsx, which is an enhanced version of the Link component:

'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; // Return original href for anchors
  }

  return (
    <Link href={localizedHref} {...props}>{children}</Link>
  );
}

export default LocalizedLink;

As you can see, we get the locale from the Redux store configured in the previous section. It's important to use LocalizedLink everywhere instead of Link - this will eliminate unnecessary redirects (improving performance) and prevent unwanted effects related to static page caching.

Let's create a similar version for the hook in the same way. Use Router, but first install sanitizeUrl

npm install @braintree/sanitize-url

And create a Use Router

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. Creating Static Routes

In the last chapter, we are waiting for the simplest step, adding the generateStaticParams function to add statics wherever possible.
I recommend calling this function in the root Layout.tsx to immediately add a value to the lang attribute of the html tag. And also add the Store Provider created at stage 3.

Example of root 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>
  );
}

Of course, you could set dynamicParams=false and skip locale validation, but this would disable dynamic page generation for all routes not included in generateStaticParams across your entire application.

Now, to extract data from dictionaries, follow these steps:

  1. Read locale from params:
  const { locale } = await params
  1. Pass the locale to getDictionary function:
  const dict = (await getDictionary((locale))).your.path.to.data

Important note: You don't necessarily need to await params in your page or layout, especially if they're not needed there - you can pass the params Promise to the component where it's actually needed and extract the locale there.

Example:

//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;
    //...
}

If you want to read data on the client side, use useAppSelector:

const locale = useAppSelector(state => state.locale.locale)

Conclusion

You've now set up a highly flexible internationalization system for your website. You can add custom functionality or completely rewrite the logic. In this example, Redux isn't strictly necessary - you could remove it and replace httpOnly cookies with regular cookies, giving you access to the locale simply by reading the cookie value. I sincerely hope this guide has helped you solve some of the challenges related to application internationalization.

An example of the finished project can be found on GitHub: https://github.com/zh-blog-ru/test_internationalization

Comments

    0 / 512
    Loading...