Next.js 13 App Router with next-intl (Tutorial)
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:

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:

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:

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

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:

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:

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
:

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

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
:

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:

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