Headless CMS Localization with Phrase and Contentful

Looking into ways to integrate your translations with your Contentful data? Learn how to localize content at scale with ultimate flexibility using Phrase and Contentful.

If you’re new to headless content management systems (CMS), prepare yourself for a breath of fresh air. Instead of a monolithic system where the backend is tied to the frontend, a headless CMS decouples the two and provides an API to access its content.

This means that our websites, mobile apps, and smart toaster apps (I’ll bet you $10 they’re coming) can display content in the way we see fit for them. It also means that our websites can be largely static—their content can be pulled in from the CMS API on the server—which gives them a performance and security boost.

Headless is a great developer experience

Do some research on headless CMS options, and you’ll likely come across Contentful. A powerful, trusted, feature-rich CMS. Contentful is cloud-based, offering managed hosting, a fantastic web admin console, REST and GraphQL API access, and a ton of first-party SDKs.

And if you’re a Phrase user looking to integrate your translations with your Contentful data, we got you covered. In fact, in this article, we’ll walk you through exactly how to do that.

🗒 Note » You’ll need a Phrase Exclusive Plan to use the Contentful Phrase integration.

Our Demo App: RBrite

We’ll create a simple React app to walk through a working example of localized content with Phrase and Contentful.

Let’s imagine that we have a business that sells retro electronics: old computers, game consoles, etc. We buy, refurbish, and flip this retroware for a small profit. Our company, RBrite, will serve English- and French-speaking visitors. The RBrite web app will look like the following.

When it’s all said and done

🗒 Note » Thanks to Mark for providing the icon we’re using for our logo, Antique, for free on the Noun Project.

Let’s get to work building. We’ll start with a React app and internationalize it using the i18next library, and we’ll use Phrase for a localization platform.

🗒 Note » Knowledge of React will be helpful, but not strictly necessary. You can probably translate the concepts presented here to the stack/language/framework of your choice.

Libraries Used

Here are the versions of the all relevant NPM packages we’ll have used by the time we finish this app (versions in parentheses).

  • react (17.0) — UI library
  • i18next (19.8) — base i18n library
  • react-i18next (11.8) — i18n framework for React built on i18next
  • i18next-http-backend (1.0) — for loading translation files over the network
  • bulma (0.9) — CSS framework (not necessary, but here in case you’re coding along and you want to know what we’re using)
  • contentful (8.1) — the official SDK used to pull Contentful content into our app

We’ll also use version 2.0 of the Phrase command line interface (CLI) to sync our translation files between our app and our Phrase project.

Creating the React App

Let’s start by creating our React app with Create React App. From the command line:

npx create-react-app rbright

🗒 Note » You’ll need npm version 5.2+ to use npx. If you’re using an older version of npm check out these instructions.

And if you’re coding along and you want your app to look exactly like ours, install the Bulma CSS framework as well.

npm i bulma

🗒 Note » We connect Bulma to our app in index.js.

Next, let’s override the Create React App boilerplate with our own code.

import Retroware from "./components/Retroware";
import Footer from "./components/Footer";
import Navbar from "./components/Navbar";

function App() {
  return (
    <>
      <Navbar />

      <section className="section">
        <div className="container">
          <h2 className="title">Latest Retroware</h2>

          <Retroware />
        </div>
      </section>

      <Footer />
    </>
  );
}

export default App;

Our App is a top-level layout that houses three components: Navbar and Footer are presentational. The Retroware component is responsible for loading our products and presenting them in a grid. Let’s peek into Retroware.

🔗 Resource » Check out the code for Navbar and Footer on GitHub.

import { useState, useEffect } from "react";
import Card from "./Card";

const Retroware = () => {
  const [retroware, setRetroware] = useState([]);

  useEffect(() => {
    setRetroware([
      {
        id: 1,
        model: "ZX Spectrum",
        manufacturer: "Sinclair Research",
        description:
          "Excellent condition. New power supply. Original parts otherwise.",
        imageUrl:
          "https://upload.wikimedia.org/wikipedia/commons/3/33/ZXSpectrum48k.jpg",
        priceInCents: 13999,
      },
      {
        id: 2,
        model: "Commodore 64",
        manufacturer: "Commodore International",
        description:
          "Very good condition. All original parts.",
        imageUrl:
          "https://upload.wikimedia.org/wikipedia/commons/e/e9/Commodore-64-Computer-FL.jpg",
        priceInCents: 14499,
      },
    ]);
  }, []);

  return (
    <div className="columns is-multiline">
      {retroware.map((item) => (
        <div key={item.id} className="column is-one-third">
          <Card {...item} />
        </div>
      ))}
    </div>
  );
};

export default Retroware;

Our item data is currently hardcoded in our component. We’ll change that a bit later. For now, we “load” this data after the component renders for the first time and display it in a grid. Each item in the grid is presented using a Card component.

🔗 Resource » Check out the code for our Card component on GitHub.

With that in place, our app looks like the following.

We need more inventory

🔗 Resource » If you want to grab the code for the app as it is right now, and code along with us from this point on, check out the start branch from our repo on GitHub.

Localizing the UI With Phrase & i18next

At the moment, we’re hardcoding our UI strings. This is especially apparent in our layout components, like Navbar.

// ...

function Navbar() {
  return (
    <nav>
      /* ... */

      <div className="navbar-menu">
        <div className="navbar-start">
          <a className="navbar-item" href="/">
            Retroware
          </a>

          <a className="navbar-item" href="/cart">
            Cart
          </a>
        </div>

        /* ... */
      </div>
    </nav>
  );
}

export default Navbar;

If we’re going to present our website in both English and French, we should internationalize. We’ll start by setting up Phrase to work with our project.

🗒 Note » At this point I’m assuming you have a Phrase account (again, you’ll need the Exclusive Plan to connect with Contentful later).

Setting up Phrase

Once we’ve logged in to Phrase, we can click the + Create New Project button from our main account page.

After giving our project a name and clicking Save, we can click Set up languages and add en and fr as languages to our project. We can click Create languages to commit our language config, then click Skip setup → near the top of the page. That’s all the setup we need to do on the Phrase web console.

📖 Go deeper » All kinds of helpful articles for using Phrase can be found in our help center.

The Phrase CLI

Let’s head back to our local React project and configure the Phrase CLI so we can sync our translation files with our Phrase project.

🔗 Resource » If you don’t have the Phrase CLI installed, please follow the instructions in the help docs to quickly install the tool on your machine.

From the root of our project, let’s run the following from the command line.

phrase init

ASCII parrot asking for an access token

At this point, we’ll be asked for an access token. We can get a new token by logging into phrase.com, clicking our name near the top-right of the window, then clicking Access Tokens.

We can generate an access token on phrase.com

Token in hand, we can please the parrot and continue with our CLI setup. After pasting our token at the prompt in the command line, and hitting Enter, we’ll be presented with a list of our Phrase projects.

We can input the number of the project we created earlier and hit Enter.

Next, we’ll be presented with a list of translation file formats to choose from. We’re going to use i18next to internationalize, so we can select the number corresponding to that format (40 at the time of writing).

The last step here is to provide the paths to our source and target translation files. We’ll be placing our translations in the public directory, so let’s give the tool the following path for both our source and target: ./public/locales/<locale_name>/translation.json

When asked to upload our locales for the first time, let’s select no (n) since we don’t have any locales to upload yet. And that should do it for the CLI config.

We should now have a .phrase.yml file at the root of our project that holds our CLI config.

Setting up i18next

Now let’s get i18next set up so we can internationalize our app. First, we’ll install the necessary packages from NPM.

npm i i18next react-i18next i18next-http-backend

It’s often a good idea to put our i18n initialization code in its own module.

import i18next from "i18next";
import { initReactI18next } from "react-i18next";
import HttpApi from "i18next-http-backend";

i18next
  .use(initReactI18next)
  .use(HttpApi)
  .init({
    lng: "en",
    interpolation: {
      escapeValue: false,
    },
    debug: process.env.NODE_ENV === "development",
  });

export default i18next;

We can make sure this code runs when our app starts by including it in our index.js file. We’ll also need to add a React Suspense boundary since i18next uses Suspense for async loading by default.

import React from "react";
import ReactDOM from "react-dom";
import "../node_modules/bulma/css/bulma.css";
import "./index.css";
import "./services/i18n";
import App from "./App";

ReactDOM.render(
  <React.StrictMode>
    <React.Suspense fallback="Loading...">
      <App />
    </React.Suspense>
  </React.StrictMode>,
  document.getElementById("root"),
);

Adding Our Translation Files

That’s it for the i18next setup. Let’s add our translation files.

{
  "cart": "Cart",
  "retroware": "Retroware"
}
{
  "cart": "Panier",
  "retroware": "Retroware"
}

Internationalizing the UI

Remember that Navbar component with the hardcoded UI strings? We can now use i18next’s t() function within the component to show our UI translations dynamically based on the active locale.

// ...
import { useTranslation } from "react-i18next";

function Navbar() {
  const { t } = useTranslation();

  return (
    <nav>
      /* ... */

      <div className="navbar-menu">
        <div className="navbar-start">
          <a className="navbar-item" href="/">
            {t("retroware")}
          </a>

          <a className="navbar-item" href="/cart">
            {t("cart")}
          </a>
        </div>

        /* ... */
      </div>
    </nav>
  );
}

export default Navbar;

📖 Go deeper » If you’re interested in all the ins and outs of using i18next with React, give The Ultimate Guide to React Localization with i18next a look.

If we reload our app now, we’ll see nothing new. However, let’s pop over to i18n.js and change the initial locale to French.

import i18next from "i18next";
import { initReactI18next } from "react-i18next";
import HttpApi from "i18next-http-backend";

i18next
  .use(initReactI18next)
  .use(HttpApi)
  .init({
    lng: "fr",
    interpolation: {
      escapeValue: false,
    },
    debug: process.env.NODE_ENV === "development",
  });

export default i18next;

Now when our app reloads, we’ll see our French translations.

Side-by-side English and French translations

Of course, we want to give our users a way to change the active locale themselves. We can do this via a simple language switcher. Covering this in detail is a bit out of the scope of this article. Still, you can view the code of our app’s language switcher on GitHub.

Our saucy switcher

Syncing with Phrase

Our app’s UI is now localized. We can also use the full power of Phrase to sync our translations and pass them on to our translators. Suppose we wanted to localize our product page’s title and call-to-action buttons.

We can add the strings for those in our English translation files.

{
  "buy": "Buy",
  "cart": "Cart",
  "latest_retroware": "Latest Retroware",
  "retroware": "Retroware"
}

🗒 Note » Of course, we use the t() function to display these translations in the app. For brevity, we’re skipping that code here.

Now we can push these translations up to our Phrase project using the Phrase CLI. From the command line:

phrase push

Our new translation keys will now appear in our Phrase project.

When our translators have provided all our French translations on the Phrase web console, we can pull them into our project.

phrase pull

Now we have the latest French translations in our project. When we reload, we can see our UI is fully translated.

(OK, we cheated: our footer isn’t translated. But you get the idea).

Migrating Content to Contentful

While our UI is localized, our content/data is not. Doubly troubling is that the data is hardcoded. Left as it is, this would be an admin nightmare as we scale. Let’s migrate our data over to Contentful.

🗒 Note » At this point, if you don’t have a Contentful account, please create one.

Adding Our Content Model

After logging into the Contentful console, let’s head over to our space’s Content model. We’ll create a new model called Retroware with the following fields.

  • model (Short text)
  • manufacturer (Short text)
  • description (Long text)
  • image (Media)
  • priceInCents (Integer)

Adding Our Content

With our model in place, we can copy our two retroware items over to their new CMS home. We just need to click on the Content tab in the console and add the data.

We have to make sure to publish each item by clicking the Publish button in the Contentful editor so that we can see the item in the app when we pull it in.

🗒 Note » We can leave the description field empty for now. We’ll want to localize this field with Phrase later.

Loading Contentful Data in Our App

Our data is ready for us to consume in our app. We’ll need to swap out our hardcoded data and load in our Contentful data instead. First, let’s install the Contentful JavaScript SDK.

npm i contentful

Next, we’ll write a small service that creates the Contentful client for our app to use.

const contentful = require("contentful");

export const createCmsClient = () => {
  return contentful.createClient({
    space: my_space_id,
    accessToken: my_delivery_token,
  });
};

To generate an access token and get the Space ID, go to Settings > API Keys in the Contentful web console.

✋🏽Heads up » Production builds of our app will contain these tokens, so they will be exposed to our visitors on the web if they choose to peer into our front-end code. Of course, the delivery token is only for reading/consuming data, so there’s no risk of a malicious user writing to our CMS. However, in a real production app it might be a good idea to pull our Contentful data on the server, and serve static pages populated with this data via server-side rendering.

Now let’s create a React context to share this client across our app.

import React from "react";

export const CmsClientContext = React.createContext({});

And we’ll wire all that up in our App component.

import { useState } from "react";
import Retroware from "./components/Retroware";
import Footer from "./components/Footer";
import Navbar from "./components/Navbar";
import { createCmsClient } from "./services/cms";
import { CmsClientContext } from "./context/CmsClientContext";
import { useTranslation } from "react-i18next";

function App() {
  const { t } = useTranslation();

  const [cmsClient] = useState(createCmsClient());

  return (
    <CmsClientContext.Provider
      value={{ client: cmsClient }}
    >
      <Navbar />

      <section className="section">
        <div className="container">
          <h2 className="title">{t("latest_retroware")}</h2>

          <Retroware />
        </div>
      </section>

      <Footer />
    </CmsClientContext.Provider>
  );
}

export default App;

This context will come in handy in a bit when we want to switch between our delivery and preview APIs. For now, we can use this context to load in our Contentful data.

import { useState, useEffect, useContext } from "react";
import Card from "./Card";
import { CmsClientContext } from "../context/CmsClientContext";

const Retroware = () => {
  const { client } = useContext(CmsClientContext);
  const [retroware, setRetroware] = useState([]);

  useEffect(() => {
    client
      .getEntries({ content_type: "retroware" })
      .then((entries) => setRetroware(entries.items));
  }, [client]);

  return (
    <div className="columns is-multiline">
      {retroware.map((item) => (
        <div
          key={item.sys.id}
          className="column is-one-third"
        >
          <Card {...item.fields} />
        </div>
      ))}
    </div>
  );
};

export default Retroware;

Instead of hardcoded items, we’re now loading our content from the Contentful client. Again, we’re using a React context because later we’ll want to reload our items when we switch to the preview API.

🔗 Resource » The getEntries() method used above is, of course, part of the Contentful JavaScript SDK.

Our data is now being loaded from Contentful

Previewing Unpublished Content

It’s often a good idea to set up a way for our editors to see how content will look like before it goes live. Contentful makes this easy by providing a swap-in preview API that’s separate from the delivery API. The former reveals the latest unpublished edits in content, while the latter serves published data.

We can make use of the preview API by creating a swapping mechanism in our app. Let’s update our CMS service so that it takes an api param, which defaults to "delivery" and also accepts "preview".

const contentful = require("contentful");

const commonConfig = {
  space: my_space_id,
};

const apiConfig = {
  delivery: {
    accessToken: my_delivery_token,
  },
  preview: {
    accessToken: my_preview_token,

    // The preview API uses its own host
    host: "preview.contentful.com",
  },
};

export const createCmsClient = (api = "delivery") => {
  const config = Object.assign(
    {},
    commonConfig,
    apiConfig[api],
  );

  return contentful.createClient(config);
};

🗒 Note » Remember to copy your preview API token from the Contentful admin and to paste it instead of my_preview_token. You’ll need to do the same for your space ID and delivery token, of course.

We can use this parameter in our App component to expose a setApi method through our context, which will cause components consuming our context to refresh their client (and its data) when the API is switched.

// ...

function App() {
  // ...

  const [cmsApi, setCmsApi] = useState("delivery");
  const [cmsClient, setCmsClient] = useState(
    createCmsClient("delivery"),
  );

  const setApi = (newApi) => {
    if (newApi === cmsApi) {
      return;
    }

    setCmsApi(newApi);
    setCmsClient(createCmsClient(newApi));
  };

  return (
    <CmsClientContext.Provider
      value={{ client: cmsClient, api: cmsApi, setApi }}
    >
      <Navbar />

      <section className="section">
        <div className="container">
          <h2 className="title">{t("latest_retroware")}</h2>

          <Retroware />
        </div>
      </section>

      <Footer />
    </CmsClientContext.Provider>
  );
}

export default App;

With that in place, we can add a preview button toggle to preview unpublished content.

import { useContext } from "react";
import { CmsClientContext } from "../context/CmsClientContext";

const PreviewToggle = () =>
  // Show the button only in non-production builds
  process.env.NODE_ENV === "production" ? null : (
    <ToggleButton />
  );

const ToggleButton = () => {
  const { api, setApi } = useContext(CmsClientContext);

  const toggleClientEnv = () =>
    setApi(api === "delivery" ? "preview" : "delivery");

  return (
    <div className="field">
      <p className="control">
        <button
          className={`button ${
            api === "preview" ? "is-warning" : "is-white"
          }`}
          onClick={toggleClientEnv}
        >
          Preview {api === "preview" ? "On" : "Off"}
        </button>
      </p>
    </div>
  );
};

export default PreviewToggle;

Let’s tuck this button in our Navbar.

/* ... */
import PreviewToggle from "./PreviewToggle";

function Navbar({ setClientEnv }) {
  const { t } = useTranslation();

  return (
    <nav>
      /* ... */

      <div className="navbar-menu">
        <div className="navbar-start">
          /* ... */
        </div>

        <div className="navbar-end">
          <div className="navbar-item">
            <PreviewToggle />
          </div>

          <div className="navbar-item">
            <LanguageSwitcher />
          </div>
        </div>
      </div>
    </nav>
  );
}

export default Navbar;

Now we can switch back and forth between published and unpublished content using our new toggle.

If we update any of our content items on the Contentful console, and not publish the update, we’ll see this update with Preview On. With Preview Off we’ll see what our site visitors will normally see: our published content.

✋🏽Heads up » While we’re hiding the preview toggle in non-production builds, our preview token and code will still be available in production. If someone were to poke around in our frontend code they could see our preview content. It’s probably a good idea to serve content from a server. This way, we can add more control on the server before exposing our content, e.g. we can limit preview content to admin users only. Read more about preview configuration in the Contentful docs.

Localizing Contentful Content with Phrase

Our app is pretty well localized at this point, with a glaring exception: the content. As Phrase users, we likely want to keep our localization on Phrase and to include localized strings in our Contentful content. That way, our translators stay happy on the platform they know, and we can structure our content on Contentful. Let’s see how to do that.

Adding Our Translations to Phrase

We first need to make sure that our translated strings are available in our Phrase project. This is easily done on the Languages page of our Phrase console. Let’s head over there, click on en (our project’s default locale), and add our keys.

Click the + Add Key button to add keys

Our retroware product and manufacturer names will probably stay the same between locales. However, we’ll want to translate the item descriptions. For each description, we can click the + Add Key button to add a new translation key and add our English translation for it. We’ll translate these keys to French a bit later.

Adding the Phrase App to Contentful

The next step is connecting our newly added keys to our Contentful content. For that, we’ll need to add the Phrase app to our Content model. Let’s login to Contentful, click the Apps tab, then click Manage apps. We’ll be presented with a list of add-on apps.

That’s a familiar parrot

We can click on the Phrase app, then Install → Authorize access → Install to add the app.

Configuring the Sidebar

With the app installed, let’s add the Phrase app to our Content model. We’ll navigate to Content model → Retroware → Sidebar.

We need to add the Phrase add to our sidebar

From there, we can simply click the + button on the phrase box under Available items to add the app. And we’ll click Save to commit our change.

Using the Phrase App for Field Appearance

In order to pull our translations into our description field, we’ll have to configure it to use the Phrase app. We can head over to Content model → Retroware → description → settings → Appearance. From there we can select phrase and click Save.

We’ll need to click Save again on the Retroware page to commit our changes.

Logging into Phrase

Now we just need to connect the Phrase app to our Phrase account. Let’s head over to Content, open one of our items, and click the Login button.

We just need to enter our credentials and click Connect with Phrase.

Connecting to Our Phrase Project

At this point, we should see the sidebar present us with the choice of Phrase project and language to use for our content.

Here we can select the Phrase project we created earlier, en for the language, and click Save.

Adding Our Phrase Keys to Contentful Content

With our Phrase project connected to our Contentful model, we can start adding our description keys from Phrase to our description field. If we focus on the field and type /key, we’ll get an autocomplete dropdown where we can select the key we want to inject. We can keep typing to narrow down our search.

Once we select a key and hit Enter, the key will be injected into our field.

We can repeat this process to add as many keys as we want, and we can mix and match “hardcoded” text with our keys as well.

🔗 Resource » More information regarding the Phrase Contentful integration can be found in our help guide.

Previewing Our Localized Content

Of course, we may not want to publish our Contentful content with Phrase keys in it before we make sure that those pieces are working well together. That’s where the preview toggle that we built earlier will come in handy.

Let’s head back to our development environment and make sure we have the latest translations from Phrase.

phrase pull

Let’s then run our app and toggle preview mode to see our newly added keys.

We can see the Phrase keys we added in Contentful, but by itself that doesn’t help us much. We need to swap in our translated strings for these keys. That’s not too difficult. We’ve already downloaded the English translations from Phrase with our last phrase pull.

{
  "buy": "Buy",
  "cart": "Cart",
  "commodore_64_description": "Very good condition. All original parts.",
  "free_shipping": "Free shipping.",
  "game_gear_description": "Excellent condition. All original parts.",
  "latest_retroware": "Latest Retroware",
  "retroware": "Retroware",
  "zx_spectrum_description": "Excellent condition. New power supply. Original parts otherwise."
}

Now we just need to replace keys like {zx_spectrum_description} with their actual translations. Let’s write a function that does this.

import i18next from "i18next";

i18next
  /* ... */
  .init({
    /* ... */
  });

 export const tInner = (str) =>
  str.replace(/{([^}]+)}/g, (_, key) => i18next.t(key));

export default i18next;

We can now use tInner() to display our translated description text.

import { tInner } from "../services/i18n";

/* ... */

const Description = ({ text }) => (
  <p>
    {tInner(text)}
  </p>
);

/* ... */

With that, we can preview our Phrase translations in our Contentful content before we publish.

Of course, tInner() will work with preview mode off as well, ie. in production, which is what we want. We’ve effectively wired both UI and Contentful strings through i18next for translation. And since i18next is using our pulled Phrase strings, we can keep our localization on Phrase and our content on Contentful. Sweet!

📖 Go deeper » If you’re not using a library like i18next, you could still preview your Phrase-localized Contentful content by using the Phrase In-Context Editor (ICE). In fact, the ICE integrates very well with i18next as well. We’ve added it to the demo app on our GitHub, so feel free to enable it and experiment to find the workflow that suits you best.

And that’s our app 🙂

🔗 Resource » Get the complete code for our demo app from GitHub.

Closing

That about wraps up our guide to integrating Phrase into your Contentful content. We hope this guide helps you use Phrase and Contentful to realize the power and flexibility of a localization platform integrated with a headless CMS. If you have any questions, please let us know in the comments below (or contact us directly). We’d love to hear from you!

Take your i18n game to the next level with Phrase. Phrase is a professional i18n/l10n solution built by developers for developers. Featuring a robust CLI and API, GitHub, Bitbucket, and GitLab sync, machine learning translations, and a great web console for your translators, Phrase will do the heavy lifting in your i18n/l10n process to keep you focused on the creative code you love. Check out all of Phrase’s features, and sign up for a free 14-day trial.

Happy coding, friends 🧑🏽‍💻

5 (100%) 70 votes
Comments
close

The Biggest Mistakes to Watch Out For in Localization

Download our FREE INFOGRAPHIC for a strong overview of the crucial mistakes you need to avoid to ensure your localization process has the best outcome possible.