The complete guide to internationalization in Next.js

9 min read 2741

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.

Gif of sample Next.js application switching between languages

You can check out the demo and fork the repository here.

Setup

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

Internationalized routing in Next.js

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, http://www.myapp.com/en/tasks or http://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).

We made a custom demo for .
No really. Click here to check it out.

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.

How Next.js detects the user’s language

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.

Configuring our sample Next.js app

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.

Configuring Lingui.js

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.

How Lingui.js works with React

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.

Compiling messages

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.

Working with plurals

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.

Next.js and Lingui.js integration

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.

Changing the language dynamically

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>
  )
}

Pseudolocalization

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.

Conclusion

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.

LogRocket: Full visibility into production Next.js apps

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 apps, recording literally everything that happens on your Next 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 — .

Leave a Reply