Translating web applications into multiple languages is a common requirement. In the past, creating multilingual applications was not an easy task, but recently (thanks to the people behind the Next.js framework and Lingui.js library) this task has gotten a lot easier.
In this post, I’m going to show you how to build internationalized applications with the previously mentioned tools. We will create a sample application that will support static rendering and on-demand language switching.
You can check out the demo and fork the repository here.
First, we need to create Next.js application with TypeScript. Enter the following into the terminal:
npx create-next-app --ts
Next, we need to install all required modules:
npm install --save-dev @lingui/cli @lingui/loader @lingui/macro babel-plugin-macros @babel/core npm install --save @lingui/react make-plural
One of the fundamental aspects of internationalizing a Next.js application is internationalized routing functionality, so users with different language preferences can land on different pages, and be able to link to them.
Additionally, with proper link tags in the head of the site, you can tell Google where to find all other language versions of the page for proper indexing.
Next.js supports two types of internationalized routing scenarios.
The first is subpath routing, where the first subpath (www.myapp.com/{language}/blog) marks the language that is going to be used. For example, www.myapp.com/en/tasks
or www.myapp.com/es/tasks
. In the first example, users will use the English version of the application (en
) and in the second, users will use the Spanish version (es
).
The second is domain routing. With domain routing, you can have multiple domains for the same app, and each domain will serve a different language. For example, en.myapp.com/tasks
or es.myapp.com/tasks
.
When a user visits the application’s root or index page, Next.js will try to automatically detect which location the user prefers based on the Accept-Language
header. If the location for the language is set (via a Next.js configuration file), the user will be redirected to that route.
If the location is not supported, the user will be served the default language route. The framework can also use a cookie to determine the user’s language.
If the NEXT_LOCALE
cookie is present in the user’s browser, the framework will use that value to determine which language route to serve to the user, and the Accept-Language
header will be ignored.
We are going to have three languages for our demo: default English (en
), Spanish (es
), and my native language Serbian (sr
).
Because the default language will be English, any other unsupported language will default to that.
We are also going to use subpath
routing to deliver the pages, like so:
//next.config.js module.exports = { i18n: { locales: ['en', 'sr', 'es', 'pseudo'], defaultLocale: 'en' } }
In this code block, locales
is all the languages we want to support and defaultLocale
is the default language.
You will note that, in the configuration, there is also a fourth language: pseudo
. We will discuss more of that later.
As you can see, this Next.js configuration is simple, because the framework is used only for routing and nothing else. How you are going to translate your application is up to you.
For actual translations, we are going to use Lingui.js.
Let’s set up the configuration file:
// lingui.config.js module.exports = { locales: ['en', 'sr', 'es', 'pseudo'], pseudoLocale: 'pseudo', sourceLocale: 'en', fallbackLocales: { default: 'en' }, catalogs: [ { path: 'src/translations/locales/{locale}/messages', include: ['src/pages', 'src/components'] } ], format: 'po' }
The Lingui.js configuration is more complicated than Next.js, so let’s go over each segment one by one.
locales
and pseudoLocale
are all of the locations we are going to generate, and which locations will be used as pseudo locations, respectively.
sourceLocale
is followed by en
because default strings will be in English when translation files are generated. That means that if you don’t translate a certain string, it will be left with the default, or source, language.
The fallbackLocales
property has nothing to do with the Next.js default locale, it just means that if you try to load a language file that doesn’t exist, Lingui.js will fallback to the default
language (English, in our case).
catalogs:path
is the path where the generated files will be saved. catalogs:include
instructs Lingui.js where to look for files that need translating. In our case, this is the src/pages
directory, and all of our React components that are located in src/components
.
format
is the format for the generated files. We are using the po
format, which is recommended, but there are other formats like json
.
There are two ways we can use Lingui.js with React. We can use regular React components provided by the library, or we can use Babel macros, also provided by the library.
Linqui.js has special React components and Babel macros. Macros transform your code before it is processed by Babel to generate final JavaScript code.
If you are wondering about the difference between the two, take a look at these examples:
//Macro import { Trans } from '@lingui/macro' function Hello({ name }: { name: string }) { return <Trans>Hello {name}</Trans> } //Regular React component import { Trans } from '@lingui/react' function Hello({ name }: { name: string }) { return <Trans id="Hello {name}" values={{ name }} /> }
As you can see, the code between the macro and the generated React component is very similar. Macros enable us to omit the id
property and write cleaner components.
Now let’s set up translation for one of the components:
// src/components/AboutText.jsx import { Trans } from '@lingui/macro' function AboutText() { return ( <p> <Trans id="next-explanation">My text to be translated</Trans> </p> ) }
After we are done with the components, the next step is to extract the text from our source code that needs to be translated into external files called message catalogs.
Message catalogs are files that you want to give to your translators for translating. Each language will have one file generated.
To extract all the messages, we are going to use Lingui.js via the command line and run:
npm run lingui extract
The output should look like the following:
Catalog statistics: ┌──────────┬─────────────┬─────────┐ │ Language │ Total count │ Missing │ ├──────────┼─────────────┼─────────┤ │ es │ 1 │ 1 │ │ en │ 1 │ 0 │ │ sr │ 1 │ 1 │ └──────────┴─────────────┴─────────┘ (use "lingui extract" to update catalogs with new messages) (use "lingui compile" to compile catalogs for production)
Total count
is the total number of messages that need to be translated, and in our code we only have one message from AboutText.jsx
(ID: next-explanation
).
Missing
is the number of messages that need to be translated. Because English is the default language, there are no missing messages for the en
version. However, we are missing translations for Serbian and Spanish.
The contents of the en
generated file will be something like this:
#: src/components/AboutText.jsx:5 msgid "next-explanation" msgstr "My text to be translated"
And the contents of es
file will be the following:
#: src/components/AboutText.jsx:5 msgid "next-explanation" msgstr ""
You will notice that the msgstr
is empty. This is where we need to add our translation. In case we leave the field empty, at runtime, all components that refer to this msgid
will be populated with the string from the default language file.
Lets translate the Spanish file:
#: src/components/AboutText.jsx:5 msgid "next-explanation" msgstr "Mi texto para ser traducido"
Now, if we run the extract
command again, this will be the output:
Catalog statistics: ┌──────────┬─────────────┬─────────┐ │ Language │ Total count │ Missing │ ├──────────┼─────────────┼─────────┤ │ es │ 1 │ 0 │ │ en │ 1 │ 0 │ │ sr │ 1 │ 1 │ └──────────┴─────────────┴─────────┘ (use "lingui extract" to update catalogs with new messages) (use "lingui compile" to compile catalogs for production)
Notice how the Missing
field for the Spanish language is 0
, which means that we have translated all the missing strings in the Spanish file.
This is the gist of translating, now let’s start integrating Lingui.js with Next.js.
For the application to consume the files with translations (.po
files), they need to be compiled to JavaScript. For that, we need to use the lingui compile
CLI command.
After the command finishes running, you will notice that inside the locale/translations
directory there are new files for each locale (es.js
, en.js
, and sr.js
):
├── en │ ├── messages.js │ └── messages.po ├── es │ ├── messages.js │ └── messages.po └── sr ├── messages.js └── messages.po
These are the files that are going to be loaded into the application. Treat these files as build artifacts and do not manage them with source control; only .po
files should be added to source control.
One other thing that will certainly come up is working with singular or plural words (in the demo, you can test that with the Developers dropdown element).
Lingui.js makes this very easy:
import { Plural } from '@lingui/macro' function Developers({ developerCount }) { return ( <p> <Plural value={developerCount} one="Whe have # Developer" other="We have # Developers" /> </p> ) }
When the developerCount
value is 1
, the Plural
component will render “We have 1 Developer.”
You can read more about plurals in the Lingui.js documentation.
Now, different languages have different rules for pluralization. To accommodate those rules we are later going to use one additional package called make-plural
.
Now comes the hardest part: integrating Lingui.js with the Next.js framework.
First, we are going to initialize Lingui.js:
// utils.ts import type { I18n } from '@lingui/core' import { en, es, sr } from 'make-plural/plurals' //anounce which locales we are going to use and connect them to approprite plural rules export function initTranslation(i18n: I18n): void { i18n.loadLocaleData({ en: { plurals: en }, sr: { plurals: sr }, es: { plurals: es }, pseudo: { plurals: en } }) }
Because initialization should only be done once for the whole app, we are going to call the function from the Next.js _app
component, which by design wraps all other components:
// _app.tsx import { i18n } from '@lingui/core' import { initTranslation } from '../utils' //initialization function initTranslation(i18n) function MyApp({ Component, pageProps }) { // code ommited }
After the Lingui.js code is initialized, we need to load and activate the appropriate language.
Again, we are going to use _app
for that, like so:
// _app.tsx function MyApp({ Component, pageProps }) { const router = useRouter() const locale = router.locale || router.defaultLocale const firstRender = useRef(true) if (pageProps.translation && firstRender.current) { //load the translations for the locale i18n.load(locale, pageProps.translation) i18n.activate(locale) // render only once firstRender.current = false } return ( <I18nProvider i18n={i18n}> <Component {...pageProps} /> </I18nProvider> ) }
All components that consume the translations need to be under the Lingui.js <I18Provider>
component. In order to determine which language to load, we are going to look into the Next.js router locale
property.
Translations are passed to the component via pageProps.translation
. If you are wondering how is pageProps.translation
property is created, we are going to tackle that next.
Every page in src/pages
before it gets rendered needs to load the appropriate file with the translations, which reside in src/translations/locales/{locale}
.
Because our pages are statically generated, we are going to do it via the Next.js getStatisProps
function:
// src/pages/index.tsx export const getStaticProps: GetStaticProps = async (ctx) => { const translation = await loadTranslation( ctx.locale!, process.env.NODE_ENV === 'production' ) return { props: { translation } } }
As you can see, we are loading the translation file with the loadTranslation
function. This is how it looks:
// src/utils.ts async function loadTranslation(locale: string, isProduction = true) { let data if (isProduction) { data = await import(`./translations/locales/${locale}/messages`) } else { data = await import( `@lingui/loader!./translations/locales/${locale}/messages.po` ) } return data.messages }
The interesting thing about this function is that it conditionally loads the file depending on whether we are running the Next.js project in production or not.
This is one of the great things about Lingui.js; when we are in production we are going to load compiled (.js
) files, but when we are in development mode, we are going to load the source (.po
) files. As soon as we change the code in the .po
files it is going to immediately reflect in our app.
Remember, .po
files are the source files where we write the translations, which are then compiled to plain .js
files and loaded in production with the regular JavaScript import
statement. If it weren’t for the special @lingui/loader!
webpack plugin, we would have to constantly manually compile the translation files to see the changes while developing.
Up to this point, we handled the static generation, but we also want to be able to change the language dynamically at runtime via the dropdown.
First, we need to modify the _app
component to watch for location changes and start loading the appropriate translations when the router.locale
value changes. This is pretty straightforward; all we need to do is to use the useEffect
hook.
Here is the final _app
component:
// _app.tsx // import statements omitted initTranslation(i18n) function MyApp({ Component, pageProps }) { const router = useRouter() const locale = router.locale || router.defaultLocale const firstRender = useRef(true) // run only once on the first render (for server side) if (pageProps.translation && firstRender.current) { i18n.load(locale, pageProps.translation) i18n.activate(locale) firstRender.current = false } // listen for the locale changes useEffect(() => { if (pageProps.translation) { i18n.load(locale, pageProps.translation) i18n.activate(locale) } }, [locale, pageProps.translation]) return ( <I18nProvider i18n={i18n}> <Component {...pageProps} /> </I18nProvider> ) }
Next, we need the build the dropdown component. Every time the user selects a different language from the dropdown, we are going to load the appropriate page.
For that, we are going to use the Next.js router.push
method to instruct Next.js to change the locale of the page (which will, in turn, be picked up by the useEffect
we created in the _app
component):
// src/components/Switcher.tsx import { useRouter } from 'next/router' import { useState, useEffect } from 'react' import { t } from '@lingui/macro' type LOCALES = 'en' | 'sr' | 'es' | 'pseudo' export function Switcher() { const router = useRouter() const [locale, setLocale] = useState<LOCALES>( router.locale!.split('-')[0] as LOCALES ) const languages: { [key: string]: string } = { en: t`English`, sr: t`Serbian`, es: t`Spanish` } // enable 'pseudo' locale only for development environment if (process.env.NEXT_PUBLIC_NODE_ENV !== 'production') { languages['pseudo'] = t`Pseudo` } useEffect(() => { router.push(router.pathname, router.pathname, { locale }) }, [locale, router]) return ( <select value={locale} onChange={(evt) => setLocale(evt.target.value as LOCALES)} > {Object.keys(languages).map((locale) => { return ( <option value={locale} key={locale}> {languages[locale as unknown as LOCALES]} </option> ) })} </select> ) }
Now I’m going to address all the pseudo
code that you have seen in the examples.
Pseudo localization is a software testing method that replaces text strings with altered versions while still maintaining string visibility. This makes it easy to spot which strings we have missed wrapping in the Lingui.js components or macros.
So when the user switches to the pseudo
locale, all the text in the application should be modified like this:
Account Settings --> [!!! Àççôûñţ Šéţţîñĝš !!!]
If any of the text is not modified, that means that we probably forgot to do it. When it comes to Next.js, the framework has no notion of the special pseudo
localization, it is just another language to be routed to. However, Lingui.js requires special configuration.
Other than that, pseudo
is just another language we can switch to. pseudo
locale should only be enabled in the development
mode.
In this article, I have shown you how to translate and internationalize a Next.js application. We have done static rendering for multiple languages and on-demand language switching. We have also created a nice development workflow where we don’t have to manually compile translation strings on every change. Next, we implemented a pseudo
locale in order the visually check if there are no missing translations.
If you have any questions post them in the comments, or if you find any issues with the code in the demo, make sure to open an issue on the github repository.
Debugging Next applications can be difficult, especially when users experience issues that are difficult to reproduce. If you’re interested in monitoring and tracking state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket.
LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your Next.js app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app's performance, reporting with metrics like client CPU load, client memory usage, and more.
The LogRocket Redux middleware package adds an extra layer of visibility into your user sessions. LogRocket logs all actions and state from your Redux stores.
Modernize how you debug your Next.js apps — start monitoring for free.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowBuild scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.
Deno is a popular JavaScript runtime, and it recently launched version 2.0 with several new features, bug fixes, and improvements […]
7 Replies to "The complete guide to internationalization in Next.js"
Hi friend. It is a great article, With your tutorial I have set i18n in my next.js application with Lingui.js thanks.
But I come with some issues that I resolve, may be it will help other if it happens.
NB: I don’t use Typescript
1
– I get Module not found: Can’t resolve ‘fs’ when I add Lingui configurations and tools
– to fix: configure .babelrc like this
{
“presets”: [“next/babel”],
“plugins”: [“macros”]
}
2
– In most Next.js application folder I think we don’t have “src” folder so the path where Lingui will look at translation can be an issue if they start with “src”, lingui extract will not return any data
3
– “npm run lingui extract” result as an issue because we don’t setup the script.
– to fix: in script of package.json we can add :
{
“extract”: “lingui extract”,
“compile”: “lingui compile”
}
4
– In _app.js I remove “firstRender.current” because it blocks the rendering when I change the language in my menu.
But again thank you I set translation in my app and may be I’ll add NEXT_LOCALE.
Great article.
Thanks, If you have any problems with the code you can file an issue on the github repo, and we can take it from there.
Ok cool. I’ll do it.
Thank you for the writeup, wondering why you put the code for loading translations inside getStaticProps? This means you have the overhead of adding the same code to every page that relies on translated content. Wouldn’t offloading the message loading to _app also work, where you have access to the routers locale and can act accordingly?
Cheers
Interesting idea, you are welcome to create a pull request 🙂
Hey, the locale works only If I visit the index root, but I load another url in my website, the locale doesnt work… the locale doesnt appear in my url. Do you know why?
Hi Ivan, thank you for the great tutorial. I’m trying to migrate to App Router atm. Are you aware of any tutorial where the App Router is being used?