Getting Internationalization (i18n) Right With Remix And Headless CMS — Smashing Magazine

Getting Internationalization (i18n) Right With Remix And Headless CMS

About The Authors

Arisa is a Frontend Developer who became a DevRel Engineer. She works at Storyblok to share and improve better DX through talks, maintaining SDKs, and …
More about
Facundo & Arisa ↬

Email Newsletter

This article will show you the impact of internationalization, its fundamental logic, how to approach it with Remix, and optionally, how to manage it more conveniently using a headless CMS.

How much of a language barrier is there still in the 21st century? You, as the reader, are probably very familiar with English, but what about others?

Nowadays, most of us have often heard the importance of accessibility, better performance, and better UX or DX. You might not hear or often see about i18n compared with these topics. But if you see facts and numbers from the statistics, you might find some surprising results about i18n and its impact. Let’s find out about that together.

i18n And l10n

Before we go through the impact of i18n, let’s learn the difference between the two terminologies.

As a follow-up, i18n contains a programmatic process to implement features for content editors and translators to be able to start the l10n process from the UI.

Why Does i18n Matters That Much?

To see the importance of i18n, let’s look at the numbers and statistics for objective information. You will see the numbers of some facts below, and before reading further, let’s guess what those numbers stand for.

The first fact shows a tremendous amount of the numbers. 5.07 billion is the number of users in this world in 2020. The world population in 2021 was 7.837 billion, nearly 8 billion. More than half of the world’s population has access to content on the internet and apps.

Based on the number of users in this world, there’s another research about the most common languages used on the internet. Looking at the chart, most of us pay attention to the highest number on this diagram: 25.9%, English.

The second highest is the rest of the languages, 23.1%. Also, suppose you gather the rest of the percentage except English. In that case, you may realize out of the over 5 billion users, 74.1% of the users are accessing the content in any other language.

After going through these facts, we can now talk about why internationalizing and localizing your content for Asia and China, in particular, is crucial. China has the most internet users worldwide. As a result, more than half of all internet users globally are from Asia.

Based on what we saw, probably, we can not ignore localizing content. It will improve UX if these huge amounts of users worldwide could have localized content. After knowing the potential impact of proper i18n, let’s look at the fundamental logic.

How i18n Works At A Basic Level

Regardless of the technology to implement i18n, there are three ways to determine languages and regions.

Using the IP address detects the region of the users and allows them to access content in their regional languages. However, the users’ IP address does not necessarily match their language preference. Moreover, location analysis prevents the sites from being crawled by search engines.

Using Accept-Language header or Navigator.languages is another possible approach to implement i18n. However, this approach provides language information but not regional information.

i18n is not just about localizing content. It includes improving UX as well. For example, creating identifiers in URLs enhances UX. It also helps to divide localized content into the dedicated system. We will cover how it’s possible to implement such a system in the “A Combination Of Remix And CMS” section.

Typically, identifiers in URLs exist in three different patterns:

To follow the same-origin policy for better SEO, localized sub-directories can be used.

Based on the interesting facts and the fundamental logic for implementing i18n, we’ll talk about frameworks and libraries, as some of them use i18n libraries.

In order to not reinvent the wheel whenever we want to implement i18n into our projects, developers have different libraries, tools, and services that can be used to facilitate the work. If we are working with React or React-based frameworks, we have different options available. Let’s talk about some of them.

Format.js

Format.js is a modular collection of JavaScript libraries that we can use to implement i18n logic in both the client and the server. This group of libraries is focused on formatting numbers, dates, and strings. It offers different functionalities and tooling and runs in the browser as well as in the Node.js runtime. It integrates with various frameworks, like Vue and React, so that we can use its functionalities on our Remix projects. You can read more about it on the official React Intl’s docs.

Another alternative that we can evaluate for our project is i18next. This JavaScript library goes beyond the standard i18n features, providing a whole suite to manage i18n in our projects. We can detect users’ language, cache translations, and even install plugins and extensions. As it was built in JavaScript, we can use this tool for websites as well as for mobile and desktop applications.

What About Remix?

When creating a website using Remix, we have different options to consider. As it is a React-based framework, we can use any of the previously mentioned libraries. However, we will go through two approaches that can fit better in your Remix projects. First, we will see how to localize content using remix-i18next, a Remix specific library for i18n. Second, we will use a headless content management system as the source of our content’s different languages/locales.

remix-i18next

Based on i18next, Sergio Xalambrí, one of the main Remix contributors, created remix-i18next. This library offers similar features and modules as the JavaScript library but focusing on Remix concepts and approaches. Easy to set up and use, production-ready, and without any requirement or dependency. Let’s have a closer look at how to implement i18n into our Remix projects using remix-i18next.

First of all, we need to install some npm packages:

All of them will help us to manage i18n on both the server and the client side of our website. We will also use them to set up our backend and define the logic that will detect the language from the user.

Now, we should add some configuration that will be used across the website from both the client and the server side. Let’s create a couple of JSON files with the translations of the different character strings that we’ll use on our website:

By naming the files “common.json”, we’re defining the namespace for the strings that we’ll list in them.

Now, let’s create a file called i18n.js. This file contains different configuration settings that we’ll use at the moment of initializing our i18n server.

You can see more configuration options in the official i18next docs.

Now, create the file i18next.server.js, which contains logic that will be used in the entry.server.jsx file of our Remix project.

import Backend from "i18next-fs-backend";
    import { resolve } from "node:path";
    import { RemixI18Next } from "remix-i18next";
    import i18n from "~/i18n"; // The configuration file we created
    
    let i18next = new RemixI18Next({
      detection: {
        supportedLanguages: i18n.supportedLngs,
        fallbackLanguage: i18n.fallbackLng,
      },
      i18next: {
        ...i18n,
        backend: {
          loadPath: resolve('./public/locales/{{lng}}/{{ns}}.json'),
        },
      },
      backend: Backend,
    });
    
    export default i18next;

We’re basically initializing a new i18n server that will run with our Remix backend. We’re specifying the location of the JSON files containing the translations to be used.
Let’s add these features to our main Remix config files. First, we add some logic to be able to translate content client side. To do that, let’s edit our `entry.client.jsx` file:

import i18next from "i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import Backend from "i18next-http-backend";
import { I18nextProvider, initReactI18next } from "react-i18next";
import { getInitialNamespaces } from "remix-i18next";
import i18n from "./i18n"; // The configuration file we created

i18next
  .use(initReactI18next)
  .use(LanguageDetector)
  .use(Backend)
  .init({
    ...i18n, // The same config we created for the server
    ns: getInitialNamespaces(),
    backend: {
      loadPath: "/locales/{{lng}}/{{ns}}.json",
    },
    detection: {
      order: ["htmlTag"],
      caches: [],
    },
  })
  .then(() => {
    // After i18next init, hydrate the app
    hydrateRoot(
      document,
      // Wrap RemixBrowser in I18nextProvider
      <I18nextProvider i18n={i18next}>
        <RemixBrowser />
      </I18nextProvider>
    );
  });

We need to wait to ensure translations are loaded before the hydration in order to keep our web app interactive.

Let’s add the logic to the entry.server.jsx file now:

import { createInstance } from "i18next";
import Backend from "i18next-fs-backend";
import { resolve } from "node:path";
import { I18nextProvider, initReactI18next } from "react-i18next";
import i18next from "./i18next.server"; // The backend file we created
import i18n from "./i18n"; // The configuration file we created

...

export default async function handleRequest(
...
) {
  // We create a new instance of i18next
  let instance = createInstance();

  // We can detect the specific locale from each request
  let lng = await i18next.getLocale(request);
  // The namespaces the routes about to render wants to use
  let ns = i18next.getRouteNamespaces(remixContext);

  await instance
    .use(initReactI18next)
    .use(Backend)
    .init({
      ...i18n,// The config we created
      lng, // The locale we detected from the request
      ns,
      backend: {
        loadPath: resolve("./public/locales/{{lng}}/{{ns}}.json"),
      },
    });

  return new Promise((resolve, reject) => {
    ...

    let { pipe, abort } = renderToPipeableStream(
      
        {" "}
      ,
      ...
    );
    ...
  });
}

Identifying users’ preferred language will allow us, among other things, to redirect them to certain routes.

Now we can start using the functionalities provided by remix-i18next to detect the user’s locale and deliver translated content based on that. Let’s edit the root.jsx file:

...

import { json } from "@remix-run/node";
import { useChangeLanguage } from "remix-i18next";
import { useTranslation } from "react-i18next";
import i18next from "~/i18next.server";

...

export let loader = async ({ request }) => {
  let locale = await i18next.getLocale(request);
  return json({ locale });
};

export let handle = {
  i18n: "common",
};

export default function App() {
  // Get the locale from the loader
  let { locale } = useLoaderData();
  let { i18n } = useTranslation();

  useChangeLanguage(locale);

  return (
    <html lang={locale} dir={i18n.dir()}>
      ...
    </html>
  );
}

The useChangeLanguage hook will change the language of the instance to the locale detected by the loader. Whenever we do something to change the language this locale will change, and i18next will load the correct translations.

Now, we are able to translate content in any route:

import { useTranslation } from "react-i18next";

export default function MyPage() {
  let { t } = useTranslation();
  return <h1>{t("intro")}</h1>;
}

We use the t() function to show translated strings based on the list of messages that we defined in our JSON files.

In this example, we use one default namespace, but we can set up multiple namespaces if we want. You can read more about the t() function in the official i18next docs.

In case we want to translate content server side, we can use the getFixedT method inside our loaders and actions:

import i18next from "~/i18next.server";

...

export let loader = async ({ request }) => {
  let t = await i18next.getFixedT(request);
  let title = t("intro");
  return json({ title });
};

A Combination Of Remix And CMS

Together, we explored the available options to implement i18n with Remix. At the beginning of this article, we learned that i18n could result in hugely improved UX and SEOUX, and SEO. As part of UX, it’s also important to include better DX.

The approach above creates translation files at the source code level. Also, we don’t have the logic to implement identifiers in URLs. To achieve this, let’s look at the approach of integrating a CMS. In this article, we’ll use Storyblok, which offers three different approaches to localizing content and handles to determine the languages and regions.

Note: If you want to create the connection between your Remix app and Storyblok, there’s a 5-minute tutorial that explains just how to do that.

After that, you can quickly clone a starter space by using this magic link to have all the necessary components and field types. This example space covers an approach called folder-level translation. We’ll cover what it is about in the next section.
https://app.storyblok.com/#!/build/181387

Choose Between Three Approaches

Storyblok has three ways to create the layout to store localized content and determine languages and regions.

  1. Folder-level translation: Divide localized content in folder-level.
  2. Field-level translation: Translate in field-type level.
  3. Space-level translation: Dedicate spaces (environments or repositories) into certain localized content.

To cover identifiers in URLs, folder-level translation works perfectly, as each folder will only contain relevant localized content.

Folder Level Translation
(Large preview)

Also, identifiers can be modified from the folder settings via the slug.

Folder settings
(Large preview)

By modifying the slug from the folder settings screen, this localized identifier in the URL appears in all stories inside this Japanese folder. For example, the about page inside of the Japanese folder already has a localized identifier in the URL.

Storyblok page general teaser
(Large preview)

To programmatically generate content pages, Remix has a feature called Splats, catching all slugs regardless of the nested levels. Naming a file $.jsx will enable the catch-all slug fundamental function.

app
├── root.jsx
└── routes
    ├── files
    │   └── $.jsx
    └── files.jsx

The difference between dynamic segments from Remix is that splats still match at the next /. Therefore, splats will capture everything in the path. If the URL path is hello.com/ja/about/something, the splat route has a special param to capture the trailing segments of the URL.

export async function loader({ params }) {
  params["*"]; // "ja/about/something"
}

Using the splat route’s special param, let’s edit $.jsx file.

export default function Page() {
 // useLoaderData returns JSON parsed data from loader func
 let story = useLoaderData();
 story = useStoryblokState(story, {
   resolveRelations: ["featured-posts.posts", "selected-posts.posts"]
 });
 return <StoryblokComponent blok={story.content} />
};
// loader is Backend API & Wired up through useLoaderData
export const loader = async ({ params, preview = false }) => {
 let slug = params["*"] ?? "home";
 slug = slug.endsWith("/") ? slug.slice(0, -1) : slug;
 let sbParams = {
   version: "draft",
   resolve_relations: ["featured-posts.posts", "selected-posts.posts"],
 };
 // …
 let { data } = await getStoryblokApi().get(`cdn/stories/${slug}`,
 sbParams);
 return json(data?.story, preview);
};

HINT: In the section “Choose between 3 approaches”, we didn’t cover all three approaches, but if you’d like to know more, all approaches are documented below.

Summary

We learned together the facts and statistics to know the impacts and the importance of i18n and saw how Remix handles several options to implement advanced i18n. Interestingly, a better i18n experience provides better SEO and UX. Hopefully, this article provided you with new knowledge and insightful learnings.

  • “Internationalization,” Storyblok Docs
Video version of this article https://portal.gitnation.org/contents/lets-remix-to-localize-content-1072
Remix docs https://remix.run/docs/en/v1
remix-i18next https://github.com/sergiodxa/remix-i18next
Storyblok docs https://www.storyblok.com/docs/guide/introduction
Smashing Editorial
(vf, il)
import i18next from "i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import Backend from "i18next-http-backend";
import { I18nextProvider, initReactI18next } from "react-i18next";
import { getInitialNamespaces } from "remix-i18next";
import i18n from "./i18n"; // The configuration file we created

i18next
  .use(initReactI18next)
  .use(LanguageDetector)
  .use(Backend)
  .init({
    ...i18n, // The same config we created for the server
    ns: getInitialNamespaces(),
    backend: {
      loadPath: "/locales/{{lng}}/{{ns}}.json",
    },
    detection: {
      order: ["htmlTag"],
      caches: [],
    },
  })
  .then(() => {
    // After i18next init, hydrate the app
    hydrateRoot(
      document,
      // Wrap RemixBrowser in I18nextProvider
      <I18nextProvider i18n={i18next}>
        <RemixBrowser />
      </I18nextProvider>
    );
  });

We need to wait to ensure translations are loaded before the hydration in order to keep our web app interactive.

Let’s add the logic to the entry.server.jsx file now:

import { createInstance } from "i18next";
import Backend from "i18next-fs-backend";
import { resolve } from "node:path";
import { I18nextProvider, initReactI18next } from "react-i18next";
import i18next from "./i18next.server"; // The backend file we created
import i18n from "./i18n"; // The configuration file we created

...

export default async function handleRequest(
...
) {
  // We create a new instance of i18next
  let instance = createInstance();

  // We can detect the specific locale from each request
  let lng = await i18next.getLocale(request);
  // The namespaces the routes about to render wants to use
  let ns = i18next.getRouteNamespaces(remixContext);

  await instance
    .use(initReactI18next)
    .use(Backend)
    .init({
      ...i18n,// The config we created
      lng, // The locale we detected from the request
      ns,
      backend: {
        loadPath: resolve("./public/locales/{{lng}}/{{ns}}.json"),
      },
    });

  return new Promise((resolve, reject) => {
    ...

    let { pipe, abort } = renderToPipeableStream(
      
        {" "}
      ,
      ...
    );
    ...
  });
}

Identifying users’ preferred language will allow us, among other things, to redirect them to certain routes.

Now we can start using the functionalities provided by remix-i18next to detect the user’s locale and deliver translated content based on that. Let’s edit the root.jsx file:

The useChangeLanguage hook will change the language of the instance to the locale detected by the loader. Whenever we do something to change the language this locale will change, and i18next will load the correct translations.

Now, we are able to translate content in any route:

We use the t() function to show translated strings based on the list of messages that we defined in our JSON files.

In this example, we use one default namespace, but we can set up multiple namespaces if we want. You can read more about the t() function in the official i18next docs.

In case we want to translate content server side, we can use the getFixedT method inside our loaders and actions:

A Combination Of Remix And CMS

Together, we explored the available options to implement i18n with Remix. At the beginning of this article, we learned that i18n could result in hugely improved UX and SEOUX, and SEO. As part of UX, it’s also important to include better DX.

The approach above creates translation files at the source code level. Also, we don’t have the logic to implement identifiers in URLs. To achieve this, let’s look at the approach of integrating a CMS. In this article, we’ll use Storyblok, which offers three different approaches to localizing content and handles to determine the languages and regions.

Note: If you want to create the connection between your Remix app and Storyblok, there’s a 5-minute tutorial that explains just how to do that.

After that, you can quickly clone a starter space by using this magic link to have all the necessary components and field types. This example space covers an approach called folder-level translation. We’ll cover what it is about in the next section.
https://app.storyblok.com/#!/build/181387

Choose Between Three Approaches

Storyblok has three ways to create the layout to store localized content and determine languages and regions.

To cover identifiers in URLs, folder-level translation works perfectly, as each folder will only contain relevant localized content.

Also, identifiers can be modified from the folder settings via the slug.

By modifying the slug from the folder settings screen, this localized identifier in the URL appears in all stories inside this Japanese folder. For example, the about page inside of the Japanese folder already has a localized identifier in the URL.

To programmatically generate content pages, Remix has a feature called Splats, catching all slugs regardless of the nested levels. Naming a file $.jsx will enable the catch-all slug fundamental function.

The difference between dynamic segments from Remix is that splats still match at the next /. Therefore, splats will capture everything in the path. If the URL path is hello.com/ja/about/something, the splat route has a special param to capture the trailing segments of the URL.

Using the splat route’s special param, let’s edit $.jsx file.

export default function Page() {
 // useLoaderData returns JSON parsed data from loader func
 let story = useLoaderData();
 story = useStoryblokState(story, {
   resolveRelations: ["featured-posts.posts", "selected-posts.posts"]
 });
 return <StoryblokComponent blok={story.content} />
};
// loader is Backend API & Wired up through useLoaderData
export const loader = async ({ params, preview = false }) => {
 let slug = params["*"] ?? "home";
 slug = slug.endsWith("/") ? slug.slice(0, -1) : slug;
 let sbParams = {
   version: "draft",
   resolve_relations: ["featured-posts.posts", "selected-posts.posts"],
 };
 // …
 let { data } = await getStoryblokApi().get(`cdn/stories/${slug}`,
 sbParams);
 return json(data?.story, preview);
};

HINT: In the section “Choose between 3 approaches”, we didn’t cover all three approaches, but if you’d like to know more, all approaches are documented below.

We learned together the facts and statistics to know the impacts and the importance of i18n and saw how Remix handles several options to implement advanced i18n. Interestingly, a better i18n experience provides better SEO and UX. Hopefully, this article provided you with new knowledge and insightful learnings.

Smashing Editorial
(vf, il)

This content was originally published here.