Software localization
Gatsby i18n: A Hands-on Guide
Let’s face it, the modern React workflow can be a pain in the neck: We have routing, server-side rendering (SSR), static generation, and more. So we find ourselves tending towards a React-based framework that gives us all these functions out of the box. One of the first and most popular of these frameworks is Gatsby. While its architectural approach does have a learning curve, the framework’s maturity and large plugin ecosystem make it an attractive option among its ilk.
When it comes to internationalization (i18n), Gatsby’s robust official offerings can take some fiddling to get working (perhaps like all things Gatsby?). This guide aims to help with that: We’ll build a small demo app and localize it with the official Gatsby i18n theme and i18next, walking through the i18n step by step. Shall we?
Packages and versions used
The following NPM packages were used in the building of our demo app.
When we run gatsby new
in a few minutes, the default Gatsby starter will install the following packages among others.
gatsby@4.12.1
➞ all hail the great Gatsbyreact@17.0.1
➞ the ubiquitous UI library Gatsby is built on top of
We’ll add the following packages ourselves as we work through the article:
gatsby-plugin-mdx@3.12.1
➞ integrates MDX Markdown with Gatsbygatsby-theme-i18n@3.0.0
➞ provides foundational i18n functionality for Gatsby appsi18next@21.6.16
➞ the popular i18n library provides UI string localization functionsreact-i18next@11.16.8
➞ provides components and hooks to easily integrate i18next with React appsi18next-phrase-in-context-editor-post-processor@1.3.0
➞ adds support for the Phrase In-Context Editor (optional, covered below)
🔗 Resource » See the package.json file in the accompanying GitHub repo for all package dependencies.
To begin (the starter app)
Our small demo app, the fictional fslivre, is a blog that celebrates the writing of F. Scott Fitzgerald.
Let’s quickly run through how to build this puppy.
🔗 Resource » You can get the code for the app before localization from the start branch of our accompanying GitHub repo.
With the Gatsby CLI installed, we can run the following in our command line to spin up our app.
$ gatsby new fslivre
This should yield a fresh Gatsby app with the official default starter packages in place.
Building the blog
A simple file-based MDX structure will do the trick for our little blog. To work with MDX files we need to install the official Gatsby MDX plugin by running the following from the command line.
$ npm install gatsby-plugin-mdx
As usual, we need to add the MDX plugin to gatsby-config.js
to complete its setup.
module.exports = { // … plugins: [ `gatsby-plugin-react-helmet`, `gatsby-plugin-image`, `gatsby-transformer-sharp`, `gatsby-plugin-sharp`, `gatsby-plugin-postcss`, `gatsby-plugin-mdx`, // … ], }
We can now add our blog content as MDX files, and pull it into our pages with GraphQL. Let’s organize our files:
. ├── blog/ │ ├── courage-after-greatness/ │ │ ├── cover.jpg │ │ └── index.mdx │ ├── the-other-side-of-paradise/ │ │ ├── cover.jpg │ │ └── index.mdx │ └── … └── src/ └── pages/ ├── blog/ │ └── {mdx.frontmatter__slug}.js └── index.js
Our MDX files are standard-fare Markdown with front matter. Note the slug
field in the front matter, as we’ll use it for our dynamic routing in a minute.
--- title: Courage After Greatness slug: courage-after-greatness published_at: "2022-04-11" hero_image: image: "./cover.jpg" alt: "Cover of book: Tender is the Night" --- Fitzgerald began the novel in 1925 after the publication of his third novel _The Great Gatsby_…
Our home page, at the root route /
, can query all the MDX files and present them as post teasers.
import * as React from "react" import { graphql } from "gatsby" import { getImage } from "gatsby-plugin-image" import Seo from "../components/seo" import Teaser from "../components/teaser" import Layout from "../components/layout" export const query = graphql` query BlogPosts { allMdx { nodes { frontmatter { hero_image { image { childImageSharp { gatsbyImageData( width: 150 placeholder: BLURRED formats: [AUTO, WEBP, AVIF] ) } } alt } slug title published_at } id excerpt(pruneLength: 150) } } } ` const IndexPage = ({ data }) => ( <Layout> <Seo title="Home" /> <h2>Recent writing</h2> {data.allMdx.nodes.map(post => { const image = getImage(post.frontmatter.hero_image.image) return <Teaser key={post.id} post={post} image={image} /> })} </Layout> ) export default IndexPage
Our Teaser
component is largely presentational, so we’ll skip much of it here for brevity. Importantly, however, the header of each post in a Teaser
links to a page with the full body of the post.
// … export default function Teaser({ post, image }) { return ( <article> // Image rendering code omitted for brevity <div> <h3> <Link to={`blog/${post.frontmatter.slug}`}> {post.frontmatter.title} </Link> </h3> <p> Published {post.frontmatter.published_at} </p> <p>{post.excerpt}</p> </div> </article> ) }
🤿 Go deeper » You can get the full code of the
Teaser
component from GitHub.
The dynamic <Link>
is handled by Gatsby’s routing system so that the route /blog/my-frontmatter-slug
automatically hits our page, src/pages/blog/{mdx.frontmatter__slug}.js
. This page attempts to find a blog by the given slug and displays its contents.
🤿 Go deeper » We’re using Gatsby’s file system route API to map our front matter slugs to our route.
import * as React from "react" import { graphql } from "gatsby" import { MDXRenderer } from "gatsby-plugin-mdx" import Seo from "../../components/seo" import Layout from "../../components/layout" export const query = graphql` query PostBySlug($frontmatter__slug: String) { mdx(frontmatter: { slug: { eq: $frontmatter__slug } }) { frontmatter { title published_at # … } body } } ` const BlogPost = ({ data }) => { const post = data.mdx return ( <Layout> <Seo title={post.frontmatter.title} /> <h1>{post.frontmatter.title}</h1> <p> Published at {post.frontmatter.published_at} </p> // Image rendering code omitted for brevity <article> <MDXRenderer>{post.body}</MDXRenderer> </article> </Layout> ) } export default BlogPost
So when a visitor hits the route /blog/courage-after-greatness
, the post with the slug courage-after-greatness
is loaded, for example.
We have a basic blog structure working, but alas, it’s all English. What if we want our site presented in other languages and locales? Well, we internationalize and localize, of course. Let’s get cooking.
🔗 Resource » You can get the entire code for our starter app from the start branch of our accompanying GitHub repo.
Localizing with the official Gatsby i18n theme
Let’s start by localizing our routes and blog content. We’ll localize our site to Arabic here, but you can add any language(s) of your choice. Luckily, the official gatsby-theme-i18n handles route and MDX content localization for us, so let’s set it up.
Installing and configuring the theme
We’ll install the theme via NPM from the command line:
$ npm install gatsby-theme-i18n
Next, we’ll configure the theme in our gatsby-config.js
.
module.exports = { // … plugins: [ // … { resolve: `gatsby-theme-i18n`, options: { defaultLang: `en`, configPath: require.resolve(`./i18n/config.json`), }, }, ], }
The defaultLang
will be the one used for our unlocalized routes, like /
or /about
. Other languages will require a route prefix, like /ar
or /ar/about
.
🤿 Go deeper » All the gatsby-theme-i18n configuration options are listed in the official docs.
Note the configPath
above: It points to a configuration file that defines our app’s supported locales. Let’s create this file now.
[ { "code": "en", // ISO 639-1 language code "hrefLang": "en-CA", // Used for <html lang> attribute "name": "English", // Human-readable name "localName": "English", // Name in the language itself "langDir": "ltr" // Layout direction for language }, { "code": "ar", "hrefLang": "ar-EG", "name": "Arabic", "localName": "عربي", "langDir": "rtl" } ]
We can add as many entries to the array in config.json
as we want; each should correspond to a unique locale our app supports and should have a unique two-letter ISO 639-1 code.
🗒 Note » The configuration data in
config.json
can be accessed via GraphQL from thethemeI18N
type. We’ll access this data indirectly via a built-inuseLocalization()
hook in a moment.
The theme ought to be configured at this point. We can test it by restarting our Gatsby development server and adding the following code to one of our components.
// … import { useLocalization } from "gatsby-theme-i18n" const IndexPage = ({ data }) => { const { locale, defaultLang, config } = useLocalization() return ( <Layout> <Seo title="Home" /> <p>Current locale: {locale}</p> <p>Default locale: {defaultLang}</p> <pre>{JSON.stringify(config, null, 2)}</pre> // ... </Layout> ) } export default IndexPage
If we visit our root route /
, we should see that Gatsby has en
(English) as the active locale. This is because we configured en
to be the default language earlier.
Hitting /ar
, however, yields ar
(Arabic) as the active locale.
This is wonderful since it saves us from mucking about with the routing system just to get localized routes. Next, we’ll use this newfound power to localize our blog MDX posts.
Localizing content
The Gatsby i18n theme will automatically recognize MDX files that are prefixed or suffixed with locale codes and assign locales to them. So a file like index.ar.mdx
will be assigned the ar
locale, for example.
Let’s update our blog content to use this. We can rename each of our blog index.mdx
files to index.en.mdx
and add an index.ar.mdx
alongside it.
Before:
. └── blog/ ├── courage-after-greatness/ │ ├── cover.jpg │ └── index.mdx ├── the-other-side-of-paradise/ │ ├── cover.jpg │ └── index.mdx └── …
After:
. └── blog/ ├── courage-after-greatness/ │ ├── cover.jpg │ ├── index.ar.mdx │ └── index.en.mdx ├── the-other-side-of-paradise/ │ ├── cover.jpg │ ├── index.ar.mdx │ └── index.en.mdx └── …
Of course, the contents of each index.ar.mdx
file should be localized to Arabic.
--- title: الشجاعة بعد العظمة slug: courage-after-greatness published_at: "2022-04-11" hero_image: image: "./cover.jpg" alt: "غلاف الكتاب: الليل حنون" --- بدأ فيتزجيرالد الرواية عام 1925 بعد نشر روايته الثالثة "غاتسبي العظيم". خلال عملية الكتابة المطولة ، تدهورت الصحة العقلية لزوجته زيلدا فيتزجيرالد ، وتطلبت دخول المستشفى لفترة طويلة بسبب ميولها الانتحارية والقتل. بعد دخولها المستشفى في بالتيمور بولاية ماريلاند ، استأجر المؤلف عقار La Paix في ضاحية توسون ليكون قريبًا من زوجته ، وواصل العمل على المخطوطة. — [ويكيبيديا](https://en.wikipedia.org/wiki/Tender_Is_the_Night)
✋ Heads up » We’re sharing the same front matter
slug
value acrossen.mdx
andar.mdx
versions. This way we can use theslug
to query for a single post as we’ve done before, and further filter by locale to get theen
orar
version of that post. We’ll see this in action soon.
Updating our GraphQL queries
The Gatsby i18n theme will add the active locale as a variable named locale
to each page’s context, making it available to our GraphQL queries.
A locale
field will also be added to the mdx
GraphQL type and will be equal to the locale in the corresponding file’s suffix. So an MDX file with the suffix ar.mdx
will have a node with {fields{locale: {eq: "ar"}}}
.
We can use these two new additions in tandem to filter our blog index list by the active locale.
// ... export const query = graphql` query BlogPosts($locale: String) { allMdx(filter: { fields: { locale: { eq: $locale } } }) { nodes { frontmatter { hero_image { # Image query omitted for brevity } slug title published_at } id # Add `truncate: true` to enable pruning for # non-ascii languages excerpt(pruneLength: 150, truncate: true) } } } ` // Component rendering is the same as before const IndexPage = ({ data }) => { return ( <Layout> <Seo title="Home" /> <h2>Recent writing</h2> {data.allMdx.nodes.map(post => { const image = getImage(post.frontmatter.hero_image.image) return <Teaser key={post.id} post={post} image={image} /> })} </Layout> ) } export default IndexPage
And with that, we now have localized content on our index page. When we visit /
, our app looks the same as before, but when we visit /ar
, we see our Arabic content.
Our standalone {mdx.frontmatter__slug}.js
page will need a similar update to its query to show localized content. The main difference here is that we’re filtering by both the locale and the front-matter slug. These will map to the corresponding route like /{locale}/blog/{frontmatter__slug}
.
// … export const query = graphql` query PostBySlug( $frontmatter__slug: String, $locale: String ) { mdx( frontmatter: { slug: { eq: $frontmatter__slug } } fields: { locale: { eq: $locale } } ) { frontmatter { title published_at hero_image { # Image query omitted for brevity } } body } } ` // Component rendering is the same as before const BlogPost = ({ data }) => { const post = data.mdx return ( <Layout> <Seo title={post.frontmatter.title} /> <h1>{post.frontmatter.title}</h1> <p> Published at {post.frontmatter.published_at} </p> // … <article> <MDXRenderer>{post.body}</MDXRenderer> </article> </Layout> ) } export default BlogPost
Localized links
If we were to manually navigate to a blog post by typing, say, /ar/blog/courage-after-greatness
into our browser’s address bar, we would get this post’s Arabic version. The links on the index page, however, will currently point to the default English versions of blog posts: None of the links on our site are locale-aware.
An easy fix for this is swapping Gatsby’s Link
component with the LocalizedLink
component from gatsby-theme-i18n
. The latter will add the locale URI prefix to links for us.
// Given that the active locale is "ar" <LocalizedLink to="about">About</LocalizedLink> // renders to => <a href="/ar/about">About</a>
Of course, LocalizedLink
will omit the prefix for the default en
locale. Additionally, the component is just a wrapper around Gatsby’s Link
, so it will inherit the latter’s performance benefits without any extra work by us.
Generally speaking, we’ll want to go through and replace every occurrence of <Link>
with <LocalizedLink>
in our app.
// … // Remove the next line: import { Link } from 'gatsby' // Use this instead: import { LocalizedLink as Link } from "gatsby-theme-i18n" export default function Teaser({ post, image }) { return ( <article> // … <div> <h3> <Link to={`/blog/${post.frontmatter.slug}`}> {post.frontmatter.title} </Link> </h3> <p> Published {post.frontmatter.published_at} </p> <p>{post.excerpt}</p> </div> </article> ) }
Now when we click on a blog post from our Arabic index, we land on the Arabic post.
✋ Heads up » Links within rendered Markdown from MDX posts won’t be localized by default. Luckily, gatsby-theme-i18n provides an
MdxLink
component that can help with that. Take a look at the official docs to learn how to use it.
Localizing UI with i18next
So far we have localized routing and content, but what about the strings in our React components and pages? It would certainly look odd if we left those unlocalized. We can take care of this with the popular i18next library, which has first-class React support. Luckily for us, Gatsby is built on React, so we can make use of i18next to internationalize our demo app.
🤿 Go deeper » A Guide to React Localization with i18next covers more i18next + React topics, so take a look there if you feel we’ve missed something in this article.
Setup: installation and creating a custom plugin
Let’s install i18next and create a small Gatsby wrapper plugin around it so that we can localize our pages and components during server-side rendering and on the browser.
🗒 Note » An official i18next add-on theme can be added to the Gatsby i18n theme we’re currently using, and it does the integration we’re about to do here for you. However, if you want to modify the
i18next
instance during initialization—say to add i18next plugins—you might not be able to with the official add-on theme. For maximum flexibility, we’ll be going the manual route here.
Installing the libraries
We’ll want both the core i18next library and the react-i18next extension framework. Let’s install them through NPM.
$ npm install i18next react-i18next
Creating the plugin
OK, with the packages installed, let’s write a custom Gatsby plugin to bootstrap an i18next
instance and provide it to all our pages. Here’s what our plugin file structure will look like:
. ├── blog/ │ └── … ├── src/ │ └── … ├── i18n/ │ ├── … │ └── l10n/ │ ├── ar/ │ │ └── translation.json │ └── en/ │ └── translation.json └── plugins/ ├── gatsby-theme-i18n-i18next-wrapper/ │ ├── gatsby-browser.js │ ├── gatsby-node.js │ ├── gatsby-ssr.js │ ├── index.js │ └── package.json └── src/ └── wrap-page-element.js
We’ll put our translations in per-locale files that look like the following:
{ "about": "About us", "app_description": "A blog dedicated to F. Scott Fitzgerald", "app_name": "fslivre", "articles": "Articles", // … }
{ "about": "نبذة", "app_description": "مدونة مخصصة لأف سكوت فتزجيرلد", "app_name": "أف أس ليفر", "articles": "مقالات", // … }
In our plugin, we’ll determine the active locale and feed that locale’s translation JSON to i18next. We’ll then be able to dynamically load translations by key via i18next.t()
. Here’s an example of how we'll use the solution:
// Given the above JSON translation files // In our component import { useTranslation } from "react-i18next" const IndexPage = () => { const { t } = useTranslation() return <h2>{t("articles")}</h2> } // When active locale is "en", our component renders to: <h2>Articles</h2> // When active locale is "ar", we get: <h2>مقالات</h2>
We’ll see this in action after we finish our setup. Now let’s go through our plugin files, starting with gatsby-node.js
, which Gatsby will load automatically while it’s building our site, calling any exported Gatsby Node APIs from the file.
const path = require(`path`) // … let absoluteLocalesDirectory // Called during Gatsby execution, runs as soon as plugins are loaded. exports.onPreInit = ({ store }, { locales }) => { // … // Get the absolute path to the locales directory absoluteLocalesDirectory = path.join( store.getState().program.directory, locales ) } // Let plugins extend/mutate the site’s webpack configuration. exports.onCreateWebpackConfig = ({ actions, plugins }) => { // Expose the absolute path to the locale directory as // a global variable. actions.setWebpackConfig({ plugins: [ plugins.define({ GATSBY_THEME_I18N_I18NEXT_WRAPPER: JSON.stringify( absoluteLocalesDirectory ), }), ], }) }
gatsby-node.js
exposes a GATSBY_THEME_I18N_I18NEXT_WRAPPER
global variable which holds the absolute path to our locale JSON parent directory. We can now use this variable during SSR and on the browser to initialize an i18next
instance. We'll do that via a shared isomorphic wrapPageElement
function.
export { wrapPageElement } from "./src/wrap-page-element"
export { wrapPageElement } from "./src/wrap-page-element"
Both our gatsby-ssr.js
and gatsby-browser.js
files are loaded and used by Gatsby during SSR and on the browser, respectively. The files export the shared wrapPageElement
function which Gatsby will call automatically to allow our plugin to wrap the React component of the currently requested page before rendering it.
wrapPageElement
is a good place for creating an i18next
instance and injecting it into an I18nextProvider
that wraps each of our pages. So where is this elusive function, you ask? Coming right up.
🔗 Resource » Read the
wrapPageElement
documentation for the Gatsby SSR and Gatsby browser APIs.
/* global GATSBY_THEME_I18N_I18NEXT_WRAPPER */ // 👆Supress ESLINT error regarding global variables import * as React from "react" import i18next from "i18next" import { I18nextProvider } from "react-i18next" const wrapPageElement = ({ element, props }, options) => { // The Gatsby I18n plugin we installed earlier will add // the active locale to our page context const currentLocale = props.pageContext.locale // Use the variable exposed by gatsby-node.js to find // the JSON file corresponding to the active locale e.g. // /absolute/path/to/i18n/l10n/ar/translation.json // when active locale is "ar" const translation = require(`${GATSBY_THEME_I18N_I18NEXT_WRAPPER}/${currentLocale}/translation.json`) // Initialize the i18next instance i18next.init({ lng: currentLocale, // Load translations for the active locale resources: { [currentLocale]: { translation } }, fallbackLng: "en", // Make init() run synchronously, ensuring that // resources/translations are loaded as soon as init() // finishes (default behaviour is async loading) initImmediate: false, // Output useful logs to the browser console debug: process.env.NODE_ENV === "development", // Disable escaping for cross-site scripting (XSS) // protection, since React does this for us interpolation: { escapeValue: false }, }) // Wrap 🌯 return <I18nextProvider i18n={i18next}>{element}</I18nextProvider> } export { wrapPageElement }
Our shared wrapPageEement
function ensures that an i18next
instance, including the translations for the active locale, is available to each of our Gatsby pages.
🤿 Go deeper » Check out all of i18next’s config options and get more details on the I18nextProvider in the official documentation.
🔗 Resource » Our plugin code is largely based on the official Gatsby i18next add-on theme. You can peruse the theme’s code on GitHub.
Configuring our plugin
The final step in wiring up i18next to our Gatsby project is registering and configuring our shiny new plugin in gastby-config.js
.
module.exports = { // … plugins: [ `gatsby-plugin-react-helmet`, // … { resolve: `gatsby-theme-i18n`, options: { defaultLang: `en`, configPath: require.resolve(`./i18n/config.json`), }, }, { // Gatsby will automatically resolve from the // /plugins directory resolve: `gatsby-theme-i18n-i18next-wrapper`, options: { // Provide the relative path to our translation files // to our plugin locales: `./i18n/l10n`, }, }, ], }
🔗 Resource » Get the code for our little custom plugin from GitHub.
✋ Heads up » When developing with
gatsby develop
you might get an error saying “Warning: Cannot update a component (Header
) while rendering a different component (PageRenderer
),” in your browser console. This is a known issue at time of writing, and doesn’t seem to break the app. Thankfully, the issue disappears entirely in production builds. If you have an update on this issue, please let us know in the comments below.
🗒 Note » A warning reading “i18next: init: i18next is already initialized. You should call init just once!” might appear in the browser console when you have
debug: true
in thei18next.init()
options. This didn’t break anything in my testing and the warning disappeared entirely withdebug: false
.
That should do it for setting up and connecting i18next to our app. How about we start using it to localize our UI?
Basic translation
Say we have the following translations in our JSON.
{ "about": "About us", "articles": "Articles", // … }
{ "about": "نبذة", "articles": "مقالات", // … }
Our local plugin, along with the Gatsby i18n plugin, will have ensured that if we hit the /
route, the en/translation.json
values will be loaded. And if we hit /ar
the ar/translation.json
values will be loaded. In our components, we just need to use the useTranslation
hook to get the active locale’s translations.
import * as React from "react" import { useTranslation } from "react-i18next" import { LocalizedLink } from "gatsby-theme-i18n" const Header = ({ siteTitle }) => { const { t } = useTranslation() return ( <header> <div> <div> // … <nav> <ul> <li> <LocalizedLink to="/"> {t("articles")} </LocalizedLink> </li> <li> <LocalizedLink> {t("about")} </LocalizedLink> </li> </ul> </nav> </div> <LanguageSwitcher /> </div> </header> ) } export default Header
The t()
function retrieves the active locale translation by key.
Interpolation
To inject dynamic values, we use a {{placeholder}}
in our message and pass a map with a corresponding key as a second parameter to t()
.
// In our translation file { "user_greeting": "Hello {{username}}!" } // In our component <p>{t("user_greeting", {username: "Adam"})}</p> // Renders => <p>Hello, Adam!</p>
Plurals
We define plural forms in our translation files and use a special interpolated count
variable to choose the correct form.
{ // … "articles_published_one": "{{count}} article", "articles_published_other": "{{count}} articles" }
{ // … // Arabic has six plural forms "articles_published_zero": "لم تنشر مقالات بعد", "articles_published_one": "مقال {{count}}", "articles_published_two": "مقالان", "articles_published_few": "{{count}} مقالات", "articles_published_many": "{{count}} مقال", "articles_published_other": "{{count}} مقال" }
Note the special suffixes above (_zero
, _one
, etc.). These correspond to the locale’s plural form as returned by the JavaScript standard Intl.PluralRules.select() method, which i18next uses under the hood.
In our components, we call t()
using the key without any suffix and passing in a count
integer.
<p>{t("articles_published", {count: 2})}</p> // When active locale is English => <p>2 articles</p> // When active locale is Arabic => <p>مقالان</p>
🤿 Go deeper » There’s a lot to React i18next localization that we couldn’t cover here: date and number formatting, right-to-left layouts, localizing page titles, to name a few. Our Guide to React Localization with i18next should fill in many of these gaps for you. Additionally, The Ultimate Guide to JavaScript Localization is an expansive resource that covers a lot of i18n topics across an array of JavaScript libraries.
A language switcher
Remember the Gatsby i18n plugin we set up earlier? Well, it’s the source of truth for both configured supported locales and the active locale. Let’s use these values and control a <select>
element to provide our users with a language-switching UI.
import * as React from "react" import { navigate } from "gatsby" import { useLocalization } from "gatsby-theme-i18n" // … function LanguageSwitcher() { const { locale, defaultLang, config } = useLocalization() const switchLanguage = e => { // Avoid an unnecessary page load if the // user selected the already active locale if (e.target.value === locale) { return } // Go to the home page corresponding to the // selected locale if (e.target.value === defaultLang) { navigate("/") } else { navigate(`/${e.target.value}`) } } return ( <div> // … <select value={locale} onChange={switchLanguage} > {config.map(c => ( <option key={c.code} value={c.code}> {c.localName} ({c.name}) </option> ))} </select> </div> ) } export default LanguageSwitcher
If we tuck our <LanguageSwitcher>
into our <Header>
component, we get a saucy little switcher.
🔗 Resource » Get the code for the app we’ve built from GitHub.
Using the Phrase in-context editor
Gatsby can be a bit configuration-heavy, but its structured architecture is well-suited for large-scale sites with bigger teams working on them. A reliable partner for scaling your app localization is Phrase. With its myriad features for product managers, translators, developers, and designers, Phrase takes care of the heavy lifting of software localization at scale and keeps your team focused on your product.
Phrase comes with an in-context editor (ICE) that allows translators to simply browse your site and edit along the way. This avoids a lot of confusion regarding the context around a translation, improving translation quality. The ICE has built-in support for react-i18next, which means we can plug it into our Gatsby sites that are using react-i18next.
🤿 Go deeper » The Phrase ICE can be used with vanilla JS, Vue, React, Next.js, among others. Check out the documentation for all the details.
At this point, I assume you're a Phrase user (get a free trial if you aren't), you’ve created a project in Phrase and connected it with your app using the Phrase command-line client. If you’re following along with us here and want the .phrase.yml
we’re using, here’s the listing:
phrase: access_token: your_access_token project_id: your_project_id push: sources: - file: ./i18n/l10n/<locale_name>/translation.json params: file_format: i18next pull: targets: - file: ./i18n/l10n/<locale_name>/translation.json params: file_format: i18next
Installing the ICE
The Phrase in-context editor comes as an i18next post-processor and can be installed via NPM.
$ npm install i18next-phrase-in-context-editor-post-processor
With the package in place, we can revisit our custom i18next wrapper plugin to add the ICE to i18next.
// … import PhraseInContextEditorPostProcessor from "i18next-phrase-in-context-editor-post-processor" const wrapPageElement = ({ element, props }, options) => { // … i18next .use( new PhraseInContextEditorPostProcessor({ phraseEnabled: true, projectId: your_project_id, // Match the default i18next placeholder syntax: prefix: "{{", suffix: "}}", }) ) .init({ lng: currentLocale, resources: { [currentLocale]: { translation } }, fallbackLng: "en", initImmediate: false, debug: process.env.NODE_ENV === "development", interpolation: { escapeValue: false, }, // Register the plugin as a post-processor postProcess: ["phraseInContextEditor"], }) return <I18nextProvider i18n={i18next}>{element}</I18nextProvider> } export { wrapPageElement }
That’s it. If we restart our Gatsby development server, we should be greeted by a Phrase login modal.
We can now log in to Phrase and begin translating on-the-fly.
✋ Heads up » At the time of writing, there were some issues when using the Phrase ICE with Firefox; these weren’t showstoppers, and I could still translate and save, but I generally found the experience smoother on Chrome-based browsers and Safari.
Saving a translation in the ICE will automatically update it in our Phrase project. When our translators are happy with their translations and want us, developers, to commit them to the Gatsby project, we just do a pull with the Phrase CLI.
$ phrase pull
This will update our /en/translation.json
and /ar/translation.json
files, which makes our new translations ready to go to production.
🤿 Go deeper » Peruse the documentation for the Phrase In-Context Editor for all the configuration options and more.
🔗 Resource » Get all the code for the app we built above, along with the Phrase ICE integration, from GitHub.
Closing up
We hope you’ve enjoyed our little run through Gatsby i18n. Until next time, keep your hands on the keyboard and your coffee warm. Cheers 😊