Next.js 13 App Router with next-intl (Tutorial)

Next.js Logo

A complete guide for setting up next-intl with the App Router

(6 minute read)

Available App Router i18n Tutorials:

Why use next-intl?

With the release of Next.js 13 and the new App Router, internationalized routing is no longer supported out of the box… But next-intl is here to save the day!

The next-intl library not only adds back internationalized routing support, but it is also a complete solution for loading and rendering translations.

Let’s Get Started

I’ve created a new Next.js 13 App Router project using:

$ npx create-next-app@latest

Before we start up our dev server, let’s install next-intl:

$ npm install next-intl

I’ve removed the default homepage content and replaced it with my own simple home page:

app/page.js
'use client'

import Link from 'next/link';
import styles from './page.module.scss';

export default function Home() {
  const userName = 'David';

  return (
    <div className={styles.wrapper}>
      <h1>Welcome to my app, {userName}!</h1>
      <div className={styles.links}>
        <Link href="/products">Products</Link>
        <Link href="/contact">Contact</Link>
      </div>
    </div>
  );
}

Note: You may notice I used 'use client' to make the page a Client Component. This is because rendering translations in Server Components is still in beta with next-intl. If you want your page to be a Server Component, just make sure to nest any text on the page into Client Components.

Here is a look at the page:

Initial page with a header and two links

With that done, let’s internationalize! 🌎

Setting Our Supported Languages

The first thing we need to do is tell next-intl what languages we want our app to support. To do this, we need to create a middleware.js file at the root of our project:

middleware.js
import createMiddleware from 'next-intl/middleware';

export default createMiddleware({
  locales: ['en', 'de', 'es', 'ja'],
  defaultLocale: 'en'
});

// this tells the middleware to run only on requests to our app pages
export const config = {
  matcher: ['/((?!api|_next|.*\\..*).*)']
};

In my project I’m going to support English, German, Spanish, and Japanese!

Setting Up Internationalized Routing

The next-intl library automatically adds internationalized routes for all of our pages and locales. For example, the German version of our /products page will be accessed at /de/products.

Our defaultLocale will be accessible from the base path. In my case, the English version of the /products page will still be accessed at /products.

To support next-intl, we will also need to make a small change to our app directory. Add a directory named [locale] to contain all pages and layouts inside your app directory:

└── app

    └── [locale]

        ├── layout.js

        └── page.js

└── middleware.js

In this case, the [locale] folder is a dynamic segment that will allow us to access the current locale in our pages via params.

Handling Messages

In next-intl, all of the strings that we render in our app are referred to as “messages”.

From experience, one of the biggest headaches of internationalization is organizing all of our messages in giant JSON files for every language. 🤕

Another problem is that we don’t have any idea what our app looks like in other languages until we hire professional translators.

To solve these problems, we’re going to use i18nexus to store and automatically machine translate our messages for us. We can later invite professional translators to our i18nexus project to edit and confirm these translations if we want.

We will have a fully translated app by the end of this tutorial:

A fully internationalized page changing languages with an HTML select element

Adding Messages

If you haven’t already, create an i18nexus account and select next-intl for your project’s library. In our i18nexus project, we’ll click Add Language to add all of the languages that we listed earlier in our middleware.js file:

i18nexus user interface for adding languages to a project

Now we’ll click Open Project in the top right corner of the page and then click Add String to add our first string:

i18nexus user interface for add a new string to our project

First I’m going to add a string for my page header: “Welcome back, David”

The name “David” is an interpolated word. To use interpolation in next-intl, we need to wrap a variable in single curly braces: “Welcome back, {name}

The string key is used to reference this string in our code. I’ll set the key to: “header”

The value is what will be rendered to the end user: “Welcome back, {name}

The details field is optional and meant for for any notes you want to write about the context of the string for the rest of your team to reference.

After saving, we can expand the row to see all of the machine translations generated by i18nexus for each of our languages:

Listing all the automatic machine translations for a string

All of our translations are ready to go! 🚀

Namespaces

The next-intl library uses namespaces to organize messages. i18nexus has namespace support built-in.

In the top right corner of the page, we see that we are currently in a namespace called default. Let’s rename this namespace to home and use it for our home page. It is common to create one namespace per page in our app:

Renaming the default namespace in i18nexus to home

Nested Keys

next-intl also supports nested keys for more granular organization of our messages. It treats keys that contain the . character as nested keys.

Let’s see how this works by using nested keys for our “Products” and “Contact” links on our home page by adding keys named links.products and links.contact:

Adding two more strings to our i18nexus project for the links that appear on the page

In the top of the page, we can change View Mode to Nested List to see a nested JSON-like view of our keys.

Using the Nested List mode to list the strings in a nested folder structure

Awesome!

Loading Translations

It is possible use the i18nexus API to dynamically fetch our latest translations in each page. But due to the nature of Next 13's caching, many developers find it easier to use the i18nexus-cli to download their translations into their project.

Let's do it!

$ npm install i18nexus-cli -g

In the Export page in i18nexus, copy your project’s API key and add it as an environment variable in your project:

.env
I18NEXUS_API_KEY="your_api_key"

With the CLI installed, we can now run the pull command to download our translations straight to our project:

$ i18nexus pull

You should now see a directory named messages at the root of your project that contains a JSON file for each language.

Perfect!

For convenience, let's also pull our translations automatically every time we start up our dev server or build. I'm going to prepend the command to each of my scripts:

package.json
...
  "scripts": {
    "dev": "i18nexus pull && next dev",
    "build": "i18nexus pull && next build",
    "start": "i18nexus pull && next start",
    "lint": "next lint"
  },
...

To ensure that the build works in all environments, it is smart to install the i18nexus-cli as a dev dependency as well:

$ npm install i18nexus-cli --save-dev

Loading Messages into next-intl

In our RootLayout, we’re going to get the current locale from the page params and then load our messages from our messages folder. We will then pass the translations into next-intl’s NextIntlClientProvider.

Here is what that looks like:

app/[locale]/layout.js
import { NextIntlClientProvider } from 'next-intl';
import { notFound } from 'next/navigation';

export function generateStaticParams() {
  return [
    { locale: 'en' },
    { locale: 'de' },
    { locale: 'ja' },
    { locale: 'es' }
  ];
}

export default async function RootLayout({ children, params: { locale } }) {
  let messages;
  try {
    messages = (await import(`@/messages/${locale}.json`)).default;
  } catch (error) {
    notFound();
  }

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

Rendering Translations

To render a translation in our home page using next-intl, we use the useTranslations hook. This hook takes a namespace as an argument:

app/[locale]/page.js
...
import { useTranslations } from 'next-intl';

export default function Home() {
  const t = useTranslations('home');
...

The t function now has access to all translations in our home namespace.

Now let’s use it to render our translations:

app/[locale]/page.js
'use client';

import Link from 'next-intl/link';
import styles from './page.module.scss';
import { useTranslations } from 'next-intl';

export default function Home() {
  const t = useTranslations('home');

  const userName = 'David';

  return (
    <div className={styles.wrapper}>
      <h1>{t('header', { name: userName })}</h1>
      <div className={styles.links}>
        <Link href="/products">{t('links.products')}</Link>
        <Link href="/contact">{t('links.contact')}</Link>
      </div>
    </div>
  );
}

Note: When using next-intl, you should import the Link component from 'next-intl/link' instead of from 'next/navigation'.

⚡️ We are now fully internationalized! ⚡️

Let’s test by going to localhost:3000/de:

Home page rendered in browser with German translations

It works! 🎉

Changing Languages

By default, next-intl will automatically redirect visitors to their preferred language by using the accept-language header of a visitor’s request. This is great, but we also want to let them manually change languages.

Let’s create a language selector component:

components/LanguageChanger.js
'use client';

import { useRouter } from 'next-intl/client';
import { usePathname } from 'next-intl/client';

export default function LanguageChanger({ locale }) {
  const router = useRouter();
  const pathname = usePathname();

  const handleChange = e => {
    router.push(pathname, { locale: e.target.value });
  };

  return (
    <select value={locale} onChange={handleChange}>
      <option value="en">English</option>
      <option value="de">Deutsch</option>
      <option value="es">Español</option>
      <option value="ja">日本語</option>
    </select>
  );
}

To change languages with next-intl, we import the useRouter and usePathname hooks from next-intl/client. The usePathname hook gives us the current pathname without the current locale. We then pass the pathname and the selected locale to router.push to apply it.

That’s it! Let’s add it to the bottom of our page:

A fully internationalized page changing languages with an HTML select element

We now have a fully translated and internationalized Next.js app using the App Router and next-intl! 🥳

Summing It Up...

With next-intl, internationalizing a Next 13 App Router project is a breeze, especially when used with i18nexus. Not only do we have automatic machine translations for all our messages, but we are able to later invite professional translators to our i18nexus project to edit and verify our translations.

To learn more about next-intl and what other features it has, I recommend reading the documentation here. Happy internationalizing! 🌏

Level up your localization

It only takes a few minutes to streamline your translations forever.

Get started