Localizing Remix apps with i18next

There's a lot if libraries to implement i18n in JS and React, and i18next is one of the most popular out there. For plain old React apps created with something like Create React App it's really simple to setup it, even for Next.js it has a specific library to implement it. With Remix instead we will need to implement the library by hand.

How it will work

There are multiple ways we can detect the language preference of the user, some of them depends if we are server-side or client-side.

For server-side we have:

  • Search params
  • Cookies
  • Accept-Language header

For client-side we have:

  • Search params
  • Cookies
  • navigator.language API

As we can see the first two are shared and the third is actually the same value but accessed in different ways.

What we will do is to check:

  1. If the user has a lng key on the search params we will use that
  2. If the user has a i18next cookie with will use that
  3. If the user has an accept-language (server) or navigator.language (client) we will use that
  4. We will default to English if nothing above works or the prefered language is not supported by our application

The i18n configuration

First of all, we will create a i18n.ts file somewhere in our code where we will put most of the code related to the setup.

First we will create an array with our supported languages.

let supportedLanguages = ["es", "en"];

And we need to know the default language.

let defaultLanguage = "en";

Now we need a way to detect, using that list of supported languages, if the user preferred language is supported, we can use a module accept-language-parser used to parse the Accept-Language header, but we can actually use it to support the header or any string value. Let's create a getFromSupported function.

import { pick } from "accept-language-parser";

function getFromSupported(language: string | null) {
  return (
    pick(supportedLanguages, language ?? defaultLanguage, { loose: true }) ??
    defaultLanguage
  );
}

Now, we need a function we can use to detect the preferred language from the request.

import type { Request } from "remix";

export function detectLanguage(request: Request) {
  // first we prioritize the URL, if the user adds the `lng` is most likely what they want
  let url = new URL(request.url);
  if (url.searchParams.has("lng")) {
    return getFromSupported(url.searchParams.get("lng"));
  }

  // then we use the cookie, using this cookie we can store the user preference with a form
  let cookie = Object.fromEntries(
    request.headers
      .get("Cookie")
      ?.split(";")
      .map((cookie) => cookie.split("=")) ?? []
  ) as { i18next?: string };

  if (cookie.i18next) {
    return getFromSupported(cookie.i18next);
  }

  // and then we check the Accept-Language header and use that, this will have the value
  // of the language the user use for their OS
  if (request.headers.has("accept-language")) {
    return getFromSupported(request.headers.get("accept-language"));
  }

  // finally, we fallback to our default language
  return defaultLanguage;
}

And with that, we need a way to initialize i18next with the configuration we need for client and server.

// we will import i18n and the types of their option
import i18n, { InitOptions } from "i18next";
// for client-side language detection we will use this plugin, it will follow a similar flow as we did for the server
import LanguageDetector from "i18next-browser-languagedetector";
// to load the localized messages client-side we will use this plugin to fetch them
import HttpApi from "i18next-http-backend";
// and to use it with React with need this plugin
import { initReactI18next } from "react-i18next";
// we need to re-export fs.promises from a .server file to ensure it's not shipped to the browser
import { fs } from "./fs.server";

let isBrowser = typeof window === "object" && typeof document === "object";

export async function initI18Next(i18next: typeof i18n, language?: string) {
  // first we add the generic configuration for client and server
  let options: InitOptions = {
    fallbackLng: "en",
    supportedLngs: supportedLanguages,
    keySeparator: false,
    load: "languageOnly",
    initImmediate: true,
    interpolation: { escapeValue: false },
    react: { useSuspense: false },
    detection: {
      caches: ["cookie"],
    },
  };

  // then we add configuration used only server-side
  if (!isBrowser) {
    // here we set the language we are going to use
    options.lng = language ?? defaultLanguage;
    // and the namespace
    options.defaultNS = "namespace1";
  }

  // then we add the configuration used only client-side
  if (isBrowser) {
    // here we configure the path for our JSON filas with the localized messages
    options.backend = { loadPath: "/locales/{{lng}}.json" };
    // and here we set what to use as cache for the language detection
    options.detection = { caches: ["cookie"] };
    // and we tell i18next to use the language detector and http api plugins
    i18next.use(LanguageDetector).use(HttpApi);
  }

  // now we tell i18next to use the React plunig and wee initialize it with our options
  await i18next.use(initReactI18next).init(options);

  if (!isBrowser) {
    // finally if we are running server-side we will require the localized message from the public/locales folder
    // and add it as a resource bundle to i18next so it has the localized message already loaded
    i18n.addResourceBundle(
      language ?? defaultLanguage,
      "namespace1",
      require(`../../public/locales/${language}.json`)
    );
  }
}

entry.server

With our i18n.ts module ready, we can use it, we will go to our entry.server.tsx and initialize i18next there.

import i18n from "i18next";
import ReactDOMServer from "react-dom/server";
import { I18nextProvider } from "react-i18next";
import type { EntryContext, Request } from "remix";
import { RemixServer } from "remix";
import { detectLanguage, initI18Next } from "./framework/shared/i18n";

export default async function handleRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext
) {
  // we detect the language and initialize i18next with that language
  await initI18Next(i18n, detectLanguage(request));

  // we render the RemixServer component wrapping it in the I18nextProvider
  // this way our app will have access to the i18n instance with the messages loaded
  let markup = ReactDOMServer.renderToString(
    <I18nextProvider i18n={i18n}>
      <RemixServer context={remixContext} url={request.url} />
    </I18nextProvider>
  );
  // finally send the response
  return new Response("<!DOCTYPE html>" + markup, {
    status: responseStatusCode,
    headers: {
      ...Object.fromEntries(responseHeaders),
      "Content-Type": "text/html",
    },
  });
}

entry.client

After the entry.server it comes the entry.client.tsx, we will do something similar here.

import i18n from "i18next";
import ReactDOM from "react-dom";
import { I18nextProvider } from "react-i18next";
import { RemixBrowser } from "remix";
import { initI18Next } from "./framework/shared/i18n";

// we initialize i18next
initI18Next(i18n)
  .then(() => {
    // and after it started and fetched the messages we will hydrate our app wrapped with
    // the I18nextProvider component too
    return ReactDOM.hydrate(
      <I18nextProvider i18n={i18n}>
        <RemixBrowser />
      </I18nextProvider>,
      document
    );
  })
  .catch((error) => console.error(error));

Waiting for i18next before the hydrate is needed because if we don't wait for the messages to be loaded our app will go back to the default language after the hydrate, and after the messages load it will switch back to the user preferred language, another option is to enable Suspense for loaded the messages but it will switch the whole app back to a spinner.

Preloading messages

Because our app needs to wait for the localized messages to load client-side and we already know the language server-side we can help our users wait a little bit less by preloading the messages.

We will go to the root.tsx file and in the loader we will detect the language.

export let loader: LoaderFunction = async ({ request }) => {
  let language = detectLanguage(request);
  return json({ language });
};

Then, in the same file, we will update or add the links function and we will use the language from the loader to know what JSON file to preload.

export let links: LinksFunction = ({ data }) => {
  return [
    {
      rel: "preload", // we want to preload
      href: `/locales/${data.language}.json`, // the locales for the user language
      as: "fetch", // as a fetch request
      type: "application/json", // we expect the type application/json
      crossOrigin: "anonymous", // and the request should be cross origin anonymous
    },
  ];
};

Now, if we go to the browser in the Networks tab of the devtools we will see, even before the JS load, the correct localized messages already loaded. Then if the JS doesn't take too much to load it will re-use that request and the app will be hydrated without waiting for the messages to load, because they are already loaded.

Using it

With all of this, we can use i18next inside our components as you would do in a normal React app or a Next app.

import { useTranslation } from "react-i18next";

export default function View() {
  let [t] = useTranslation();
  return <h1>{t("Dashboard")}</h1>
}