Software localization

Next.js Localization with Format.JS/react-intl

Explore the ins and outs of localizing Next.js Pages Router apps with react-intl/Format.JS.
Blog post featured image | Phrase

Next.js Pages Router localization has become a streamlined feature of the leading full-stack React framework since the advent of Next.js 10, which brought us helpful localized routing capabilities.

Adding the robust react-intl/Format.JS i18n library takes this a step further, offering a comprehensive solution that includes production-grade translation message management, along with refined date and number formatting.

What sets react-intl/Format.JS apart from other i18n libraries is its advanced translation message extraction and compilation, allowing our apps to scale gracefully and efficiently. In this tutorial, we explore the ins and outs of localizing Next.js Pages Router apps with react-intl/Format.JS.

String Management UI visual | Phrase

Phrase Strings

Take your web or mobile app global without any hassle

Adapt your software, website, or video game for global audiences with the leanest and most realiable software localization platform.

Explore Phrase Strings

💡 Internationalization (i18n) and localization (l10n) allow us to make our apps available in different languages and to different regions, often for more profit. If you’re new to i18n and l10n, check out our guide to internationalization.

🗒️ Format.JS is a set of ICU-compliant JavaScript i18n libraries. react-intl extends Format.JS with handy React components and hooks. In this article, where we focus on React/Next.js, we will use the terms “react-intl” and “Format.JS” interchangeably.

🔗 This article is focused on the Next.js Pages Router. If you’re working with the App Router, check out our Deep Dive into Next.js App Router Localization with next-intl—and if you prefer working with the Pages Router and the popular i18next library, our Step-by-Step Guide to Next.js Internationalization has you covered.

Our demo app

We will use a simple blog to demo the i18n in this guide. Here’s what the app looks like before localization.

The blog post index page | Phrase

Single post page | Phrase

📣 Shoutout » Thanks to Scotty G for creating the fun Star Wars lorem ipsum generator, Forcem Ipsum, which we’ve used for our mock content.

After spinning up a Next.js app with the Pages Router, TypeScript, and Tailwind, we created/modified the following files to build the demo:

.
└── pages
    ├── components
    │   └── Layout.tsx  # Wraps our pages
    ├── data
    │   └── posts.ts    # Mock blog post data
    ├── posts
    │   ├── [slug].tsx  # Single blog post
    │   └── index.tsx   # Blog post listing
    └── index.tsx       # Home pageCode language: plaintext (plaintext)

It’s all bread-and-butter Next.js, and we will get into the details of these files as we internationalize. Let’s get to it.

🔗 Resource » If you want to code along with us, you can grab the starter demo app from our GitHub repo. (Find the starter in the /pages-router/start directory).

Package versions used

Here are the relevant NPM packages we’ve used in this tutorial.

Package Version Notes
typescript 5.2.
next 14,9
react 18.2
react-intl 6.5 Used for i18n
@formatjs/cli 6.2 Used for message extraction and compilation
babel-plugin-formatjs 10.5 Used for automatic message ID injection
nookies 2.5 Used for setting the Next.js locale cookie
accept-language-parser 1.5 Used for custom locale auto-detection
rtl-detect 1.1 Used for right-to-left (rtl) locale detection
tailwindcss 3.3 Used for styling (and out of the scope of this tutorial)

How do I localize my Pages Router app with react-intl?

Next.js Pages Router apps need to manage server-side rendering (SSR) using getStaticProps() and static site generation (SSG) with getStaticPaths(). With that in mind, here are the basic steps to localizing a Next.js Pages router with react-intl:

  1. Configure built-in Next.js i18n routing.
  2. Install and configure react-intl.
  3. Localize page/component strings using react-intl.
  4. Extract translation messages from our codebase using the Format.JS CLI.
  5. Translate messages into supported languages.
  6. Load translation messages on the server using getStaticProps().
  7. Format dates and numbers using react-intl.

In the following sections, we will cover these steps and more, including compiling messages for production, writing custom middleware for locale auto-detection, and adding a language selector for our site visitors.

Let’s work through in one step at a time.

How do I configure the built-in Next.js i18n routing?

Recent versions of Next.js come with localized routing out-of-the-box. This automatically adds our supported locales to routes, e.g. /fr/posts for our posts page in French.

Let’s see how this works by adding it to our demo. We will support English (USA) and Arabic (Egypt) here. Feel free to use any locales you want.

A note on locales

Locales typically use IETF BCP 47 language tags, like en for English, fr for French, and es for Spanish. Adding a region with the ISO Alpha-2 code (e.g., BH for Bahrain, CN for China, and US for the United States) is recommended for accurate date and number localization. So a complete locale might look like en-US for American English or zh-CN for Chinese in China.

🔗 Explore more language tags on Wikipedia and find country codes through the ISO’s search tool.

To enable localized routing, we need to add an i18n section to our next.config.js:

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
+ i18n: {
+   locales: ["en-US", "ar-EG"], // required
+   defaultLocale: "en-US",      // required
+ },
};

module.exports = nextConfig;Code language: Diff (diff)

✋ Both locales and defaultLocale are required in Next.js. If either is missing or invalid, Next.js will trigger an error during development server runs and production builds.

Just with that, Next.js creates an /ar-EG prefix for all our routes. We started with these routes in our app:

Route Description
/ home page
/posts blog post index
/posts/foo-bar single blog post

After adding the above i18n config, we now have the following routes:

Route Active locale Description
/ English (en-US) home page
/ar-EG Arabic (ar-EG) home page
/posts English (en-US) blog post index
/ar-EG/posts Arabic (ar-EG) blog post index
/posts/foo-bar English (en-US) single blog post
/ar-EG/posts/foo-bar Arabic (ar-EG) single blog post

For every locale included in the locales array of our config, a corresponding route prefix is created. The default route (en-US in our case) doesn’t receive any prefix.

🗒️ It’s worth noting, though, that the Next.js documentation offers a workaround for redirecting unprefixed URLs, e.g. /, to prefixed URLs, such as /en-US. This approach, however, comes with its own set of challenges, such as the need to juggle a placeholder default locale in the supported locales list, and potential errors during production builds when Next.js attempts to build pages with this placeholder default locale.

Moving on, Next.js also provides access to the active locale through its router object. Let’s make use of this in our single post page to show the translated blog post.

Our hard-coded mock data is already localized:

// data/posts.ts

import type { Post } from "@/types";

export const posts: Post[] = [
  {
    slug: "marching-into-detention-area",
    date: "2023-11-10",
    translations: {
      "en-US": {
         title: "Marching into the...",
         content: "What an incredible...",
      },
      "ar-EG": {
         title: "إن الزحف إلى منطقة الاحتجاز لم...",
         content: "يا لها من رائحة مذهلة اكتشفتها...",
      },
    },
  },
  // ...
]Code language: TypeScript (typescript)

Using the locale route param from Next.js, we can refine this data and show the appropriate content for the active locale.

// pages/posts/[slug].tsx

import Layout from "@/components/Layout";
import { posts } from "@/data/posts";
import type { Post } from "@/types";
import type {
  GetStaticPaths,
  GetStaticProps,
  GetStaticPropsContext,
} from "next";
import Link from "next/link";
import { useRouter } from "next/router";

type SinglePostProps = {
  post: Post;
};

export const getStaticPaths: GetStaticPaths<{
  slug: string;
}> = () => {
  return {
    paths: posts.map((post) => ({
      params: { slug: post.slug },
    })),
    fallback: true,
  };
};

export const getStaticProps = (async ({
  params,
}: GetStaticPropsContext) => {
  const post = posts.find(
    (post) => post.slug === params?.slug,
  );

  if (!post) return { notFound: true };

  return { props: { post } };
}) satisfies GetStaticProps<SinglePostProps>;

export default function SinglePost({
  post,
}: SinglePostProps) {
  const router = useRouter();

+ const locale = router.locale!;
+ // => "en-US" when route is /posts/foo
+ // => "ar-EG" when route is /ar-EG/posts/foo

  if (router.isFallback) {
    return <div>Loading...</div>;
  }

  return (
    <Layout>
      <!-- ... -->

      <!-- We previously hard-coded the locale -->
-     <h1 className="...">{post.translations["en-US"].title}</h1>
+     <h1 className="...">{post.translations[locale].title}</h1>
      <p className="...">{post.date}</p>
      <div className="...">
-        <p>{post.translations["en-US"].content}</p>
+        <p>{post.translations[locale].content}</p>
      </div>
    </Layout>
  );
}Code language: Diff (diff)

With that, we’re showing the post translated to the active locale.

When the route default to English | Phrase

The route is Arabic | Phrase

On localized dynamic routes and getStaticPaths()

For dynamic routes like [slug].tsx, when using fallback: false to generate all locale variants during the build, we need to include all locales in getStaticPaths():

 

// pages/_app.tsx

- import arMessages from "@/lang/compiled/ar-EG.json";
- import enMessages from "@/lang/compiled/en-US.json";
  import "@/styles/globals.css";
  import type { AppProps } from "next/app";
  import { useRouter } from "next/router";
  import { useEffect } from "react";
  import { IntlProvider } from "react-intl";

- const messages = {
-   "en-US": { ...enMessages },
-   "ar-EG": { ...arMessages },
- };

  export default function App({ Component, pageProps }: AppProps) {
    const { locale, defaultLocale } = useRouter();

    return (
      <IntlProvider
        locale={locale!}
        defaultLocale={defaultLocale!}
-       messages={messages[locale as keyof typeof messages]}
+       messages={pageProps.localeMessages}
      >
        <Component {...pageProps} />
      </IntlProvider>
    );
  }Code language: Diff (diff)
export const getStaticPaths: GetStaticPaths = ({
  locales,
}: GetStaticPathsContext) => {
  return {
    paths: locales!.flatMap((locale) =>
      posts.map((post) => ({
        params: { slug: post.slug },
        locale,
      })),
    ),
    fallback: false,
  };
};Code language: TypeScript (typescript)

Here, locales refers to the array defined in next.config.js. However, this approach can slow down our production build due to the large number of page variants (equal to the number of slugs times the number of locales).

As an alternative, omitting locale only builds variants for the default locale, with fallback: true dynamically generating other locale variants on route visits.

🔗 Learn more in the official doc, Dynamic Routes and getStaticProps Pages.

✋ Be aware that localized routes do not work with pure static site generation (SSG), i.e., output: "export".

Localized links

Next.js automatically localizes <Link> elements. For example, <Link href="/about"> will actually point to /ar-EG/about when the active locale is ar-EG. Additionally, you can switch locales by using the locale prop on <Link>. (We will discuss locale switching using the Next.js router a bit later).

Automatic locale detection

Next.js automatically detects a user’s locale when they visit the home route / by using the HTTP Accept-Language header, which reflects the browser and operating system language settings.

For instance, if a user with Arabic (Egypt) (ar-EG) as their top browser language preference visits /, they will get redirected to /ar-EG. However, this detection isn’t loosely matched; a user with Arabic (Syria) (ar-SY) won’t be redirected to /ar-EG, despite Syrians and Egyptians sharing the same written Arabic. We will address this when we tackle custom locale detection later.

🤿 Go deeper **with Detecting a User’s Locale in a Web App.

Localized routes alone don’t fully internationalize an app; managing UI translations, dates, and numbers is also crucial. This is where react-intl comes in.

How do I install and configure react-intl in my Next.js Pages Router app?

Installing react-intl is easy enough.

npm install react-intlCode language: Bash (bash)

To make our active locale and translation messages available to our pages and components, we need to wrap them with react-intl’s <IntlProvider>.

// pages/_app.tsx

import "@/styles/globals.css";
import type { AppProps } from "next/app";

// Import IntlProvider
import { IntlProvider } from "react-intl";

// Add translations
const messages = {
  "en-US": {
    hello: "Hello, World!",
  },
  "ar-EG": {
    hello: "مرحباً بالعالم!",
  },
};

export default function App({ Component, pageProps }: AppProps) {
  const { locale, defaultLocale } = useRouter();

  return (
    // Wrap page component with IntlProvider
    <IntlProvider
      locale={locale!}
      defaultLocale={defaultLocale!}
      messages={messages[locale as keyof typeof messages]}
    >
      <Component {...pageProps} />
    </IntlProvider>
  );
}Code language: TypeScript (typescript)

When configuring <IntlProvider>:

  • locale is used for date and number formatting.
  • defaultLocale is used for fallback when a message isn’t provided in the active locale (this is only for date and number formatting consistency; in fact, each message will provide its own default fallback).
  • messages are the translation messages for the active locale (it’s up to the developer to ensure this is the case).

OK, let’s take react-intl for a test run to see if it’s working.

Our home page has a heading we can replace with our new “Hello, World” translation.

// pages/index.tsx

  import Layout from "@/components/Layout";
  import type { GetStaticProps } from "next/types";
+ import { FormattedMessage } from "react-intl";

// ...

  export default function Home(...) {
    return (
      <Layout>
        <h1 className="...">
-         Hello i18n!
+         <FormattedMessage id="hello" />
        </h1>
        <p className="...">
          This is a Next.js demo of i18n with react-intl.
        </p>

        {/* ... */}

      </Layout>
    );
  }Code language: Diff (diff)

The <FormattedMessage> component is aware of the messages provided by <IntlProvider> and can reference any of them by its id.

Header renders our new English translation | Phrase

The header renders our Arabic translation | Phrase

How do I extract and compile translation messages with the Format.JS CLI?

The key advantage of react-intl/Format.JS over other i18n libraries is its support for a message extraction and optional compilation workflow, which is particularly beneficial for large projects with multiple locales and teams, including dedicated translators. The workflow involves:

  1. Defining translation messages in the default locale (e.g., English) within the codebase.
  2. Using a CLI to extract these messages into a translation file.
  3. Sharing this file with translators, typically via an automation script.
  4. Translators then provide translations for all supported locales, often using Translation Management Software (TMS) like Phrase.
  5. Another automation script pulls the latest translations back into the codebase.
  6. Optionally, translation messages are compiled into a more efficient format for production.

This workflow streamlines the coordination of internationalization and localization efforts for larger teams. In this section, we will focus on the initial steps: defining messages, extracting them, and compiling them.

First, we need to install the Format.JS CLI and the Format.JS Babel plugin. The former will give us the extraction and compilation commands, while the latter will allow Format.JS to create IDs for our messages automatically. This saves us the need to explicitly connect messages in our components with IDs in translation files.

Let’s see this in action. First, we will install both packages as dev dependencies.

npm install --save-dev @formatjs/cli babel-plugin-formatjsCode language: Bash (bash)

Next, we will add a .babelrc to the root of our project, which allows us to wire up the Format.JS plugin.

// .babelrc

{
  "presets": ["next/babel"],
  "plugins": [
    [
      "formatjs",
      {
        "ast": true
      }
    ]
  ]
}Code language: JSON / JSON with Comments (json)

For faster runtime performance, we will pre-compile our translation messages into AST (Abstract Syntax Tree) format. This format will only be used by Format.JS. Our source translation files handled by translators will be in a normal JSON key/value format.

A note on adding Babel config to a Next.js project

Next.js will automatically pick up our new .babelrc file and use it as an override for its Babel config, which is why we needed to explicitly add the next/babel plugin above. Otherwise, our Next.js environment doesn’t work at all!

Also, note that manually configuring Babel will cause Next.js to opt out of its own performant SWC compiler. One thing I noticed doesn’t work without SWC is Next.js’ automatic font optimization with next/font. Otherwise, my Next.js app continued to work fine with the Babel override.

🗒️ Format.JS does provide a SWC plugin which could, theoretically, be wired up to Next.js using the latter’s SWC plugin configuration. Since the SWC plugin config is experimental at the time of writing, we’ve chosen to go with the stable Babel override here.

Extracting messages

Now let’s add our extraction script to package.json.

// package.json

{
  // ...
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
+   "intl:extract": "formatjs extract '{pages,components,sections}/**/*.{js,jsx,ts,tsx}' --out-file lang/src/en-US.json --format simple",
  },
  // ...
}Code language: Diff (diff)

Our new intl:extract command will tell the Format.JS CLI to look through our source files and output a translation file for our default language, en-US.

🗒️ We designate the simple format to output our translation file in simple key/value JSON. Other formats are available, however. We can even provide custom formats.

OK, let’s give our new command a spin. First, we will update our messages to remove explicit IDs and ensure each has an English defaultMessage.

import Layout from "@/components/Layout";
import { FormattedMessage } from "react-intl";

// ...

export default function Home(...) {
  return (
    <>
      <Layout>
        <h1 className="...">
-         <FormattedMessage id="hello" />
+         <FormattedMessage defaultMessage="Hello i18n!" />
        </h1>
      </Layout>
    </>
  );
}Code language: Diff (diff)

Notice how this change makes the message much more readable. Now let’s run our new extraction command.

npm run intl:extractCode language: Bash (bash)

If all goes well, we should have a new file in our project, lang/src/en-US.json:

// lang/src/en-US.json

{
  "RohNOo": "Hello i18n!"
}Code language: JSON / JSON with Comments (json)

The Format.JS CLI will automatically generate a unique ID for any message it finds in the source code that doesn’t have an explicit ID. It will also assume that the defaultMessage value is the translation in the default locale.

We can now create a copy of the English translation file for each of our other supported locales. (In production, we would probably automate this step and upload all the files to a TMS like Phrase).

// lang/src/ar-EG.json 

{
  "RohNOo": "أهلاً بالتدويل!"
}Code language: JSON / JSON with Comments (json)

Of course, we have to load our new message files into our app.

// pages/_app.tsx

+ import arMessages from "@/lang/src/ar-EG.json";
+ import enMessages from "@/lang/src/en-US.json";
  import "@/styles/globals.css";
  import type { AppProps } from "next/app";
  import { useRouter } from "next/router";
  import { IntlProvider } from "react-intl";

  const messages = {
-   "en-US": { ... }, // inlined messages
+   "en-US": { ...enMessages },
-   "ar-EG: { ... },  // inlined messages
+   "ar-EG": { ...arMessages },
  };

export default function App({ Component, pageProps }: AppProps) {
  const router = useRouter();
  const { locale, defaultLocale } = useRouter();

  return (
    <IntlProvider
      locale={locale!}
      defaultLocale={router.defaultLocale}
      messages={messages[locale as keyof typeof messages]}
    >
      <Component {...pageProps} />
    </IntlProvider>
  );
}Code language: Diff (diff)

If we run our app now, we should see our English and Arabic translations.

English route header | Phrase

Arabic route header | Phrase

Behind the scenes the Format.JS Babel plugin is doing its magic, injecting the message ID in the <FormattedMessage> component. We can see this if we poke in with our React dev tools.

React dev tools browser extension shows an automatically injected ID | Phrase

Aliasing and custom components/functions

The Format.JS CLI and babel plugins will automatically work with <FormattedMessage> and the imperative intl.formatMessage (we will cover the latter a bit later). Aliasing them will not work out-of-the-box however:

// ⛔️👇 Will not work out-of-the-box. (See below).

import { FormattedMessage } from "react-intl";

export default function Aliased({ defaultMessage }) {
  return <FormattedMessage defaultMessage={defaultMessage} />;
}

// In some other component
<Aliased defaultMessage="I am a translation message" />

// ⛔️👆 Will not work! (See below).Code language: JavaScript (javascript)

To enable message extraction and automatic ID generation for your aliases or custom components/functions, refer to the additionalComponentNames and additionalFunctionNames options in the CLI docs and Babel plugin docs.

Compiling messages

In production, we can speed up our app with Format.JS message compilation, which does two things:

  • Compiles the translation message to a performant AST format.
  • Allows us to remove the ICU MessageFormat parser in production environments, further speeding up our app.

The Format.JS Babel plugin has been compiling our messages to AST on the fly during development. We can see this when we look at one of our message components, like <FormattedMessage>, using the React dev tools.

Our defaultMessage is compiled into an AST | Phrase

Normally, we define our translation messages using the robust ICU Message syntax. We will get into ICU a bit later. For now, just know that parsing and compiling this syntax can be a bit expensive, so pre-compiling our messages for production can make our app more performant. We do this using the Format.JS CLI.

Let’s add a new compile command to our package.json

// package.json

{
  // ...
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "intl:extract": "formatjs extract '{pages,components,sections}/**/*.{js,jsx,ts,tsx}' --out-file lang/src/en-US.json --format simple",
+   "intl:compile": "formatjs compile-folder lang/src lang/compiled --format simple --ast"
  },
  // ...
}Code language: JSON / JSON with Comments (json)

Format.JS’s compile-folder command will scan all message files in one folder and compile them to another folder.

When using the compiler, it’s important to specify the source message format with the --format flag. Since the simple format was used for message extraction, this same format should be provided to the compiler.

We also add the --ast flag to get compiled messages in AST format instead of the default string format.

Let’s run the command.

npm run intl:compileCode language: Bash (bash)

If all goes well, our messages should be compiled under the lang/compiled directory:

// lang/compiled/en-US.json

{
  "RohNOo": [
    {
      "type": 0,
      "value": "Hello i18n!"
    }
  ]
}Code language: JSON / JSON with Comments (json)
// lang/compiled/ar-EG.json

{
  "RohNOo": [
    {
      "type": 0,
      "value": "أهلاً بالتدويل!"
    }
  ]
}Code language: JSON / JSON with Comments (json)

As you continue building your app and adding translation messages, you’ll notice that messages compiled into AST format retain a simplified object structure that doesn’t need to be parsed.

So we can remove the expensive ICU MessageFormat parser from our production environment:

// next.config.js

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  i18n: {
    locales: ["en-US", "ar-EG"],
    defaultLocale: "en-US",
  },
+ webpack: (config, { dev, ...other }) => {
+   if (!dev) {
+      config.resolve.alias["@formatjs/icu-messageformat-parser"] =
+       "@formatjs/icu-messageformat-parser/no-parser";
+   }
+   return config;
+ },
};

module.exports = nextConfig;Code language: Diff (diff)

This speeds up our app in production by reducing the Format.JS bundle size by ~40%.

We can see our new config in action by swapping in our compiled messages.

// pages/_app.tsx

- import arMessages from "@/lang/src/ar-EG.json";
+ import arMessages from "@/lang/compiled/ar-EG.json";
- import enMessages from "@/lang/src/en-US.json";
+ import enMessages from "@/lang/compiled/en-US.json";

// ...

export default function App({ Component, pageProps }: AppProps) {
// ...Code language: Diff (diff)

Running npm run build && npm start from the command line allows us to test our production environment. Looking at our browser network tab, we can see the difference in app bundle size:

Our app bundle size with the ICU MessageFormat parser bundled, weighing in at 44.9 KB
Our app bundle size with the ICU MessageFormat parser removed, dropping by 20% to 35.86 KB

🔗 Read more about pre-compiling and performance in the Format.JS Advanced Usage docs.

We’ve made our workflow and our production app leaner by using the wonderful Format.JS CLI and the Format.JS Babel plugin. We can do even better by only loading the translation file we need (instead of all of them) and doing so only on the server (instead of potentially reloading on the client). We will look at that next.

How do I load a translation file on the server?

We’re currently loading the translation message files for all our supported locales while only using the file for our active locale, which doesn’t scale well. We can solve this problem by using our pages’ getStaticProps() to load only the active locale’s translation file on the server.

We have to do this in each of our pages, so let’s write a helper function that we can reuse.

// i18n/get-locale-messages.ts

import fs from "fs/promises";
import path from "path";

export default async function getLocaleMessages(
  locale: string | undefined,
): Promise<Record<string, string>> {
  if (!locale) {
    throw new Error("Locale is missing.");
  }

  const messageFilePath = path.join(
    process.cwd(),
    "lang",
    process.env.NODE_ENV === "development"
      ? "src"
      : "compiled",
    `${locale}.json`,
  );

  const messages = await fs.readFile(
    messageFilePath,
    "utf8",
  );

  return JSON.parse(messages);
}Code language: TypeScript (typescript)

When we load the translation file for a given locale, we check to see if we’re in development or production. In development, we load lang/src/en-US.json (or ar-EG.json). In production, we load the compiled lang/compiled/en-US.json.

We can now use getLocaleMessage() to load our translation file in our home page.

// pages/index.tsx

+ import getLocaleMessages from "@/i18n/get-locale-messages";
  import type { 
    GetStaticProps,
    GetStaticPropsContext,
  } from "next/types";
  import { FormattedMessage } from "react-intl";

  type HomeProps = {
+   localeMessages: Record<string, string>;
    date: string;
  };

  export const getStaticProps: GetStaticProps<
    HomeProps
  > = async ({ 
+   locale
  }: GetStaticPropsContext) => {
    return {
      props: {
+       localeMessages: await getLocaleMessages(locale),
        date: new Date().toString(),
      },
    };
  };

export default function Home({ date }: HomeProps) {
  return (
    <Layout>
      <h1 className="...">
        <FormattedMessage defaultMessage="Hello i18n!" />
      </h1>

      {/* ... */}

    </Layout>
  );
}Code language: Diff (diff)

Next.js provides a locale param with the value of the active locale to getStaticProps(). We use this param to load the translation messages corresponding to the active locale into the localeMessages page prop.

We can now use localeMessages in our root App component, passing it to react-intl’s <IntlProvider>.

// pages/_app.tsx

- import arMessages from "@/lang/compiled/ar-EG.json";
- import enMessages from "@/lang/compiled/en-US.json";
  import "@/styles/globals.css";
  import type { AppProps } from "next/app";
  import { useRouter } from "next/router";
  import { useEffect } from "react";
  import { IntlProvider } from "react-intl";

- const messages = {
-   "en-US": { ...enMessages },
-   "ar-EG": { ...arMessages },
- };

  export default function App({ Component, pageProps }: AppProps) {
    const { locale, defaultLocale } = useRouter();

    return (
      <IntlProvider
        locale={locale!}
        defaultLocale={defaultLocale!}
-       messages={messages[locale as keyof typeof messages]}
+       messages={pageProps.localeMessages}
      >
        <Component {...pageProps} />
      </IntlProvider>
    );
  }Code language: Diff (diff)

The App component has access to each page component’s props through pageProps. So our server-loaded messages from the home page are available to App via pageProps.localeMessages.

With that, we’ve eliminated the need to load all of our translation files. We’ve also limited expensive file loading and parsing to the server by using pages’ getStaticProps().

✋ Of course, for this to work, we have to add localeMessages: await getLocaleMessages(locale) to each page in our app. Here is our posts index as another example:

// pages/posts/index.tsx

  import Layout from "@/components/Layout";
  import { posts } from "@/data/posts";
+ import getLocaleMessages from "@/i18n/get-locale-messages";
  import type { Post } from "@/types";
  import type { 
    GetStaticProps,
    GetStaticPropsContext
  } from "next";
  import Link from "next/link";

  type PostIndexProps = {
+   localeMessages: Record<string, string>;
    posts: Post[];
  };

  export const getStaticProps: GetStaticProps<
    PostIndexProps
  > = async ({ 
+   locale
  }: GetStaticPropsContext) => {
    return {
      props: {
+       localeMessages: await getLocaleMessages(locale),
        posts,
      },
    };
  };

  export default function PostIndex({
    posts,
  }: PostIndexProps) {
    // Render the posts...
  }Code language: Diff (diff)

Loading translation messages in the App component

We only need to add a few lines per page to load our translations on the server. However, you might be wondering how we make all this DRY (Don’t Repeat Yourself). One possible solution is using the legacy getInitialProps() function in the App component.

// pages/_app.tsx

  import "@/styles/globals.css";
- import type { AppProps } from "next/app";
+ import type { AppContext, AppProps } from "next/app";
  import App from "next/app";
  import { useRouter } from "next/router";
  import { IntlProvider } from "react-intl";

- export default function App({
+ export default function MyApp({
    Component,
    pageProps,
+   messages,
- }: AppProps) {
+ }: AppProps & { messages: Record<string, string> }) {
    const { locale, defaultLocale } = useRouter();

    return (
      <IntlProvider
        locale={locale!}
        defaultLocale={defaultLocale!}
-       messages={pageProps.localeMessages}
+       messages={messages}
      >
        <Component {...pageProps} />
      </IntlProvider>
    );
  }

+ // 👇 Load the messages for the active locale
+ MyApp.getInitialProps = async (ctx: AppContext) => {
+   const { locale } = ctx.router;
+
+   const subdirectory =
+     process.env.NODE_ENV === "development"
+       ? "src"
+       : "compiled";
+
+   const messages: Record<string, string> = await import(
+     `@/lang/${subdirectory}/${locale}`
+   );
+
+   const appProps = await App.getInitialProps(ctx);
+
+   return { ...appProps, messages };
+ };Code language: Diff (diff)

With that, we can remove all the getLocaleMessage() calls in our pages, encapsulating locale loading in the App component.

This DRYing up comes at a cost, however: using getInitialProps() on the App component causes Next.js to opt out of Automatic Static Optimization. This means that simple pages that have no getServerSideProps() or getInitialProps() will not be pre-rendered (and cached in a CDN) as usual. We can see this when we build our app.

🗒️ I added a simple About page to test here

By default, simple pages are statically rendered

 

With App component getInitialProps() translation loading, simple pages become dynamically rendered

So it’s a tradeoff either way. In this tutorial, we will stick to per-page getStaticProps translation loading, since it’s only a few lines per page for an overall performance gain. The choice is yours, of course.

🔗 If you want a closer look at the getInitialProps() solution, we have a dedicated branch in our GitHub repo that covers it. You can also see a handy diff that focuses on loading code.

🗒️ Server Components, introduced in Next.js with the App Router, can be really helpful here. We cover this in A Deep Dive into Next.js App Router Localization with next-intl. (Do note that at the time of writing, unlike react-intl, next-intl doesn’t cover message extraction/compilation).

How do I build a language switcher?

We’ve laid the plumbing of react-intl + Next.js, making sure we’re loading translations as efficiently as possible. Let’s switch gears a bit and make a locale switcher for our site visitors since we often need a way for our users to manually select their own language.

We will add a LocaleSwitcher component that takes care of this.

// components/LocaleSwitcher.tsx

import { useRouter } from "next/router";

const localeNames: Record<string, string> = {
  "en-US": "English",
  "ar-EG": "(Arabic) العربية",
};

export default function LocaleSwitcher() {
  const router = useRouter();

  // `locales` list is configured in next.config.js
  const { locale, locales } = router;

  const handleLocaleChange = (
    e: React.ChangeEvent<HTMLSelectElement>,
  ) => {
    const locale = e.target.value;
    router.push(router.pathname, router.asPath, { locale });
  };

  return (
    <div>
      <select
        value={locale}
        onChange={handleLocaleChange}
        className="..."
      >
        {locales!.map((locale) => (
          <option key={locale} value={locale}>
            {localeNames[locale]}
          </option>
        ))}
      </select>
    </div>
  );
}Code language: TypeScript (typescript)

Like the <Link> component, the Next.js router takes a locale option that makes the router change the locale route prefix for a given route path. For example, if we call router.push("/posts", "/posts", { locale: "ar-EG" }), we will be navigated to /ar-EG/posts.

🔗 See the router.push() docs for more info.

If we place our new <LocaleSwitcher> in the header section of our <Layout>, we can see this in action.

A language-switching UI makes it easy for our site visitors to manually select their locale

Setting the NEXT_LOCALE cookie

Right now, our user’s selected locale won’t be remembered the next time they visit our site. Recall that Next.js i18n has built-in locale detection. We can override this detection by setting a NEXT_LOCALE cookie.

Let’s add that logic to our LocaleSwitcher. We will use the popular nookies package, which allows to set cookies easily from the browser.

Let’s install it.

npm install nookiesCode language: Bash (bash)

Now can use nookies’ setCookie() in our component.

// components/LocaleSwitcher.tsx

  import { useRouter } from "next/router";
+ import { setCookie } from "nookies";

  // ...

  export default function LocaleSwitcher() {
    const router = useRouter();
    const { locale, locales } = router;

    const handleLocaleChange = (
      e: React.ChangeEvent<HTMLSelectElement>,
    ) => {
      const locale = e.target.value;
    
+     setCookie(null, "NEXT_LOCALE", locale, {
+       sameSite: "Strict",
+       path: "/",
+       // Set the lifetime of the cookie to one year
+       maxAge: 365 * 24 * 60 * 60,
+     });

      router.push(router.pathname, router.asPath, { locale });
    };

    return (
      // ...
    );
  }Code language: Diff (diff)

Now, whenever we change the locale manually with the switcher, we can see a cookie being set in our browser dev tools.

Our browser dev tools confirm that the NEXT_LOCALE cookie is being set

With this setup, when a visitor manually selects a locale, the NEXT_LOCALE cookie stores their choice. Next.js’ locale auto-detection then prioritizes the cookie’s value over its own detected locale. This ensures that the visitor sees the site in their previously selected language during subsequent visits.

How do I automatically detect the user’s locale?

Next.js i18n has automatic locale detection built in. In fact, it’s on by default. However, as we’ve mentioned before, this detection can be a bit too precise.

For example, if a visitor sets ar-SY (Arabic as it is used in Syria) in their browser language preferences, Next.js won’t serve them the ar-EG version of our website. This is a missed opportunity because Egypt and Syria share the same written Arabic.

To achieve loose locale matching, where only the language is matched, we can roll our own locale auto-detection via custom Next.js middleware.

First, let’s disable Next’s built-in locale auto-detection.

// next.config.js

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  i18n: {
    locales: ["en-US", "ar-EG"],
    defaultLocale: "en-US",
+   localeDetection: false,
  },
  webpack: (config, { dev, ...other }) => {
    // ...
};

module.exports = nextConfig;Code language: Diff (diff)

To make our work easier, we will use the accept-language-parser package to parse and match the user’s locale from the Accept-Language HTTP header. Let’s install the package.

npm install accept-language-parserCode language: Bash (bash)

We can now utilize the package to provide a bestMatch() function that we will use in our new middleware momentarily.

// i18n/best-match.ts

import nextConfig from "@/next.config";
import acceptLanguageParser from "accept-language-parser";

export function bestMatch(
  acceptLanguageHeader: string | null,
): string | null {
  if (!nextConfig.i18n) {
    throw new Error(
      "Please add i18n config to next.config.js",
    );
  }

  if (!acceptLanguageHeader) {
    return nextConfig.i18n.defaultLocale;
  }

  const supportedLocales = nextConfig.i18n.locales;

  const bestMatch = acceptLanguageParser.pick(
    supportedLocales,
    acceptLanguageHeader,
    { loose: true },
  );
  return bestMatch;
}Code language: TypeScript (typescript)

We pull in our next.config.js i18n values and use the accept-language-parser’s pick() function to find the best matching locale.

The acceptLanguage string parameter should match the format of a standard HTTP Accept-Language header, such as "fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5". This represents a prioritized list of user-preferred locales, indicating their language preferences and environment.

🔗 Learn more about the Accept-Language header in our locale detection guide.

Given acceptLanguage and a list of app-supported locales (en-US, ar-EG in our case), pick() will try to find the best match in the supported locales list. By setting the loose option to true, we ensure that this best match is by language regardless of region, e.g. ar-SY in the Accept-Language header will match the supported ar-EG.

OK, armed with bestMatch(), let’s write our custom middleware.

// middleware.ts

import { bestMatch } from "@/i18n/best-match";
import {
  NextResponse,
  type NextRequest,
} from "next/server";

export function middleware(req: NextRequest) {
  const matchedLocale = bestMatch(
    req.headers.get("Accept-Language"),
  );

  const { locale } = req.nextUrl;

  if (locale !== matchedLocale) {
    return NextResponse.redirect(
      new URL(
        `/${matchedLocale}${req.nextUrl.pathname}`,
        req.nextUrl,
      ),
    );
  }
}

export const config = {
  // Match the root route and any other route except
  // internal Next.js routes and public files.
  matcher: [
    "/",
    "/((?!api|_next/static|_next/image|favicon.ico).*)",
  ],
};Code language: TypeScript (typescript)

In our middleware, we check to see if the best-matched locale is different from the current locale in the URL. If it is, we redirect to the URL with the best-matched locale swapped in.

For example, if a site visitor has pt-BR (Portuguese in Brazil) as a browser language preference, and our app supports the locale, visiting /foo or /fr-CA/foo would redirect the visitor to /pt-BR/foo.

Minding the NEXT_LOCALE cookie override

Remember the NEXT_LOCALE cookie we set in our locale switcher? The built-in Next.js locale detector will use it as an override, but we’re not doing the same in our custom middleware. This means that if a visitor manually selects a locale from our switcher, their choice will be overridden by our auto-detection.

Let’s fix this.

// middleware.ts

import {
  NextResponse,
  type NextRequest,
} from "next/server";
import { bestMatch } from "./i18n/best-match";

export function middleware(req: NextRequest) {
  const { locale } = req.nextUrl;

+ // If a locale was manually selected by the visitor
+ // during a previous visit, use *that* locale and
+ // don't attempt to auto-detect a best match.
+ const storedLocale = req.cookies.get("NEXT_LOCALE");
+
+ if (storedLocale) {
+   if (storedLocale.value !== locale) {
+     return NextResponse.redirect(
+       new URL(
+         `/${storedLocale.value}${req.nextUrl.pathname}`,
+         req.nextUrl,
+       ),
+     );
+   } else {
+     return;
+   }
+ }

  const matchedLocale = bestMatch(
    req.headers.get("Accept-Language"),
  );

  if (locale !== matchedLocale) {
    return NextResponse.redirect(
      new URL(
        `/${matchedLocale}${req.nextUrl.pathname}`,
        req.nextUrl,
      ),
    );
  }
}

export const config = {
  matcher: [
    "/",
    "/((?!api|_next/static|_next/image|favicon.ico).*)",
  ],
};Code language: Diff (diff)

With this, we check for a previously selected locale and only auto-detect when we don’t find one.

How do I localize SSR and SSG pages?

We’ve been working with server-side rendering (SSR) and static site generation (SSG) throughout this guide, so we’ve covered most of the basics. Here’s a quick recap:

  • In the context of static generation with Next.js i18n Routing, locale information is accessible via the Next.js router. Properties available are locale (the active locale), locales (all supported locales), and defaultLocale.
  • When pre-rendering pages with getStaticProps or getServerSideProps, this locale information is provided in the context param passed to these functions.
  • Similarly, when using getStaticPaths, the supported locales and defaultLocale are included in the context parameter of the function.
  • Next.js i18n routing does not work with static exports, ie. output: 'export'.

🔗 Read How does [i18n] work with Static Generation? in the Next.js docs.

Let’s take a look at the code of the single post page to see all this in action.

// pages/posts/[slug].tsx

import Layout from "@/components/Layout";

// Our mock data
import { posts } from "@/data/posts";

// For loading our UI translation messages
import getLocaleMessages from "@/i18n/get-locale-messages";

// TypeScript types
import type { TranslatedPost } from "@/types";
import type {
  GetStaticPaths,
  GetStaticProps,
  GetStaticPropsContext,
} from "next";

import Link from "next/link";
import { useRouter } from "next/router";

// react-intl
import { FormattedMessage } from "react-intl";

type SinglePostProps = {
  localeMessages: Record<string, string>;
  post: TranslatedPost;
};

// Since we're not providing locales in our
// returned `paths`, Next.js will only 
// pre-build a version of this page translated
// to our default locale (en-US) during 
// production builds.
export const getStaticPaths: GetStaticPaths<{
  slug: string;
}> = () => {
  return {
    paths: posts.map((post) => ({
      params: { slug: post.slug },
    })),

    // Ensure that all locale translations
    // are served when requested.
    fallback: true,
  };
};

export const getStaticProps = (async ({
  params,
  locale,
}: GetStaticPropsContext) => {
  if (typeof params?.slug !== "string" || !locale) {
    return { notFound: true };
  }

  const foundPost = posts.find(
    (p) => p.slug === params.slug,
  );
  if (!foundPost) {
    return { notFound: true };
  }

	// Only get the translations for the
  // active `locale`.
  const post = {
    date: foundPost.date,
    slug: foundPost.slug,
    ...foundPost.translations[locale],
  };

  return {
    props: {
      // Load the active locale's translation
      // messages for the UI view (react-intl).
      localeMessages: await getLocaleMessages(locale),
      post,
    },
  };
}) satisfies GetStaticProps<SinglePostProps>;

export default function SinglePost({
  post,
}: SinglePostProps) {
  const router = useRouter();

  // Just a reminder that we can access
  // i18n config values here as well.
  const { locale, defaultLocale, locales } = router;

  if (router.isFallback) {
    return <div>Loading...</div>;
  }

  return (
    <Layout>
      <div className="...">
        <Link href="/posts" className="...">
          <FormattedMessage defaultMessage="Back to post index" />
        </Link>
      </div>

      <h1 className="...">{post.title}</h1>

      <p className="...">{post.date}</p>

      <div className="...">
        <p>{post.content}</p>
      </div>
    </Layout>
  );
}Code language: TypeScript (typescript)

🔗 Get all the code for the localized demo app from our GitHub repo.

How do localize my pages and components with react-intl?

We’ve touched on this earlier, but there’s more to localizing our views than basic messages with <FormattedMessage>. In this section, we briefly cover interpolation, plurals, and date and number formatting.

🔗 We cover all this in more detail in our Guide to Localizing React Apps with react-intl/FormatJS.

Basic translations

Translating basic strings often means adding a <FormattedMessage> with a defaultMessage.

// In a component
import { FormattedMessage } from "react-intl";

// ...

<p className="...">
  <FormattedMessage defaultMessage="Another look at Darth Vader" />
</p>Code language: TypeScript (typescript)

We then extract, translate, and compile to see our message in different locales.

An imperative intl.formatMessage() also exists, and it’s especially handy for localizing attributes and props.

// In a component
import { useIntl } from "react-intl";

export default function MyComponent() {
  const intl = useIntl();

  return (
    <img
			src="..."
      alt={intl.formatMessage({ 
			  defaultMessage: "Picture of Chewbacca, unimpressed."
      })}
    />
  );
}Code language: TypeScript (typescript)

✋ Remember that aliasing <FormattedMessage> or intl.formatMessage() won’t work with extraction without setting extra options. See the Aliasing and custom components/functions section above for more details.

Localizing page metadata

intl.formatMessage() is the only way to localize page metadata since using <FormatMessage> will throw an error when used inside Next’s <Head> component.

// pages/posts/index.tsx

import Layout from "@/components/Layout";
import Head from "next/head";
import { useIntl } from "react-intl";

// ...

export default function PostIndex({ posts }: PostIndexProps) {
  const intl = useIntl();

  return (
    <>
      <Head>
        <title>
          {intl.formatMessage({
            defaultMessage: "Posts | r.intl",
          })}
        </title>
        <meta
          name="description"
          content={intl.formatMessage({
            defaultMessage: "Our latest posts.",
          })}
        />
      </Head>>
      <Layout>
        {/* ... */}
      </Layout>
    </>
  );
}Code language: TypeScript (typescript)

Interpolation

To inject runtime values in our translation messages, we designate their locations with the ICU {variable} syntax, then provide named values as params.

{/* Imperative */}
<p>
  {intl.formatMessage(
    {
      defaultMessage:
        "This is a {next} demo of i18n with {reactIntl}",
    },
    {
       next: "Next.js",
       reactIntl: "react-intl",
    },
  )}
</p>

{/* Declarative */}
<p>
  <FormattedMessage
    defaultMessage="This is a {next} demo of i18n with {reactIntl}"
    values={{
      next: "Next.js",
      reactIntl: "react-intl",
    }}
  />
</p>Code language: TypeScript (typescript)

🗒️ react-intl/Format.JS implements the ICU (International Components for Unicode), a localization standard found in many environments. Learn more about it in The Missing Guide to the ICU Message Format.

Plurals

One of the best things about the ICU Message Format is its robust support for plurals across locales.

🤿 Different languages have significantly different pluralization rules. Our Guide to Localizing Plurals goes deeper into that subject.

// components/Plurals.tsx

const [messageCount, setMessageCount] =
    useState<number>(0);

// ...

<span>
  <FormattedMessage
    defaultMessage={`{count, plural,
      =0 {You have no messges.}
      one {You have one message.}
      other {You have # messages.}}`}
      values={{ count: messageCount }}
  />
</span>Code language: TypeScript (typescript)
While English has 3 plural forms, Arabic has 6

🔗 See the complete code for the above Plurals component on GitHub.

Date formatting

Under the hood, Format.JS uses the standard Intl.DateTimeFormat for its localized date formatting. This means that we can pass options to react-intl’s formatter that are used in turn by Intl.DateTimeFormat.

// components/Dates.tsx

import { FormattedDate, useIntl } from "react-intl";

// The `date` prop could be of type `Date`
// here as well: The following code would
// still work fine.
export default function Dates({ date }: { date: string }) {
  const intl = useIntl();

  return (
    <>
      <span className="...">
        {intl.formatDate(date)}
      </span>

      <span className="...">
        <FormattedDate value={date} dateStyle="long" />
      </span>

      <span className="...">
        {intl.formatDate(date, {
          year: "2-digit",
          month: "short",
          day: "2-digit",
        })}
      </span>
    </>
  );
}Code language: TypeScript (typescript)
Our various date formats rendered for the en-US locale
Our various date formats rendered for the ar-EG locale

✋ Safari on macOS will throw a hydration error in its developer console for the above long date in Arabic since it adds that comma in the date format on the client—and the Node.js server doesn’t. We could solve this by formatting dates on the server using getStaticProps() and passing them to pages and components as pre-formatted strings. Alternatively, we could use a library like date-fns.

🔗 Grab the full code for the above Date component from our GitHub repo.

Number formatting

Again, Format.JS uses the standard Intl.NumberFormat underneath the hood.

// components/Numbers.tsx

import { FormattedNumber, useIntl } from "react-intl";

export default function Numbers() {
  const intl = useIntl();

  return (
    <>
      <span className="...">
        <FormattedNumber value={1234.56} />
      </span>
      
      <span className="...">
        {intl.formatNumber(1234.56, {
          style: "currency",
          currency: "EUR",
        })}
      </span>
      
      <span className="...">
        {intl.formatNumber(0.98, { style: "percent" })}
      </span>
    </>
  );
}Code language: TypeScript (typescript)
Our number formats rendered in the en-US locale
Our number formats rendered in the ar-EG locale

🔗 Get the complete code of the above Number component from GitHub.

🤿 This article is getting a bit long, so we had to go through view localization very quickly. We invite you to look more deeply into these topics in A Guide to Localizing React Apps with react-intl/FormatJS.

How do I work with right-to-left languages?

The simplest solution to setting the document direction in the browser is through a useEffect in the App component.

// pages/_app.tsx

  import "@/styles/globals.css";
  import type { AppProps } from "next/app";
  import { useRouter } from "next/router";
+ import { useEffect } from "react";
  import { IntlProvider } from "react-intl";

  export default function App({
    Component,
    pageProps,
  }: AppProps) {
    const { locale, defaultLocale } = useRouter();

+    useEffect(() => {
+      // We set the `lang` attribute while
+      // we're at it.
+      document.documentElement.lang = locale!;
+
+      document.documentElement.dir =
+        locale === "ar-EG" ? "rtl" : "ltr";
+    }, [locale]);

     return (
       <IntlProvider
         locale={locale!}
         defaultLocale={defaultLocale}
         messages={pageProps.localeMessages}
       >
         <Component {...pageProps} />
       </IntlProvider>
     );
  }Code language: Diff (diff)

While it may seem that calling useEffect() inside of App would make all of our pages client-side-rendered, I found that this was not the case.

However, if you’re looking for a different approach, you could use getInitialProps() in the root Document component (at pages/_document.tsx).

Alternatively, you could set document.documentElement.dir in the LocaleSwitcher.

And with that, our demo app is localized!

Our demo app pages translated into English and Arabic

🔗 Get the completed code for our demo app from our GitHub repo.

Conclusion

There’s certainly a good amount of setup to get a Next.js app localized with react-intl. Once configured, however, we get robust, efficient i18n on both server and client. With the automation options that the react-intl CLI provides, we can scale our localization with lean workflows that keep us focused on the creative code we love.