Published on

How to build a fully internationalized Nextjs application using Lingui v4

In this tutorial we will build an internationalized (i18n) Nextjs application using the latest v4 version of Lingui.js.

Completing the tutorial should result in a good understanding of the Lingui internationalization process as well as a fully functioning multi-language Typescript sample application with:

  • Support for three languages (English, Dutch and Pseudo)
  • Nextjs subpath routing
  • Translation files in *.po format, ready for super smooth Crowdin online translations
  • A language switcher (optionally)
  • Working test suite integration using Vitest and the Lingui swc-plugin

Table of Contents

Disclaimer

Please note that Lingui is not limited to Nextjs and can be used in any Javascript application.

TL;DR

A repository with the resultant application can be found here.

1. Preparing the Nextjs application

Create a basic Nextjs application using the following configuration options:

  • Typescript: yes
  • ESLint: yes
  • Tailwind CSS: yes
  • Use src directory: yes
  • Use experimental app directory: no
  • Do not configure an import alias: enter
npx create-next-app@latest lingui-v4-app

Install the Lingui dependencies:

cd lingui-v4-app
npm install @lingui/core @lingui/react
npm install @lingui/cli @lingui/loader @lingui/macro --save-dev
npm install @lingui/swc-plugin --save-dev

2. Setting up Lingui

Lingui v4 comes with a lot of improvements that make setting up internationalization a lot easier. For example, plural support now comes out of the box and we no longer need to configure babel because we can use the Lingui swc-plugin instead. For an overview of all new v4 features and changes, please refer to these Migrating to v4 release notes.

lingui.config.js

Following Lingui best practices, first create file lingui.config.js in the root of your project with below content.

const { formatter } = require("@lingui/format-po")

const locales = ["en-us", "nl-nl"]

if (process.env.NODE_ENV !== "production") {
  locales.push("pseudo")
}

/** @type {import('@lingui/conf').LinguiConfig} */
module.exports = {
  locales: locales,
  sourceLocale: "en-us",
  pseudoLocale: "pseudo",
  catalogs: [
    {
      path: "<rootDir>/src/translations/locales/{locale}",
      include: [
        "<rootDir>/src/pages",
        "<rootDir>/src/translations/languages.ts",
      ],
    },
  ],
  format: formatter({ origins: false }),
}

We will get into details later but basically this configuration:

  • Is the single source of truth for all our Lingui/Nextjs internationalization settings
  • Uses English (en-us) as the base language
  • Will ony translate files found in the include paths
  • Will generate translation files in *.po format, in the folder specified as path
  • Supports two languages when in production mode
  • Supports pseudolocalization but only when in development mode

next.config.js

To make Nextjs Internationalized (i18n) Routing aware of our Lingui configuration, update next.config.js as shown below.

const linguiConfig = require("./lingui.config")

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  experimental: {
    swcPlugins: [
      [
        "@lingui/swc-plugin", {}
      ],
    ],
  },
  i18n: {
    locales: linguiConfig.locales,
    defaultLocale: linguiConfig.sourceLocale,
  },
  webpack: (config) => {
    config.module.rules.push({
      test: /\.po$/,
      use: {
        loader: "@lingui/loader", // https://github.com/lingui/js-lingui/issues/1782
      },
    })

    return config
  },
}

module.exports = nextConfig

This will:

  • Make lingui.config.js the single source of truth for Nextjs internationalizated routing
  • Enable the Lingui swc-plugin

package.json

Later on, we will use the Lingui CLI to extract translatable messages in our code into *.po message catalog files.

To prepare for extracting, add the following npm run script to your package.json

    "translations:extract": "lingui extract --clean",

crowdin.yml

Create a crowdin.yml configuration file in the root of your project with below content to ensure that Crowdin will be able to detect, use and update the correct message catalogs in your application.

files:
  - source: /src/translations/locales/en-us.po
    translation: /src/translations/locales/%locale%.po

3. Setting up the application

_document.tsx

Nextjs will automatically update the lang meta tag for your pages using the active locale but only after you have updated src/pages/_document.tsx and have changed

<Html lang="en">

to

<Html>

To make sure things work as expected:

  1. Run npm run dev
  2. Browse to localhost:3000/nl-nl
  3. Inspect the source code of the page and make sure it starts with <html lang="nl-nl">

Lingui folders

To make sure Lingui will function as expected, create the following two folders:

  • src/translations
  • src/translations/locales

languages.ts

Create src/translations/languages.ts which will:

  • Help keep our code clean
  • Prepare for adding additional languages and properties
  • Allow us to run our first CLI message extraction (which will detect the three translatable messages using the Lingui msg macro)
import { MessageDescriptor } from "@lingui/core"
import { msg } from "@lingui/macro"

interface Languages {
  locale: string
  msg: MessageDescriptor
  territory?: string
  rtl: boolean
}

export type LOCALES = "en-us" | "nl-nl" | "pseudo"

const languages: Languages[] = [
  {
    locale: "en-us",
    msg: msg`English`,
    territory: "US",
    rtl: false,
  },
  {
    locale: "nl-nl",
    msg: msg`Dutch`,
    territory: "NL",
    rtl: false,
  },
]

if (process.env.NODE_ENV !== "production") {
  languages.push({
    locale: "pseudo",
    msg: msg`Pseudo`,
    rtl: false,
  })
}

export default languages

Please note that we are deliberately using the Lingui msg macro here so that backticked messages appear in our *.po message catalog files and that we can use those messages anywhere in our code (later on). For more information about the msg macro and when to use it/not use it please visit this page.

Lingui Pro-Tip: stick to using natural language for your translatable messages and avoid using IDs at all cost. This will prevent double work and will lead to much cleaner code and a better translator experience.

4. Lingui message extraction

Message extraction is an essential step in the internationalization process. It involves:

  • Analyzing your code
  • Extracting all translatable messages into a *.po message catalog
  • Thereby keeping your message catalogs in-sync with your source code

Even though we currently only have a limited set of translatable message in our code (the backticked msg macros found in languages.ts) we will now run an extraction to get familiar with the process.

npm run translations:extract

If things went well, your screen should display something similar to:

Lingui extract command line result

Which basically means that Lingui extraction:

  • Has detected three active languages
  • Has therefore generated three message catalogs:
    • /src/translations/locales/en-us.po
    • /src/translations/locales/nl-nl.po
    • /src/translations/locales/pseudo.po
  • Has found three translatable messages in our source code
  • Has detected that three messages are currently untranslated for both the nl-nl and pseudo locales

Please note that the source and pseudo languages should NEVER be translated/touched

5. Message catalogs

The message catalogs we have just extracted contain all translatable messages and their translations.

If you open our freshly generated /src/translations/locales/nl-nl.po file it should look similar to:

msgid ""
msgstr ""
"X-Generator: @lingui/cli\n"

msgid "Dutch"
msgstr ""

msgid "English"
msgstr ""

msgid "Pseudo"
msgstr ""

6. Translating messages

Translators can translate the messages found inside the message catalogs using three methods:

  • Manually by directly editing the *.po files (highly discouraged)
  • Using local tools like the Poedit Translation Editor
  • Using online collaboration platforms like Crowdin which provide more efficient workflows, better automation possibilities and a far superior translator experience because translators only have to use the Crowdin online editor. For an impression of the (limited amount of) steps required for Crowdin translators please visit this page.

For now, we will now make our first translations by manually changing the content of /src/translations/locales/nl-nl.po to:

msgid ""
msgstr ""
"X-Generator: @lingui/cli\n"

msgid "Dutch"
msgstr "Nederlands"

msgid "English"
msgstr "Engels"

msgid "Pseudo"
msgstr "Pseudo"

If you rerun npm run translations:extract, you should see that nl-nl no longer has missing translations which means that we are now able to actually start displaying those translations inside our application.

7. Activating Lingui locales

Before we can start displaying translated messages we first need to update our application so that it will activate and load the correct locale (using the active Nextjs i18n route).

utils.ts

Create src/translations/utils.ts with below two helper functions and where:

  • loadCatalog() will be used on every page that requires translating
  • useLinguiInit() will only be used by _app.tsx to initialize Lingui
import { i18n, Messages } from "@lingui/core"
import { useRouter } from "next/router"
import { useEffect } from "react";

export async function loadCatalog(locale: string) {
  const { messages } = await import(`./locales/${locale}.po`)

  return messages
}

export function useLinguiInit(messages: Messages) {
  const router = useRouter();
  const locale = router.locale || router.defaultLocale!;
  const isClient = typeof window !== "undefined";

  if (!isClient && locale !== i18n.locale) {
    // there is single instance of i18n on the server
    i18n.loadAndActivate({ locale, messages });
  }
  if (isClient && !i18n.locale) {
    // first client render
    i18n.loadAndActivate({ locale, messages });
  }

  useEffect(() => {
    const localeDidChange = locale !== i18n.locale;
    if (localeDidChange) {
      i18n.loadAndActivate({ locale, messages });
    }
  }, [locale, messages]);

  return i18n;
}

_app.tsx

We will now enable Lingui for our application by updating src/pages/_app.tsx so that it uses useLinguiInit() and the I18nProvider and looks like this:

import "@/styles/globals.css";
import type { AppProps } from "next/app";
import { I18nProvider } from "@lingui/react";
import { useLinguiInit } from "src/translations/utils";

export default function App({ Component, pageProps }: AppProps) {
  const initializedI18n = useLinguiInit(pageProps.i18n);

  return (
    <I18nProvider i18n={initializedI18n}>
      <Component {...pageProps} />
    </I18nProvider>
  );
}

index.ts

Because Lingui v4 supports all Nextjs page rendering methods you should always select the rendering method best suitable for your application page.

In this tutorial, we choose to use Server Side Rendering (SSR) to activate the Lingui message catalog for our index page and enable it by replacing the content of src/pages/index.tsx with the following code:

import styles from "@/styles/Home.module.css";
import { GetServerSidePropsContext, GetServerSidePropsResult } from "next";
import { loadCatalog } from "src/translations/utils";
import { Trans } from "@lingui/macro";
import Link from 'next/link'

export default function Home() {
  return (
    <main className={styles.main}>
      <div>
        <ul>
          <li><Link href="/" locale="en-us">English: <Trans>English</Trans></Link></li>
          <li><Link href="/" locale="nl-nl">Dutch: <Trans>Dutch</Trans></Link></li>
          <li><Link href="/" locale="pseudo">Pseudo: <Trans>Pseudo</Trans></Link></li>
        </ul>
      </div>
    </main>
  );
}

export async function getServerSideProps(
  ctx: GetServerSidePropsContext
): Promise<GetServerSidePropsResult<any>> {
  // some server side logic

  return {
    props: {
      i18n: await loadCatalog(ctx.locale as string),
    },
  };
}

If you browse to localhost:3000 you should now see the following:

  • A list with our three languages
  • Clicking a language will load the translations for that language, handled by the Lingui Trans macro
  • When browsing to pseudo, diacritics will be shown for all messages that require a translation which is very useful for debugging purposes because untranslated elements will not contain diacritics

Please note that we are deliberately using the Lingui Trans macro here. This macro is intended for use within JSX and supports static messages, messages with variables and even messages with inline markup. For more information about the Trans macro and when to use it/not use it please visit this page.

Pro-Tip: if you are only using GetServerSideProps() to load the Lingui message catalog, you should switch to using GetStaticProps() as that will unblock Nextjs static optimizations for static pages.

404.tsx

Because the Nextjs 404 page is rendered using Static Generation (SSG) we cannot use GetServerSideProps but have to use GetStaticProps instead. To keep things interesting we will:

  • Add an additional translatable message but this time using the Lingui t macro
  • Run a new message extraction
  • Update the Dutch nl-nl message catalog
  • See our translated 404 page in action

First create src/pages/404.tsx with the following content:

import Head from "next/head";
import { GetStaticPropsContext, GetStaticPropsResult } from "next";
import { t, Trans } from "@lingui/macro";
import { loadCatalog } from "@/translations/utils";

export default function Custom404() {
  return (
    <>
      <Head>
        <title>{t`Page Not Found`}</title>
      </Head>
      <h1>
        <Trans>Page Not Found</Trans>
      </h1>
    </>
  );
}

export async function getStaticProps(
  ctx: GetStaticPropsContext
): Promise<GetStaticPropsResult<any>> {
  return {
    props: {
      i18n: await loadCatalog(ctx.locale as string),
    },
  };
}

Please note that we are now also using the Lingui t macro here. This macro is basically identical to the Trans macro but simply returns a translated string (instead of a JSX element) and is therefore perfectly suited for translating page properties etc. For more information about the t macro and when to use it/not use it please visit this page.

Let's continue with the tutorial by running npm run translations:extract and updating the nl-nl message catalog so that it looks like this:

msgid ""
msgstr ""
"X-Generator: @lingui/cli\n"

msgid "Dutch"
msgstr "Nederlands"

msgid "English"
msgstr "Engels"

msgid "Page Not Found"
msgstr "Pagina niet gevonden"

msgid "Pseudo"
msgstr "Pseudo"

If things went well, browsing to the non-existing page localhost:3000/nl-nl/whatever should show the Dutch 404 page:

  • Using the Dutch message translation for page title Page Not Found
  • Using the Dutch message translation for page content Page Not Found

8. Locale Switcher

No internationalized application is complete without a proper locale switcher that allows the user to switch languages using a dropdown menu. To prevent duplication of already available information, please turn to these excellent examples for inspiration:

9. Test suite integration

The Lingui swc-plugin is compatible with most test suites but in this tutorial we will use Vitest to provide you with an example of test suite integration.

Install the required dependencies by running:

npm install jsdom @testing-library/react --save-dev
npm install vitest vite-tsconfig-paths @vitejs/plugin-react-swc --save-dev

Add the following npm run script to package.json:

    "test": "vitest run",

Create vitest.config.ts in the root of your project with the following content:

import { loadEnvConfig } from "@next/env"
import { defineConfig } from "vitest/config"

import react from "@vitejs/plugin-react-swc"
import tsconfigPaths from "vite-tsconfig-paths"

const projectDir = process.cwd()
loadEnvConfig(projectDir)

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react({ plugins: [["@lingui/swc-plugin", {}]] }), tsconfigPaths()],
  test: {
    dir: "./",
  },
})

Create test file `src/translations/dummy.test.ts" with the following content:

import { it, expect } from "vitest"

it("is just a dummy test to make sure vitest works with lingui swc-plugin", () => {
    expect("a").toEqual("a")
})

If things went well, executing the test suite by running npm run test should produce a screen similar to:

Lingui extract command line result

10. Please remember

  • Try to use the Lingui Trans, t and msg macros wherever possible
  • Use the Lingui context property if you want to provide hints to translators
  • Use msg for constants and when passing translatable messages between functions
  • Use the Lingui UseLingui() hook or Lingui t macros might not update in realtime (e.g. when using the language switcher)

11. Rounding Up

This tutorial should have provided you with a good starting point to properly incorporate localizaton and support multiple languages in your Nextjs application but there is more to explore:

Lastly

It is highly recommended to register with, and try the free version of, Crowdin which provides more than sufficient functionality for small(er) projects:

  • Superior translator experience because translators can use the online editor, no coding skills required!
  • One Github integration for automated translations via Pull Requests
  • Instant pre-translations for new languages using machine learning
  • A description of the Crowdin workflow is described at this Lingui page

Support

If you have any additional questions regarding Lingui or need support, please visit the Lingui Discord server.