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

Next.js Logo

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

(7 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 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
import Link from 'next/link';
import styles from './page.module.css';

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

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

Here is a look at the page:

Initial page with a header and two links

With that done, let’s internationalize! 🌎

Initial Setup

To support next-intl's routing behavior, we will 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

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

Next we will update our next.config.js to integrate withNextIntl:

next.config.js
const withNextIntl = require('next-intl/plugin')();
 
module.exports = withNextIntl({
  // Any other Next.js configuration ...
});

Lastly, at the root of our project, we will create a file called i18n.js that will contain a function to load our messages (which we will add later):

i18n.js
import { getRequestConfig } from 'next-intl/server';

export default getRequestConfig(async ({ locale }) => ({
  messages: (await import(`./messages/${locale}.json`)).default
}));

Navigation with next-intl

Now let's create a navigation.js file at the root of our project:

navigation.js
import { createSharedPathnamesNavigation } from 'next-intl/navigation';

export const locales = ['en', 'de', 'es', 'ja'];
export const localePrefix = 'as-needed';

export const { Link, redirect, usePathname, useRouter } =
  createSharedPathnamesNavigation({ locales, localePrefix });

What's going on here?

First we export our locales which is an array of language codes that we want our app to support.

Second, we define how we want our pathnames to be prefixed. By setting it to 'as-needed', our default language (en) will not appear in our pathnames, but our other languages will. For example, the German version of our /products page will be accessed at /de/products while our English page will be accessed at /products.

Lastly we export navigation components and hooks to be used throughout our app. Normally we would import these from next/navigation but when using next-intl we should import these from this navigation.js instead.

Adding Middleware

The next thing we need to do is initialize next-intl's middleware. 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';
import { localePrefix, locales } from './navigation';

export default createMiddleware({
  locales,
  localePrefix,
  defaultLocale: 'en'
});

// only applies this middleware to files in the app directory
export const config = {
  matcher: ['/((?!api|_next|.*\\..*).*)']
};

Here we call next-intl's createMiddleware function with our locales, localePrefix, and our choice of defaultLanguage.

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 navigation.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'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 using the useMessages hook. We will then pass the translations into next-intl’s NextIntlClientProvider.

Here is what that looks like:

app/[locale]/layout.js
import './globals.css';
import { notFound } from 'next/navigation';
import { NextIntlClientProvider, useMessages } from 'next-intl';
import { locales } from '@/navigation';

export default function RootLayout({ children, params: { locale } }) {
  if (!locales.includes(locale)) {
    notFound();
  }

  const messages = useMessages();

  return (
    <html lang={locale}>
      <NextIntlClientProvider locale={locale} messages={messages}>
        <body>{children}</body>
      </NextIntlClientProvider>
    </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
import { Link } from '@/navigation';
import styles from './page.module.css';
import { useTranslations } from 'next-intl';

export default function Home({ params: { locale } }) {
  const t = useTranslations('home');

  const userName = 'David';

  return (
    <main className={styles.main}>
      <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>
    </main>
  );
}

Note: Make sure to import the Link component from your navigation.js file that we created earlier.

⚡️ 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, usePathname } from '@/navigation';

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 our navigation.js file. 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, the initial configuration may seem a bit daunting. But once it's set up, internationalizing your Next.js 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 including implementing SSG, 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