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.
The Replay is a weekly newsletter for dev and engineering leaders.
Delivered once a week, it's your curated guide to the most important conversations around frontend dev, emerging AI tools, and the state of modern software.
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 captures console logs, errors, network requests, and pixel-perfect DOM recordings from user sessions and lets you replay them as users saw it, eliminating guesswork around why bugs happen — compatible with all frameworks.
LogRocket's Galileo AI watches sessions for you, instantly identifying and explaining user struggles with automated monitoring of your entire product experience.
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.

Vibe coding isn’t just AI-assisted chaos. Here’s how to avoid insecure, unreadable code and turn your “vibes” into real developer productivity.

GitHub SpecKit brings structure to AI-assisted coding with a spec-driven workflow. Learn how to build a consistent, React-based project guided by clear specs and plans.

:has(), with examplesThe CSS :has() pseudo-class is a powerful new feature that lets you style parents, siblings, and more – writing cleaner, more dynamic CSS with less JavaScript.

Kombai AI converts Figma designs into clean, responsive frontend code. It helps developers build production-ready UIs faster while keeping design accuracy and code quality intact.
Hey there, want to help make our blog better?
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 now
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?