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