āš›ļø - The Ultimate i18n Guide (RTL and Headless CMS Included) for Next JS in 2020

Featured on Hashnode

The article cover is a photo from Al Ula

Hey there! šŸ‘‹

Motivation

When non-English software engineers evaluate any tool/technology, they are always concerned about Internationalization and RTL support.

In Studio 966, we chose Next JS as our application framework of choice to build and design products for our customers. We like the concepts and the tooling around it, but we found it challenging to provide a flexible/robust i18n solution out of the box.

I'm writing this guide for my future self and other colleagues who are in a similar place! šŸ¤

TL;DR

  • Go to next-translate and follow their excellent guide to get up and running (the Headless CMS part is not covered there though).
  • The final code is here

Getting Started

Generate a new Next JS application

npx create-next-app nextjs-i18n-rtl-example

Install the needed i18n library: next-translate

yarn add next-translate

Amend the package.json scripts to get the i18n library into service šŸ”Ø

{
  "name": "nextjs-i18n-rtl-example",
  "version": "0.1.0",
  "private": true,
  "scripts": {
-   "dev": "next dev",
+   "dev": "next-translate && next dev",
-   "build": "next build",
+   "build": "next-translate && next build",
-   "start": "next start"
+   "start": "next-translate && next start"
  },
  "dependencies": {
    "next": "9.5.3",
    "next-translate": "^0.17.2",
    "react": "16.13.1",
    "react-dom": "16.13.1"
  }
}

Let's configure the project to meet our i18n needs:

  • We are going to support English and Arabic.
  • English version will be the default one.
  • We will have two pages for now: The home page at / and the About page at /about.
  • We want to organize our translations into separate namespaces. The common stuff will be under common, and each page will have its namespace.
  • BONUS: We want to manage some of our translations in a Headless CMS šŸ˜

To achieve all these requirements, create a new i18n.json file at the root level of your project with the following:

{
  "allLanguages": ["en", "ar"],
  "defaultLanguage": "en",
  "currentPagesDir": "pages_",
  "finalPagesDir": "pages",
  "localesPath": "public/locales",
  "pages": {
    "*": ["common"],
    "/": ["home"],
    "/about": ["about"]
  }
}

Observe that we declared a few extra (but mandatory) things:

  • currentPagesDir
  • finalPagesDir
  • localesPath

The localesPath option is related to the path of where translations are going to live. To understand the first two, we need to know how next-translate works.

How next-translate Works

In a standard Next JS app, you are coding under the pages directory to produce pages and APIs. šŸ’Æ

However, since i18n support is in-progress, the team at next-translate found an approach that ticks all the boxes to achieve the flexible rendering target (CSR/SSR/SSG/ISR) with i18n for each route. šŸ„³

Since we added next-translate as a pre-step when we run/build our Next JS app, it will ingest all the pages we created and produce another directory with all the pages ready to be served with i18n.

The currentPagesDir is the directory where you are going to code. The finalPagesDir is the directory where the next-translate will compile your pages into i18n-aware pages served by Next JS's router.

For example, coding under /pages_

.
ā”œā”€ā”€ about.js
ā”œā”€ā”€ index.js

Will compile to the same pages with i18n under /pages

.
ā”œā”€ā”€ en
ā”‚   ā”œā”€ā”€ about.js
ā”‚   ā”œā”€ā”€ index.js
ā”œā”€ā”€ ar
ā”‚   ā”œā”€ā”€ about.js
ā”‚   ā”œā”€ā”€ index.js

It's not the prettiest, but it does the job well! šŸ‘

Finalizing Configurations

Since the pages directory is compiled every time we run the app, I advise to add it to your .gitignore file.

Create a new directory named /pages_ at the root level, and add the following pages:

ā”œā”€ā”€ pages_
ā”‚   ā”œā”€ā”€ about.js
ā”‚   ā””ā”€ā”€ index.js

Before writing some React, let's create the needed locales files. Create a locales directory under the public folder with the following folders/files:

.
ā”œā”€ā”€ public
ā”‚   ā”œā”€ā”€ locales
ā”‚   ā”‚   ā”œā”€ā”€ ar
ā”‚   ā”‚   ā”‚   ā”œā”€ā”€ about.json
ā”‚   ā”‚   ā”‚   ā”œā”€ā”€ common.json
ā”‚   ā”‚   ā”‚   ā””ā”€ā”€ home.json
ā”‚   ā”‚   ā””ā”€ā”€ en
ā”‚   ā”‚       ā”œā”€ā”€ about.json
ā”‚   ā”‚       ā”œā”€ā”€ common.json
ā”‚   ā”‚       ā””ā”€ā”€ home.json

The Arabic common.json will have:

{
  "welcome": "Ł‡Ł„Ų§ ŁˆŲ§Ł„Ł„Ł‡! šŸ‘‹",
  "pageTitles": {
    "home": "Ų§Ł„ŲµŁŲ­Ų© Ų§Ł„Ų±Ų¦ŁŠŲ³ŁŠŲ©",
    "about": "Ų¹Ł†ŁŠ"
  },
  "lang-en": "English",
  "lang-ar": "Ų§Ł„Ų¹Ų±ŲØŁŠŲ©"
}

The English common.json will have:

{
  "welcome": "Hey there! šŸ‘‹",
  "pageTitles": {
    "home": "Home",
    "about": "About"
  },
  "lang-en": "English",
  "lang-ar": "Ų§Ł„Ų¹Ų±ŲØŁŠŲ©"
}

The Arabic home.json will have:

{
  "heroText": "ŲµŁŲ­ŲŖŁŠ Ų§Ł„Ų±Ų¦ŁŠŲ³ŁŠŲ©"
}

The English home.json will have:

{
  "heroText": "My Home Page"
}

The Arabic about.json will have:

{
  "age": "Ų¹Ł…Ų±ŁŠ {{ageValue}} Ų³Ł†Ų©"
}

The English about.json will have:

{
  "age": "I'm {{ageValue}} years old"
}

Building the i18n Supported Pages

Before we continue - The app will be as minimal as possible, hence why I'm not focusing on making it pretty. šŸ’…

Open up the /pages_/index.js file and paste in this snippet:

export default function Home() {
  return <h>Welcome</h>;
}

Open up the /pages_/about.js file and paste in this snippet:

export default function About() {
  return <h>About</h>;
}

Let's take these pages to a test drive to ensure we have a working Next JS app!

> yarn dev
yarn run v1.22.4
$ next-translate && next dev
Building pages | from pages_ to pages
šŸ”Ø /about [ 'common', 'about' ]
šŸ”Ø / [ 'common', 'home' ]
ready - started server on http://localhost:3000
event - compiled successfully

To confirm the app is working successfully, visit localhost:3000 to see the (default) EN version, and localhost:3000/ar to see the AR version.

Nothing exciting so far - we have hardcoded values. Let's use our translations!

Using the Translations šŸ¤“

Update the /pages_/index.js file and paste in this snippet

import useTranslation from "next-translate/useTranslation";

export default function Home() {
  const { t } = useTranslation();
  return (
    <div>
      <p>{t("common:welcome")}</p>
      <p>{t("home:heroText")}</p>
    </div>
  );
}

Update the /pages_/about.js file and paste in this snippet

import useTranslation from "next-translate/useTranslation";

export default function About() {
  const { t } = useTranslation();
  return (
    <div>
      <p>{t("common:welcome")}</p>
      <p>{t("about:age", { ageValue: 32 })}</p>
    </div>
  );
}

Now, revisit the pages and observe the translations are working as expected! šŸŽ‰

So far, all the routing is done by manually changing the URL. Let's fix this and add some links.

Routing and Switching Language

next-translate provides wrappers on Next's Link and Router to facilitate the i18n aware routing and language switching.

Update the /pages_/index.js file and paste in this snippet

import useTranslation from "next-translate/useTranslation";
import Link from "next-translate/Link";
import i18nConfig from "../i18n.json";

const { allLanguages } = i18nConfig;

export default function Home() {
  const { t, lang } = useTranslation();
  return (
    <div>
      <p>{t("common:welcome")}</p>
      <p>{t("home:heroText")}</p>
      <Link href="/about">{t(`common:pageTitles.about`)}</Link>
      {allLanguages.map((lng) =>
        lang === lng ? null : (
          <div key={lng}>
            <Link href="/" lang={lng}>
              {t(`common:lang-${lng}`)}
            </Link>
          </div>
        )
      )}
    </div>
  );
}

Update the /pages_/about.js file and paste in this snippet

import useTranslation from "next-translate/useTranslation";
import Link from "next-translate/Link";
import i18nConfig from "../i18n.json";

const { allLanguages } = i18nConfig;

export default function About() {
  const { t, lang } = useTranslation();
  return (
    <div>
      <p>{t("common:welcome")}</p>
      <p>{t("about:age", { ageValue: 32 })}</p>
      <Link href="/">{t(`common:pageTitles.home`)}</Link>
      {allLanguages.map((lng) =>
        lang === lng ? null : (
          <div key={lng}>
            <Link href="/about" lang={lng}>
              {t(`common:lang-${lng}`)}
            </Link>
          </div>
        )
      )}
    </div>
  );
}

Observe that the language switching link is only going to show if the available language is different than the current language. I.e., if I'm in English, don't offer a switcher to English.

Switching the language itself is as simple as visiting the URL with that different language as a prop! šŸ’Æ

We got a lot of stuff done. We are in the right direction. Let's fix the RTL issue now.

Supporting RTL

In most cases, simply adding dir="auto" to the highest wrapper parent element will fix the RTL issue.

Update the /pages_/index.js file and paste in this snippet

import useTranslation from "next-translate/useTranslation";
import Link from "next-translate/Link";
import i18nConfig from "../i18n.json";

const { allLanguages } = i18nConfig;

export default function Home() {
  const { t, lang } = useTranslation();
  return (
    <div dir="auto">
      <p>{t("common:welcome")}</p>
      <p>{t("home:heroText")}</p>
      <Link href="/about">{t(`common:pageTitles.about`)}</Link>
      {allLanguages.map((lng) =>
        lang === lng ? null : (
          <div key={lng}>
            <Link href="/" lang={lng}>
              {t(`common:lang-${lng}`)}
            </Link>
          </div>
        )
      )}
    </div>
  );
}

If you explicitly need to set the direction, you can use the following approach.

Update the /pages_/about.js file and paste in this snippet

import useTranslation from "next-translate/useTranslation";
import Link from "next-translate/Link";
import i18nConfig from "../i18n.json";

const { allLanguages } = i18nConfig;

const rtlLangs = ["ar"];
const getDirFromLang = (lang) => (rtlLangs.includes(lang) ? "rtl" : "ltr");

export default function About() {
  const { t, lang } = useTranslation();
  return (
    <div dir={getDirFromLang(lang)}>
      <p>{t("common:welcome")}</p>
      <p>{t("about:age", { ageValue: 32 })}</p>
      <Link href="/">{t(`common:pageTitles.home`)}</Link>
      {allLanguages.map((lng) =>
        lang === lng ? null : (
          <div key={lng}>
            <Link href="/about" lang={lng}>
              {t(`common:lang-${lng}`)}
            </Link>
          </div>
        )
      )}
    </div>
  );
}

We got ourselves a fully working Next JS app with i18n. Let's complete the last step - controlling some translations from a Headless CMS.

Controlling Translations from a Headless CMS

Commonly, our customers require a CMS to manage the content of their apps. There are many great options. You can choose what suits you. Ultimately, the approach from our application side is similar.

We want to control the common:welcome message from our Headless CMS.

For this guide, we will go with DatoCMS.

  • Create a new account
  • Create a new project
  • Add Arabic as an additional locale to English
  • Create a new model called cms-common representing the page/namespace
  • Under this model, create a new field of type text called welcome representing the translation key and ensure enabling the localization on it
  • Switch to the content tab and create a new record filling the proper translations (we will use the same ones for now)

Now the CMS is ready to be consumed by our app. Go to the settings in your DatoCMS account and copy the Read-only API token so you can use it from the Next JS app.

Retrieving The Translations

Until now, we have been reading the translations from JSON files stored in our public directory. Let's read from our freshly created CMS. šŸ¤©

Update the /pages_/index.js file and paste in this snippet

import useTranslation from "next-translate/useTranslation";
import Trans from "next-translate/Trans";
import DynamicNamespaces from "next-translate/DynamicNamespaces";
import Link from "next-translate/Link";
import i18nConfig from "../i18n.json";

const { allLanguages } = i18nConfig;

const CMS_URL = "https://graphql.datocms.com/";
const CMS_TOKEN = "YOUR_TOKEN";
const GENERATE_FETCH_OPTIONS = (lang) => ({
  method: "POST",
  headers: {
    Authorization: `Bearer ${CMS_TOKEN}`,
  },
  body: JSON.stringify({
    query: `
    query Translations {
      cmsCommon(locale: ${lang}) {
        welcome
      }
    }
  `,
  }),
});

export default function Home() {
  const { t, lang } = useTranslation();
  return (
    <DynamicNamespaces
      dynamic={(lang) =>
        fetch(CMS_URL, GENERATE_FETCH_OPTIONS(lang))
          .then((r) => r.json())
          .then((r) => r.data.cmsCommon)
      }
      namespaces={["cms"]}
      fallback="Loading..."
    >
      <div dir="auto">
        <Trans i18nKey="cms:welcome" />
        <p>{t("home:heroText")}</p>
        <Link href="/about">{t(`common:pageTitles.about`)}</Link>
        {allLanguages.map((lng) =>
          lang === lng ? null : (
            <div key={lng}>
              <Link href="/" lang={lng}>
                {t(`common:lang-${lng}`)}
              </Link>
            </div>
          )
        )}
      </div>
    </DynamicNamespaces>
  );
}

I know - a lot of changes! šŸ˜‚ Let me simplify it:

  • We wrapped our page with the DynamicNamespaces component, enabling reading dynamic JSON source on the fly. I.e., you can get your translations from anywhere you want as long as the resulted JSON is valid. (the reason I highlighted the approach is the same with any CMS option. šŸ‘)
  • We explicitly conveyed the new namespace(s) that used and called it cms
  • We replaced the conventional t() function with the Trans component to handle such dynamic translation

That's awesome! But I don't like this flashing Loading... text every time I refresh the page. Let's get Next JS SSR/SSG capabilities into use! šŸš€

Retrieving The Translations The SSR Way! šŸ˜Ž

For the sake of showing different approaches, let's keep the / page as is and update our /about with the SSR version.

import useTranslation from "next-translate/useTranslation";
import Link from "next-translate/Link";
import Trans from "next-translate/Trans";
import i18nConfig from "../i18n.json";
import DynamicNamespaces from "next-translate/DynamicNamespaces";

const { allLanguages } = i18nConfig;

const rtlLangs = ["ar"];
const getDirFromLang = (lang) => (rtlLangs.includes(lang) ? "rtl" : "ltr");

const CMS_URL = "https://graphql.datocms.com/";
const CMS_TOKEN = "YOUR_TOKEN";
const GENERATE_FETCH_OPTIONS = (lang) => ({
  method: "POST",
  headers: {
    Authorization: `Bearer ${CMS_TOKEN}`,
  },
  body: JSON.stringify({
    query: `
    query Translations {
      cmsCommon(locale: ${lang}) {
        welcome
      }
    }
  `,
  }),
});

export default function About({ translations }) {
  const { t, lang } = useTranslation();
  return (
    <DynamicNamespaces dynamic={() => translations} namespaces={["cms"]}>
      <div dir={getDirFromLang(lang)}>
        <Trans i18nKey="cms:welcome" />
        <p>{t("about:age", { ageValue: 32 })}</p>
        <Link href="/">{t(`common:pageTitles.home`)}</Link>
        {allLanguages.map((lng) =>
          lang === lng ? null : (
            <div key={lng}>
              <Link href="/about" lang={lng}>
                {t(`common:lang-${lng}`)}
              </Link>
            </div>
          )
        )}
      </div>
    </DynamicNamespaces>
  );
}

export async function getServerSideProps({ lang }) {
  const resp = await fetch(CMS_URL, GENERATE_FETCH_OPTIONS(lang));
  const data = await resp.json();

  return {
    props: {
      translations: data.data.cmsCommon,
    },
  };
}

The difference now is that we are utilizing getServerSideProps to dip into SSR goodness. Observe how we have access to the language from the server-side. The power of next-translate! šŸ’Æ

We can use getStaticProps with the revalidate option to convert the pure SSG into ISR and it will work the same too. šŸ‘

Conclusion

I'm delighted with the React tooling and ecosystem in 2020. I would like to thank all the brilliant engineers who enabled us to enjoy building stuff on the web!

If you enjoyed this guide, please like it and share it. Follow me if you are interested in more articles on JavaScript (React, React Native, Node JS), GraphQL (Hasura), and Elixir (Phoenix)! āœŒļø

Rakesh Vardan's photo

This is so cool Faisal Alghurayri. Thank you for sharing.

Peter Thaleikis's photo

Oh neat! Thanks for writing this article!