Translate your Remix.run app with Lingui

Earlier this year we were on the lookout for a new i18n library for our projects at work. We were using i18next for a long time, but we were looking for something more modern and flexible. We found Lingui, a library that offers a different approach to i18n in Javascript projects. I was so excited about it that I decided to contribute to the lingui repository to add a Remix.run example.

In this article, we will show you how to use Lingui in your Remix.run project with Vite.

Why Lingui?

Or rather "Why not i18next?".

One of the reason I wanted to try something else is that I was not happy with the developer experience of i18next. I found it hard to use and not very flexible, especially for complex translations (e.g. text with variables & components in it). I am always surprised that almost all educational content out there is about i18next. I was sure there was a better experience and a new project is always a good opportunity to try something new.

I found Lingui, a library that offers a different approach to internationalization in Javascript projects. It's a modern library that is easy to use and has a great developer experience. It's also very flexible and can be used in any Javascript project.

After reading the docs, I knew this was the right tool. It's exactly how I want to deal with translations in my Javascript projects.

Key features of Lingui

Here's some of the key features of Lingui that make the difference in my opinion:

  • No more keys: The hardest thing in programming is naming things. On i18next, the docs uses keys to identify translations. On Lingui's docs, you will see that they use the actual text as the key. This is a game changer for me. No need to think about key names, just write the text and you're done.
  • Built-in extraction: A simple CLI command to extract messages from your code and save them in a .po file, for free.
  • Easy Rich Text: Lingui supports rich text translations out of the box. You can use HTML tags or components in your translations and it will work.
  • Contextualized translations: You can add context to your translations to avoid ambiguity. This is very useful when you have multiple translations for the same text.

There are many more features, but to me these are the most important ones.

Setup Lingui in your Remix.run project

Okay let's get started and install Lingui in your project.


_10
pnpm add @lingui/core @lingui/react @lingui/macro @lingui/detect-locale
_10
pnpm add -D @lingui/conf @lingui/cli @lingui/vite-plugin vite-plugin-babel-macros

Vite configuration

Let's start with the vite config. Add the following plugins to your vite.config.js:

vite.config.ts

_11
import { lingui } from "@lingui/vite-plugin";
_11
import macrosPlugin from "vite-plugin-babel-macros";
_11
_11
export default defineConfig({
_11
plugins: [
_11
remix(),
_11
macrosPlugin(),
_11
lingui(),
_11
tsconfigPaths()
_11
],
_11
});

Lingui configuration

Now let's create a configuration file for Lingui. Create a lingui.config.ts file at the root of your project:

lingui.config.ts

_16
import type { LinguiConfig } from "@lingui/conf";
_16
_16
const config: LinguiConfig = {
_16
fallbackLocales: {
_16
default: "en",
_16
},
_16
locales: ["en", "fr"],
_16
catalogs: [
_16
{
_16
path: "<rootDir>/app/locales/{locale}",
_16
include: ["app"],
_16
},
_16
],
_16
};
_16
_16
export default config;

With this in place you can setup the extraction command in your package.json:

package.json

_10
{
_10
"scripts": {
_10
"lingui:extract": "lingui extract"
_10
}
_10
}

You can make sure everything is working by running the extract command:


_12
> pnpm lingui:extract
_12
_12
Catalog statistics:
_12
┌──────────┬─────────────┬─────────┐
_12
│ Language │ Total count │ Missing │
_12
├──────────┼─────────────┼─────────┤
_12
│ en │ 0 │ 0 │
_12
│ fr │ 0 │ 0 │
_12
└──────────┴─────────────┴─────────┘
_12
_12
(use "lingui extract" to update catalogs with new messages)
_12
(use "lingui compile" to compile catalogs for production)

This command should have generated two .po files in your app/locales folder.

Load translations in your app

Now that we have our translations extracted, we can load them in our app.

Create a function in your project that'll load the translations catalogs.

app/modules/lingui/lingui.ts

_10
export async function loadCatalog(locale: string) {
_10
const { messages } = await import(`../../locales/${locale}.po`);
_10
_10
return i18n.loadAndActivate({ locale, messages });
_10
}

Language detection

Out of the box, lingui offers a locale detection helper but that only works on the frontend. In Remix I want the server to be responsible for detecting the locale, just like you'd do with remix-i18next

So the easiest way I found was to copy and adapt the remix-i18n library.

You can use your own logic for this, but if you want to use the same approach, you can copy the following files into your lingui module:

And the following lingui.server.ts

app/modules/lingui/lingui.server.ts

_19
import config from "../../../lingui.config";
_19
import { RemixLingui } from "./remix.server";
_19
import { createCookie } from "@remix-run/node";
_19
_19
export const localeCookie = createCookie("lng", {
_19
path: "/",
_19
sameSite: "lax",
_19
secure: process.env.NODE_ENV === "production",
_19
httpOnly: true,
_19
});
_19
_19
export const linguiServer = new RemixLingui({
_19
detection: {
_19
supportedLanguages: config.locales,
_19
fallbackLanguage:
_19
(!!config.fallbackLocales && config.fallbackLocales?.default) || "en",
_19
cookie: localeCookie,
_19
},
_19
});

Initialize Lingui in your app

We now have all we need to start setting up lingui in Remix. We'll set it up in our entry.server.tsx & entry.client.tsx files. If you don't have them, you can create them at the root of your app folder or use the npx remix reveal command to generate them.

app/entry.server.tsx

_60
import { i18n } from "@lingui/core";
_60
import { I18nProvider } from "@lingui/react";
_60
import { linguiServer } from "./modules/lingui/lingui.server";
_60
import { loadCatalog } from "./modules/lingui/lingui";
_60
_60
// Repeat for the handleBotRequest
_60
async function handleBrowserRequest(
_60
request: Request,
_60
responseStatusCode: number,
_60
responseHeaders: Headers,
_60
remixContext: EntryContext
_60
) {
_60
const locale = await linguiServer.getLocale(request);
_60
await loadCatalog(locale);
_60
_60
return new Promise((resolve, reject) => {
_60
let shellRendered = false;
_60
const { pipe, abort } = renderToPipeableStream(
_60
<I18nProvider i18n={i18n}>
_60
<RemixServer
_60
context={remixContext}
_60
url={request.url}
_60
abortDelay={ABORT_DELAY}
_60
/>
_60
</I18nProvider>,
_60
{
_60
onShellReady() {
_60
shellRendered = true;
_60
const body = new PassThrough();
_60
const stream = createReadableStreamFromReadable(body);
_60
_60
responseHeaders.set("Content-Type", "text/html");
_60
_60
resolve(
_60
new Response(stream, {
_60
headers: responseHeaders,
_60
status: responseStatusCode,
_60
})
_60
);
_60
_60
pipe(body);
_60
},
_60
onShellError(error: unknown) {
_60
reject(error);
_60
},
_60
onError(error: unknown) {
_60
responseStatusCode = 500;
_60
// Log streaming rendering errors from inside the shell. Don't log
_60
// errors encountered during initial shell rendering since they'll
_60
// reject and get logged in handleDocumentRequest.
_60
if (shellRendered) {
_60
console.error(error);
_60
}
_60
},
_60
}
_60
);
_60
_60
setTimeout(abort, ABORT_DELAY);
_60
});
_60
}

app/entry.client.tsx

_26
import { i18n } from "@lingui/core";
_26
import { detect, fromHtmlTag } from "@lingui/detect-locale";
_26
import { I18nProvider } from "@lingui/react";
_26
import { RemixBrowser } from "@remix-run/react";
_26
import { startTransition, StrictMode } from "react";
_26
import { hydrateRoot } from "react-dom/client";
_26
import { loadCatalog } from "./modules/lingui/lingui";
_26
_26
async function main() {
_26
const locale = detect(fromHtmlTag("lang")) || "en";
_26
_26
await loadCatalog(locale);
_26
_26
startTransition(() => {
_26
hydrateRoot(
_26
document,
_26
<StrictMode>
_26
<I18nProvider i18n={i18n}>
_26
<RemixBrowser />
_26
</I18nProvider>
_26
</StrictMode>
_26
);
_26
});
_26
}
_26
_26
main()

Use Lingui in your components

Now that lingui is setup and your translations loaded you're ready to start using it in your components.

In a Page component

app/routes/_index.tsx

_10
import { Trans } from "@lingui/macro";
_10
_10
export default function Index() {
_10
return <Trans>Welcome to Remix</Trans>
_10
}

In a route loader

app/routes/_index.tsx

_10
import { t } from "@lingui/macro";
_10
_10
export function loader() {
_10
return json({
_10
title: t`New Remix App`,
_10
})
_10
}

Translate your content

Run the extraction command once more when you have added a few translations in your components and translate them. You can use any tool or even just use your code editor to add missing translations.

When it's done, try to navigate to your app and pass a lng query parameter to see the page in different languages.

http://localhost:3000/?lng=fr

Pass the locale to the client

As our server is responsible to detect & store the locale, we need to pass it to the client. You can do this by returning the locale in the root.tsx loader data.

app/root.tsx

_12
export async function loader({ request }: LoaderFunctionArgs) {
_12
const locale = await linguiServer.getLocale(request);
_12
_12
headers.append("Set-Cookie", await localeCookie.serialize(locale));
_12
_12
return json(
_12
{
_12
locale,
_12
},
_12
{ headers },
_12
);
_12
}

In the root component you can then use this locale to set the html lang attribute.

app/root.tsx

_10
export default function App() {
_10
const { locale } = useLoaderData<typeof loader>();
_10
_10
return (
_10
<html lang={locale ?? "en"}>
_10
// Rest of your layout
_10
</html>
_10
)
_10
}

Remember this is used to load the correct catalog in our entry.client.tsx file.

Change the locale

You can change the language using a form and an action or use a Link with a query parameter.

I prefer the Link approach as it just works out of the box. In your app, you just need to add a link to change the language.


_10
<Link to="?lng=fr" reloadDocument>French</Link>
_10
<Link to="?lng=en" reloadDocument>English</Link>

You can also use the form approach, but you'll need to handle the locale change in an action. You'll catch the language passed into the form and use it to update the locale cookie.

Conclusion

You're now ready to use Lingui everywhere in your Remix app.

I invite you to read the docs for the common gotchas you'll encounter in Remix/React.