Internationalization of NextJs 13 app with App Router

Amir Latypov
5 min readOct 18, 2023

--

NextJs docs have a simple example of using internationalization with the new App router. And it’s pretty much simple, which left you with many problems after that:

- How to manage different strings in different files?

- How to check, if all strings were translated?

- How to work with templates inside the strings, and how to work with plurals?

I want to show how to solve all these problems by using the LingUi library. Lingui is an internationalization library that supports React, Vue, Angular, and even plain JS apps. Here are the problems that it solves for our NextJs app:

  • it works with .po files, which is familiar to translators.
  • it scans your source code and extracts all translatable strings.
  • it has a CLI tool that can check if all strings were translated.
  • it can compile your strings to JSON files, which can be used in the browser.

Create NextJs app

Let’s start by creating a new NextJs app:

npx create-next-app@latest
cd nextjs-lnigui/
npm install
npm run dev

Install LingUi

I prefer using Babel instead of SWC

npm install --save-dev @lingui/cli @babel/core
npm install --save-dev @lingui/macro babel-plugin-macros
npm install --save @lingui/react
npm install --save-dev @lingui/loader

Enable macroses, by adding .babelrc file into the root folder.

{
"presets": ["next/babel"],
"plugins": ["babel-plugin-macros"]
}

Create a config file in the root folder: lingui.config.js

/** @type {import('@lingui/conf').LinguiConfig} */
module.exports = {
locales: ['en', 'cs', 'fr', 'zu'],
pseudoLocale: 'zu',
catalogs: [
{
path: 'src/locales/{locale}/messages',
include: ['src'],
},
],
format: 'po',
};

pseudoLocale — it’s a feature to make it easer finding text which are not using lingui. With this config, if you set “zu” language, it will show all translatable elements as pseudo elements.

Let’s code

First, we need to detect which language to show to the user. There are multiple ways to define it, let’s use a lang prefix to make it simpler. All URLs start with a language prefix, like /en, /cs, etc.

Let’s create a dynamic route for the new app router. Create a folder [lang] inside the “src/app” directory, and create the layout file.

// src/app/[lang]/layout.tsx
import { LanguageProvider } from './LanguageProvider';

type Lang = 'en' | 'cs' | 'fr' | 'zu';

type Props = {
children: React.ReactNode;
params: { lang: Lang };
};

export default async function Layout({ params, children }: Props) {
const messages = await loadLinguiMessages(params.lang);

return (
<>
<LanguageProvider lang={params.lang} messages={messages}>
{children}
</LanguageProvider>
</>
);
}

type MessagesFile = {
messages: {
[key: string]: string;
};
};

async function loadLinguiMessages(lang: Lang) {
if (process.env.NODE_ENV === 'development') {
return ((await import(`@lingui/loader!@/locales/${lang}/messages.po`)) as MessagesFile).messages;
} else {
return ((await import(`@/locales/${lang}/messages`)) as MessagesFile).messages;
}
}

This layout renders on the server side (it’s an async function), and load messages depend on the URL language param. It loads “po” file for dev mode and uses compiled js files, for the production mode.

Server-side components can’t use Context providers, which is required for LingUi. So I just pass the loaded messages to the client component — LanguageProvider. This trick allows us to load only one language for the user, reducing files to download for a user.

// src/app/[lang]/LanguageProvider.tsx
'use client';
import { I18nProvider } from '@lingui/react';
import { i18n } from '@lingui/core';

type Props = {
children: React.ReactNode;
messages: Record<string, string>;
lang: string;
};

export function LanguageProvider({ children, messages, lang }: Props) {
i18n.loadAndActivate({ locale: lang, messages });

return <I18nProvider i18n={i18n}>{children}</I18nProvider>;
}

Now we can use LingUi macros “t” and <Trans>. Let’s create a new file to render something:

// src/app/[lang]/page.tsx
'use client';
import { Trans } from '@lingui/macro';

export default function Home() {
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
<Trans>Example text for translation</Trans>
</main>
);
}

And one final step, before these files can be translated, we need to extract these strings to the “po” files.

There are 2 CLI commands built in with LingUi :

  1. “npx lingui extract” — scan all your code files, and extract string variables from there. (To “po” files in our case). Also, it shows how many strings have not been translated yet.
  2. “npx lingui compile” — compile “po” files to js, which we will use in production.

After you run “lingui extract”, you can open http://localhost:3000/zu, and see the pseudo local symbols for the translated string.

Let’s add translation to the Spanish language

#: @file: src/locales/fr/messages.po

#: src/app/[lang]/page.tsx:7
msgid "Example text for translation"
msgstr "Exemple de texte à traduire"

And you can see this translation on the fr prefix:

It would be a good idea, to add locales/*/messages.js files to the git ignore, and compile them using GitHub Actions (or other CI\CD tools), during deploying process.

Source code: https://github.com/AmirL/nextjs-lnigui

About Me 👨‍💻

I am a full-stack developer specializing in both backend (NodeJs) and frontend (React). I have a passion for new technologies and crafting elegant, clean solutions to complex technical problems. If you enjoyed this article, follow me on LinkedIn for more insights and updates.

--

--