Zoran Luledzija
Zoran Luledzija
November 07, 2023
25 min read
November 07, 2023
25 min read

Next.js internationalization (i18n) tutorial

Next.js internationalization (i18n) tutorial

Next.js is an open-source framework created by Vercel (formerly ZEIT). It is built atop React and designed to simplify the development of full-stack web applications. Its popularity has surged recently due to its impressive performance, an excellent developer experience, and a wide range of available libraries. Additionally, it offers out-of-the-box solutions for various rendering methods, such as Static Site Generation, Server-side Rendering, and Client-side Rendering, making it a versatile and user-friendly framework for diverse web applications.

Although a relatively young framework, Next.js has a solid foundation for internationalization (i18n) and works well with most i18n libraries. However, recent routing updates have introduced complexities to its i18n approach. These complexities arise from multiple factors, such as the Next.js version, the chosen routing method, and the i18n library in use. Aiming for clarity and simplicity, this guide will illustrate the integration of the React Intl library and explore both the App Router and Pages Router methods.

All code samples used in this section are available on the GitHub repo.

Create a new Next.js project

First, let's create a new Next.js project with the create-next-app CLI tool. This tool is maintained by the creators of Next.js and will make the creation process easier for us. By running the command below, we will create a new Next.js project called nextjs-app-router-i18n-example in a directory with the same name.

npx create-next-app@latest nextjs-app-router-i18n-example

Since this command operates in interactive mode, we will go with the default settings for the sake of this tutorial.

Would you like to use TypeScript? [Yes]
Would you like to use ESLint? [Yes]
Would you like to use Tailwind CSS? [No]
Would you like to use `src/` directory? [Yes]
Would you like to use App Router? (recommended) [Yes]
Would you like to customize the default import alias (@/*)? [No]

Add React Intl dependency

Next.js works well with most i18n libraries (react-intl, lingui, next-intl, and similar). For this guide, React Intl is our choice due to its wide use and advanced features such as ICU syntax. To add it, enter the nextjs-app-router-i18n-example directory and install the react-intl.

cd nextjs-app-router-i18n-example
npm i react-intl

Add config for internationalized routing

Configuring internationalized routing in apps based on App Router is not completely automated and requires a bit of manual work. To start, let's first create an i18n-config.ts file in the root of the project and fill it with our i18n configuration. The locales represent the list of locales we would like to support in our app, and the defaultLocale represents the default locale of the app, and the one that will be used when visiting non-locale-prefixed paths.

The i18n-config.ts file:

export const i18n = {
  locales: ["en", "ar", "fr", "nl-NL"],
  defaultLocale: "en",
};

export type I18nConfig = typeof i18n;
export type Locale = I18nConfig["locales"][number];

After creating the configuration file, the next step involves updating the app directory to support internationalized routing. To accomplish this, we need to put all page files within the [locale] directory. That way, app pages will have access to the currently used locale and be able to display appropriate localization messages.

nextjs-app-router-i18n-example
|-- public
|-- src
|   |-- app
|   |   |-- [locale]
|   |   |   |-- globals.css
|   |   |   |-- layout.tsx
|   |   |   |-- page.module.css
|   |   |   |-- page.tsx
|   |   |-- favicon.ico
|-- ...
|-- i18n-config.ts
|-- next.config.js
|-- package.json
|-- package-lock.json

Create localization files

The next step is to add localization files. For that purpose, let's create a lang directory within the src directory. Inside it, add four JSON files named ar.json, en.json, fr.json, and nl-NL.json. These files will contain the translations for Arabic, English, French, and Dutch (Netherlands), respectively.

Afterward, fill in localization files with messages that we will use later.

The ar.json file:

{
  "common.footer": "دروس التدويل",
  "page.home.head.title": "مثال على Next.js i18n",
  "page.home.head.meta.description": "مثال Next.js i18n - عربي",
  "page.home.title": "مرحبًا بك في <b> البرنامج التعليمي Next.js i18n </b>",
  "page.home.description": "أنت الآن تستعرض الصفحة الرئيسية بالعربية 🚀"
}

The en.json file:

{
  "common.footer": "internationalization tutorial",
  "page.home.head.title": "Next.js i18n example",
  "page.home.head.meta.description": "Next.js i18n example - English",
  "page.home.title": "Welcome to <b>Next.js i18n tutorial</b>",
  "page.home.description": "You are currently viewing the homepage in English 🚀"
}

The fr.json file:

{
  "common.footer": "tutoriel d'internationalisation",
  "page.home.head.title": "Next.js i18n exemple",
  "page.home.head.meta.description": "Next.js i18n exemple - Français",
  "page.home.title": "Bienvenue à <b>Next.js i18n didacticiel</b>",
  "page.home.description": "Vous consultez actuellement la page d'accueil en Français 🚀"
}

The nl-NL.json file:

{
  "common.footer": "handleiding internationalisering",
  "page.home.head.title": "Next.js i18n voorbeeld",
  "page.home.head.meta.description": "Next.js i18n voorbeeld - Nederlands (Nederland)",
  "page.home.title": "Welkom bij <b>Next.js i18n zelfstudie</b>",
  "page.home.description": "U bekijkt momenteel de homepage in het Nederlands (Nederland) 🚀"
}

Configure react-intl in Next.js project

Configuring React Intl with Next.js largely depends on how localization messages are used and whether they need to be accessed on the server side or the client side. Knowing that Next.js is a highly flexible framework, and that typical usage mostly combines both server-side and client-side approaches, in the following sections we will cover both approaches in more detail.

Server components

The use of server components in Next.js offers several advantages, such as the ability to render UI components on the server, reduce the time needed to fetch data for rendering, caching, and similar tasks. However, these components are distinct from traditional client-side components as they lack access to certain React features like context providers and side effects.

When it comes to the React Intl library and its usage for server-side rendering, an alternative method must be used to access localization messages since this library relies on the provider pattern. To overcome this limitation, we will create a lib directory in our project's src directory. Within this lib directory, we will create a file named intl.ts. In this file, we will define a function called getIntl that will use the core functionality of the React Intl library to access localization messages, allowing us to circumvent these limitations.

The intl.ts file:

import "server-only";

import { createIntl } from "@formatjs/intl";
import type { Locale } from "../../i18n-config";

export async function getIntl(locale: Locale) {
  return createIntl({
    locale: locale,
    messages: (await import(`../lang/${locale}.json`)).default,
  });
}

Client components

Just as server components have their benefits, client components do too. They facilitate the development of interactive elements utilizing event handlers, states, effects, and similar functions, as well as the browser API.

In order to use the React Intl library in client components, we first need to set up the IntlProvider. For that purpose, we will add a components directory within the src directory. Inside the components directory, let's create a Footer directory, and add two files within it, namely FooterContainer.tsx and Footer.tsx.

Below, you will find a FooterContainer component that loads localization messages and configures the IntlProvider, enabling child components to easily access the appropriate localization messages.

The FooterContainer.tsx file:

import React from "react";
import { IntlProvider } from "react-intl";

async function getMessages(locale: string) {
  return await import(`../../lang/${locale}.json`);
}

type FooterContainerProps = {
  locale: string;
  children: React.ReactNode;
};

async function FooterContainer({ locale, children }: FooterContainerProps) {
  const messages = await getMessages(locale);

  return (
    <IntlProvider locale={locale} messages={messages}>
      <div className="footer">{children}</div>
    </IntlProvider>
  );
}

export default FooterContainer;

The Footer component displayed below is a client component that accesses localization messages declaratively using the FormattedMessage component.

The Footer.tsx file:

"use client";

import { FormattedMessage } from "react-intl";

import FooterContainer from "./FooterContainer";

function Footer({ locale }: { locale: string }) {
  return (
    <FooterContainer locale={locale}>
      <div>
        <FormattedMessage tagName="p" id="common.footer" />
      </div>
    </FooterContainer>
  );
}

export default Footer;

Adapt pages for i18n

Now when we have created a way to access localization messages on the server side and on the client side, let's merge these pieces together in our Home page.

First, let's update the RootLayout component to properly set the lang attribute on the html element and to include the Footer component below the content of the Home page.

The layout.tsx file:

import Footer from "../../components/Footer/Footer";
import "./globals.css";

type LayoutProps = {
  params: { locale: string };
  children: React.ReactNode;
};

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

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

Next, let's update Home page content so it also shows two localization messages.

The page.tsx file:

import { getIntl } from "../../lib/intl";
import styles from "./page.module.css";

type HomeProps = {
  params: { locale: string };
};

export default async function Home({ params: { locale } }: HomeProps) {
  const intl = await getIntl(locale);

  return (
    <div className={styles.container}>
      <main className={styles.main}>
        <h1 className={styles.title}>
          {intl.formatMessage(
            { id: "page.home.title" },
            { b: (chunks) => <b key="bold">{chunks}</b> }
          )}
        </h1>

        <p className={styles.description}>
          {intl.formatMessage({ id: "page.home.description" })}
        </p>
      </main>
    </div>
  );
}

Determine text direction

When it comes to text direction, languages can be ltr (Left-to-Right) or rtl (Right-to-Left). The default text direction in HTML is ltr. In most cases, you don't need to configure anything. However, when one of the languages is rtl, you need to set the proper text direction for your pages. In our case, Arabic is an rtl language, so we need to handle that. For that purpose, we extended the intl.ts file with the getDirection function. This function returns the text direction for the passed locale. Later in the code, we will use that function and apply its response to set the appropriate dir attribute for the html element.

The intl.ts file:

import "server-only";

import { createIntl } from "@formatjs/intl";
import type { Locale } from "../../i18n-config";

export async function getIntl(locale: Locale) { ... }

export function getDirection(locale: Locale) {
  switch (locale) {
    case "ar":
      return "rtl";
    case "en":
    case "fr":
    case "nl-NL":
      return "ltr";
  }
}

Now that we've added a function that returns the appropriate text direction for a locale, let's use it in our app. The easiest way to set the text direction is to set it on the html element, as the directionality set here will propagate to all child elements in the HTML tree that do not have an explicitly set text directionality. For that purpose, let's update our RootLayout component with text directionality.

The layout.tsx file:

import Footer from "../../components/Footer/Footer";
import { getDirection } from "../../lib/intl";
import "./globals.css";

type LayoutProps = {
  params: { locale: string };
  children: React.ReactNode;
};

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

  const dir = getDirection(locale);

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

Add hreflang tags

The hreflang tag is a way to tell search engines which language you are using on a specific page, as well as the other language variants of that page. Doing so will help them present users with the most appropriate version of your page. In this post, we will not delve into the details of hreflang tags, but it should be noted that including them is a good practice for improved SEO. Also, ensure that href attribute values are updated to correspond with your domain. Additionally, for enhanced SEO and better keyword targeting, consider localizing the title and meta description of your page, as illustrated in the example below.

import { Metadata, ResolvingMetadata } from "next";

import { getIntl } from "../../lib/intl";
import styles from "./page.module.css";

type RouteProps = {
  params: { locale: string };
  searchParams: { [key: string]: string | string[] | undefined };
};

export async function generateMetadata(
  props: RouteProps,
  parent: ResolvingMetadata
): Promise<Metadata> {
  const intl = await getIntl(props.params.locale);

  return {
    title: intl.formatMessage({ id: "page.home.head.title" }),
    description: intl.formatMessage({
      id: "page.home.head.meta.description",
    }),
    alternates: {
      canonical: "https://example.com",
      languages: {
        ar: "http://example.com/ar",
        en: "http://example.com",
        fr: "http://example.com/fr",
        "nl-NL": "http://example.com/nl-NL",
        "x-default": "http://example.com",
      },
    },
  };
}

type HomeProps = { ... };

export default async function Home({ params: { locale } }: HomeProps) { ... }

Add language switcher

Incorporating a feature that allows users to change the language on the app is essential if we offer multilingual support. The ability to switch languages enhances the user's experience and satisfaction.

In this blog post, we're going to implement a basic language switcher. To do this, we will create a new Header directory alongside the existing Footer directory within the components directory. Within the Header directory, we will add a Header.tsx file, which will include the code for our basic language switching functionality.

The Header.tsx file:

import Link from "next/link";
import { i18n } from "../../../i18n-config";

function Header() {
  const { locales, defaultLocale } = i18n;

  return (
    <header>
      <div dir="ltr" className="languages">
        {[...locales].sort().map((locale) => (
          <Link
            key={locale}
            href={locale === defaultLocale ? "/" : `/${locale}`}
          >
            {locale}
          </Link>
        ))}
      </div>
    </header>
  );
}

export default Header;

Having created the language switcher, it's time to embed it into our application by updating the RootLayout to encompass the Header component that features the language switcher.

The layout.tsx file:

import Header from "../../components/Header/Header";
import Footer from "../../components/Footer/Footer";
import { getDirection } from "../../lib/intl";
import "./globals.css";

type LayoutProps = { ... };

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

  const dir = getDirection(locale);

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

Now, when we've configured most of the things, we could run our Next.js app. Running the following command will allow us to see how our app looks on the localhost.

npm run dev

http://localhost:3000/ ➝ Not found (fixed in upcoming chapter)

http://localhost:3000/ar ➝ Arabic

http://localhost:3000/fr ➝ French

http://localhost:3000/nl-NL ➝ Dutch (Netherlands)

Note: In the interest of simplicity, this post omits styling. For access to the full code, please visit the corresponding GitHub repo.

Automatic locale detection

The automatic locale detection is a handy feature. It enables us to easily identify the user's preferred language from the Accept-Language header. This kind of functionality is not only related to web apps but is also used in other areas like mobile app development where the app preselects the language that best aligns with the user's phone settings.

To easily extract the user's preferred languages from the Accept-Language header, we will use the Negotiator library.

npm i negotiator
npm i @types/negotiator --save-dev

Now that we've added the required dependencies, let's create a middleware.ts file within the src directory. This file will contain middleware that will detect the language from the Accept-Language header and route to the appropriate page variant. Additionally, we will enhance this middleware to identify the default locale, and in the case of a request for the default locale, instruct the middleware to redirect to a path without a locale prefix.

The middleware.ts file:

import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

import { match } from "@formatjs/intl-localematcher";
import Negotiator from "negotiator";

import { i18n } from "../i18n-config";
import type { I18nConfig } from "../i18n-config";

function getLocale(request: NextRequest, i18nConfig: I18nConfig): string {
  const { locales, defaultLocale } = i18nConfig;

  const negotiatorHeaders: Record<string, string> = {};
  request.headers.forEach((value, key) => (negotiatorHeaders[key] = value));

  const languages = new Negotiator({ headers: negotiatorHeaders }).languages(
    locales
  );

  return match(languages, locales, defaultLocale);
}

export function middleware(request: NextRequest) {
  let response;
  let nextLocale;

  const { locales, defaultLocale } = i18n;

  const pathname = request.nextUrl.pathname;

  const pathLocale = locales.find(
    (locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
  );

  if (pathLocale) {
    const isDefaultLocale = pathLocale === defaultLocale;
    if (isDefaultLocale) {
      let pathWithoutLocale = pathname.slice(`/${pathLocale}`.length) || "/";
      if (request.nextUrl.search) pathWithoutLocale += request.nextUrl.search;

      response = NextResponse.redirect(new URL(pathWithoutLocale, request.url));
    }

    nextLocale = pathLocale;
  } else {
    const isFirstVisit = !request.cookies.has("NEXT_LOCALE");

    const locale = isFirstVisit ? getLocale(request, i18n) : defaultLocale;

    let newPath = `${locale}${pathname}`;
    if (request.nextUrl.search) newPath += request.nextUrl.search;

    response =
      locale === defaultLocale
        ? NextResponse.rewrite(new URL(newPath, request.url))
        : NextResponse.redirect(new URL(newPath, request.url));
    nextLocale = locale;
  }

  if (!response) response = NextResponse.next();

  if (nextLocale) response.cookies.set("NEXT_LOCALE", nextLocale);

  return response;
}

export const config = {
  matcher: "/((?!api|_next/static|_next/image|img/|favicon.ico).*)",
};

To check how this works, let's run the Next.js app on the localhost and try to open it with different browser settings.

  1. Run the app on the localhost
npm run dev
  1. Update browser language to Arabic, English, French, or Dutch (Netherlands)

Chrome languages settings

  1. Open the app in browser

Whenever we change the browser language and open http://localhost:3000, we will be redirected to the appropriate page.

Arabic ➝ http://localhost:3000/ar

French ➝ http://localhost:3000/fr

Dutch (Netherlands) ➝ http://localhost:3000/nl-NL

Other langauges ➝ http://localhost:3000

Note: Automatic locale detection will be performed only when a user visits the app's homepage for the first time. To test this feature for other app languages, clear the NEXT_LOCALE cookie first.

It's important to acknowledge that Next.js simplifies the process of handling cookies within its middleware, providing a straightforward way to save the user's preferred language setting. This ensures that every subsequent request is informed of the user's language preference. While this tutorial has demonstrated how to save the preferred locale via cookies, we haven't explained how to redirect users to their preferred language pages based on cookie values. We'll bypass this aspect for now to maintain a simple tutorial structure.

Nevertheless, when deciding to leverage cookies for managing language settings, consider the following:

  • Determine the optimal cookie duration for your scenario, whether it be for a session or a specified period such as a day or a month.
  • Assess any complications that may arise with Next.js's caching mechanisms.
  • Ensure your application can effectively determine the user's preferred language, particularly when there's a discrepancy between the locale indicated by the cookie and the one suggested by the Accept-Language header.

Static HTML export

Web apps don't always require databases or servers; many websites function optimally with only static files, which are simple to host and often cost little to nothing. In the end, it all depends on the purpose for which the website is used. The Next.js framework supports static exports. Below are two code snippets that would allow us to generate static HTML files in our Next.js project. It's important to recognize, though, that with these snippets, static HTML files are the only result; dynamic features like user redirection or detection of Accept-Language header aren't possible. While there are some solutions and workarounds to these limitations, this guide will not explore them in order to keep things simple and straightforward.

The next.conig.js file:

/** @type {import('next').NextConfig} */
const nextConfig = {
  output: "export",
};

module.exports = nextConfig;

The page.tsx file:

import { Metadata, ResolvingMetadata } from "next";

import { getIntl } from "../../lib/intl";
import { i18n } from "../../../i18n-config";
import styles from "./page.module.css";

type RouteProps = { ... };

export async function generateMetadata( ... ): Promise<Metadata> { ... }

type HomeProps = { ... };

export default async function Home({ params: { locale } }: HomeProps) { ... }

export async function generateStaticParams() {
  const { locales } = i18n;

  return locales.map((locale) => ({
    locale: locale,
  }));
}

Managing your Next.js translations

Easy to manage a couple of strings in two languages? Sure. But as you add more features and expand to more markets, your app will grow to thousands of strings in a dozen of languages. Here’s how to streamline your app translation process:

Step 1: Create a project in Localizely

Once you sign up to Localizely, just go to My projects page and tap “+” button (or explore the Sample Project if you wish to get more familiar with Localizely features first). Give your project a name and set your main and other languages. You can change the main language later if needed.


NextJS localization create project


Each project gets a unique ID (you can find it in My projects page), that is needed when using API.

Step 2: Upload your files

Import your main JSON file to Localizely. Go to Upload page, select the file, and confirm. Alternatively, you can start by adding string keys in Localizely first.


NextJS localization JSON file upload


Step 3: Invite team members

Localization work is a team effort. Switch to the Contributors section by clicking the icon in the side menu and start adding teammates. Any user can be granted admin privileges, i.e. the same rights on the project as you. For non-admin contributors, you can specify per-language access to the project specifying some languages as reference (read-only) or contributable (read and update). Only admins have access to string key changes, importing, exporting, settings, etc.


NextJS localization invite team member


Step 4: Translate

Localizely has an editor on Translations page that reminds of Excel tables commonly used for translations management. Feel free to explore the Sample project or just take a look at Getting started guide to grasp the basic concept and options.


NextJS localization translate


Step 5: Download the files

The translation part is done. How to get the localization data out of Localizely and make it usable in your Next.js app?

There are 2 options:

  • Option 1: Download manually

Click Download icon in your project side menu and select Key-Value JSON (.json) as the exporting format. Click Download to get the file. Then move downloaded .json file into your project replacing the existing localization, that’s all.


NextJS localization download JSON file


  • Option 2: Download via API

Depending on your project deployment setup, you may want to use Localizely API – it gives you a simple way to automatically generate and download localization files.

Conclusion

Next.js offers pretty good support for internationalization, though recent routing updates have added some complexity. Setting up may take effort, but the system runs smoothly afterwards. We've shown how to integrate the React Intl library with Next.js, utilizing both the App Router and Pages Router for routing. Next.js is pretty much compatible with other internationalization libraries as well, and from a technical perspective, there is nothing that could raise concerns. But what about translation? It is a more complex process that follows the entire lifecycle of the application. Imagine how difficult it can be to share localization files with non-technical people through emails, translate, chat about changes, and sync everything later. Luckily, the Localizely platform can help you with that. It is a platform that facilitates collaboration. It offers translation through tasks, glossary, history of changes, and much more.

Try Localizely for free.

Like this article? Share it!


Zoran Luledzija
Zoran Luledzija

Zoran is a Software Engineer at Localizely. His primary interest is web development, but he also has a solid background in other technologies. For the last few years, he has been involved in the development of software localization tools.

Enjoying the read?

Subscribe to the Localizely blog newsletter for quality product content in your inbox.

Related

Angular internationalization (i18n) tutorial
November 21, 2023
In “Coding
React internationalization with react-intl
November 15, 2023
In “Coding
Flutter localization: step-by-step
November 09, 2023
In “Coding
Copyrights 2024 © Localizely