Next.js internationalization (i18n) tutorial

By admin
All code samples used in this section are available on the

GitHub
ORG

repo.

Create a new Next.js project


First
ORDINAL

, let’s create a new Next.js project with the create-next-app

CLI
PRODUCT

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
ORG

? [Yes] Would you like to use ESLint? [Yes] Would you like to use

Tailwind
PRODUCT

CSS? [No] Would you like to use `src/` directory? [Yes] Would you like to use

App Router
PRODUCT

? (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
GPE

,

next-intl
ORG

, and similar). For this guide,

React Intl
FAC

is our choice due to its wide use and advanced features such as

ICU
ORG

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
ORG

is not completely automated and requires a bit of manual work. To start, let’s

first
ORDINAL

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
ORG

= 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
CARDINAL

JSON files named

ar.json , en.json
ORG

,

fr.json
ORG

, and

nl-NL.json
ORG

. These files will contain the translations for

Arabic
LANGUAGE

,

English
LANGUAGE

,

French
NORP

, and

Dutch
NORP

(

Netherlands
GPE

), respectively.

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

The ar.json file:

{ "common.footer": "

دروس التدويل
PERSON

", "page.home.head.title": "مثال

على Next.js i18n
PERSON

", "page.home.head.meta.description": "

مثال Next.js i18n – عربي
WORK_OF_ART

", "page.home.title": "

مرحبًا
PERSON

بك في <b>

البرنامج التعليمي
PERSON

Next.js i18n </b>", "page.home.description": "أنت الآن تستعرض الصفحة

الرئيسية بالعربية 🚀
ORG

" }

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
LANGUAGE

", "page.home.title": "Welcome to <b>Next.js i18n tutorial</b>", "page.home.description": "You are currently viewing the homepage in

English
LANGUAGE

🚀" }

The

fr.json
PERSON

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
WORK_OF_ART

>Next.js i18n didacticiel</b>", "page.home.description": "

Vous
ORG

consultez actuellement

la page d’accueil en Français 🚀
ORG

" }

The

nl-NL.json
PRODUCT

file:

{ "common.footer": "handleiding internationalisering", "page.home.head.title": "Next.js i18n

voorbeeld
PERSON

", "page.home.head.meta.description": "Next.js i18n

voorbeeld – Nederlands
ORG

(

Nederland
GPE

)", "page.home.title": "

Welkom bij <b>Next.js i18n
WORK_OF_ART

zelfstudie</b>", "page.home.description": "U bekijkt momenteel de homepage in

het Nederlands
GPE

(

Nederland
GPE

) 🚀" }

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
ORG

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
FAC

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
FAC

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
FAC

library in client components, we

first
ORDINAL

need to set up the

IntlProvider
ORG

. For that purpose, we will add a components directory within the src directory. Inside the components directory, let’s create a

Footer
PERSON

directory, and add

two
CARDINAL

files within it, namely FooterContainer.tsx and

Footer.tsx
PERSON

.

Below, you will find a FooterContainer component that loads localization messages and configures the

IntlProvider
ORG

, enabling child components to easily access the appropriate localization messages.

The FooterContainer.tsx file:

import React from "react"; import {

IntlProvider
ORG

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

locale={locale} messages={messages}> <div className="footer">{children}</div> </IntlProvider> ); } export default FooterContainer;

The

Footer
PERSON

component displayed below is a client component that accesses localization messages declaratively using the

FormattedMessage
ORG

component.

The

Footer.tsx
PERSON

file:

"use client"; import {

FormattedMessage
ORG

} from "react-intl"; import FooterContainer from "./FooterContainer"; function

Footer
PERSON

({ locale }: { locale: string }) { return ( <FooterContainer locale={locale}> <div> <FormattedMessage tagName="p"

id="common.footer
PERSON

" /> </div> </FooterContainer> ); } export default

Footer
PERSON

;

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
ORG

page.


First
ORDINAL

, let’s update the

RootLayout
ORG

component to properly set the lang attribute on the html element and to include the

Footer
PERSON

component below the content of the

Home
ORG

page.

The layout.tsx file:

import

Footer
PERSON

from "../../components/Footer/Footer"; import "./globals.css"; type LayoutProps = { params: { locale: string }; children: React.ReactNode; }; export default function

RootLayout
ORG

({ 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
CARDINAL

localization messages.

The page.tsx file:

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

HomeProps
PERSON

= { params: { locale: string }; }; export default async function Home({ params: { locale } }:

HomeProps
PERSON

) { const intl = await

getIntl(locale
ORG

); 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
ORG

is ltr . In most cases, you don’t need to configure anything. However, when

one
CARDINAL

of the languages is rtl , you need to set the proper text direction for your pages. In our case,

Arabic
LANGUAGE

is an rtl language, so we need to handle that. For that purpose, we extended the intl.ts file with the

getDirection
PRODUCT

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
ORG

component with text directionality.

The layout.tsx file:

import

Footer
PERSON

from "../../components/Footer/Footer"; import {

getDirection
PRODUCT

} from "../../lib/intl"; import "./globals.css"; type LayoutProps = { params: { locale: string }; children: React.ReactNode; }; export default function

RootLayout
ORG

({ params, children }: LayoutProps) { const { locale } = params; const dir = getDirection(locale); return ( <html lang={locale}

dir={dir
PERSON

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

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
ORG

,

ResolvingMetadata
ORG

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

RouteProps
ORG

= { params: { locale: string }; searchParams: { [key: string]: string | string[] | undefined }; }; export async function generateMetadata( props:

RouteProps
ORG

, 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
PERSON

", en: "http://example.com", fr: "http://example.com/fr", "nl-NL": "http://example.com/nl-NL", "x-default": "http://example.com", }, }, }; } type

HomeProps
PERSON

= { … }; export default async function Home({ params: { locale } }:

HomeProps
PERSON

) { … }

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
PERSON

directory within the components directory. Within the Header directory, we will add a

Header.tsx
ORG

file, which will include the code for our basic language switching functionality.

The

Header.tsx
EVENT

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
ORG

to encompass the Header component that features the language switcher.

The layout.tsx file:

import Header from "../../components/Header/Header"; import

Footer
PERSON

from "../../components/Footer/Footer"; import {

getDirection
PRODUCT

} from "../../lib/intl"; import "./globals.css"; type LayoutProps = { … }; export default function

RootLayout
ORG

({ params, children }: LayoutProps) { const { locale } = params; const dir = getDirection(locale); return ( <html lang={locale}

dir={dir
PERSON

}> <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/
GPE

➝ Not found (fixed in upcoming chapter)


http://localhost:3000
PERSON

/ar ➝ Arabic

http://localhost:3000/fr ➝

French
NORP

http://localhost:3000/nl-

NL
GPE



Dutch
NORP

(

Netherlands
GPE

)

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
PRODUCT

library.

npm i negotiator npm i

@types
PERSON

/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
ORG

file:

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

NextRequest
ORG

} from "next/server"; import { match } from "@formatjs/intl-localematcher"; import

Negotiator
WORK_OF_ART

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

I18nConfig
ORG

} from "../i18n-config"; function getLocale(request:

NextRequest
ORG

,

i18nConfig
ORG

:

I18nConfig
ORG

): 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
ORG

) { 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
ORG

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
PERSON

", 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.

Run the app on the localhost

npm run dev

Update browser language to

Arabic
LANGUAGE

,

English
LANGUAGE

,

French
NORP

, or

Dutch
NORP

(Netherlands)

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
LANGUAGE

➝ http://localhost:3000/ar


French
NORP



http://localhost:3000
PERSON

/fr


Dutch
NORP

(

Netherlands
GPE

) ➝

http://localhost:3000
PERSON

/nl-

NL
GPE

Other langauges ➝ http://localhost:3000

Note: Automatic locale detection will be performed only when a user visits the app’s homepage for the

first
ORDINAL

time. To test this feature for other app languages, clear the

NEXT_LOCALE
ORG

cookie

first
ORDINAL

.

Keep selected language in cookie for future visits

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
CARDINAL

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
ORG

= nextConfig;

The page.tsx file: