โ๏ธ - The Ultimate i18n Guide (RTL and Headless CMS Included) for Next JS in 2020
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 theTrans
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)! โ๏ธ