Skip to Content

Contentful Localization Strategy

Dale Shlass / May 11, 2021

10 min read

Introduction

We recently launched a new platform for a client which utilizes Contentful as a Headless CMS and Next.js with Serverside Rendering.

One of the challenges that we faced while building the project was to allow for localized content. When we originally planned for localization, the idea of having content that was translated 1:1 seemed like an easy task to complete using Contentful's amazing built-in field level localization.

However, as the project progressed some of the requirements had been updated. We were now tasked to enable Country specific content while also allowing for 1:1 translations within that specific Country.

For example: Canada has two national languages (English and French) while Belgium has three national languages (Dutch, German and French). Even though French is a common language between the two Countries, there are different content needs on their websites as required by the client. On Canada's website, a Hero Banner and a Card would be rendered and the fields would need to be localized for English and French while Belgium's website a Hero Banner and two Cards would need to be rendered with the content localized for Dutch, German and French. These two Countries share a similar language (French). However, due to the difference in the Content they cannot share the same French translations.

As a result, we switched our localization approach from a language based approach (en, fr, nl, de) to a locale strategy which targets the languages of a Country based on the ISO codes (en-CA, fr-CA, nl-BE, de-BE, fr-BE).

In this article I give an overview of the steps we took to achieve the updated project requirements starting with an over simplified Contentful Model breakdown.

Content model for a Locales

Page

Content model for a Page
  • Slug field has appearance Slug selected in the settings which auto-generates a path.
  • Content field is a one to many reference which accepts only Hero Banner and Card entries.

Hero Banner

Content model for the Hero Banner
  • Title and Description are both localized fields and both Required.

Card

Content model for the Card
  • Title is a localized field and is Required.
  • Call to Action field is a one to one reference which accepts only a Link entry type.
Content model for a Link
  • Label and URL are both localized fields and Required.

How the Model Works

When a user lands on the Index page (Slug: /) our Next.js setup requests the corresponding Page Model from Contentful using the locale that is in the request object (through Next.js Serverside Context). The Contentful Page data is parsed and the Content field is passed down to the Component Mapper (below) which will render out each component.

// next.config.js
module.exports = {
  i18n: {
    locales: ['en-CA', 'fr-CA', 'nl-BE', 'fr-BE', 'de-BE'],
    defaultLocale: 'en-CA'
  }
};
//utils/componentMapper.tsx
import { ExoticComponent, Fragment, ReactElement, ReactNode } from 'react';
import { HeroBanner } from '../../components/HeroBanner';
import { Card } from '../../components/Card';

interface IMapping {
  [name: string]: ({ content }: Page) => ReactElement;
}

// Content Type ID : Custom Component
const mapping = {
  heroBanner: HeroBanner,
  card: Card
};

export const componentMapper = (
  contentType: string,
  componentMapping: IMapping = mapping
):
  | (({ content }: Page) => ReactElement) /* Function Component type */
  | ExoticComponent<{ children?: ReactNode }> /* Fragment type */ =>
  componentMapping[contentType] || Fragment;

Paired with:

//[...slug].tsx
export default function Page({
  page
}: InferGetServerSidePropsType<typeof getServerSideProps>): JSX.Element {

    return (
    <div>
      {page.content.fields.map(({ fields, sys }, index) => {
        const Template = componentMapper(sys.contentType.sys.id);
        return (
          <section key={`${sys.id}-${index}`}>
            <Template {...fields} />
          </section>
        );
      })}
    </Layout>
  );
}

export async function getServerSideProps(
  context: GetServerSidePropsContext
): Promise<{ props: IServerSidePage }> {
  //code that fetches contentful here
}

While content can be translated into any of the desired languages, there was still the issue of separating content on a Country to Country basis. There are a few ways we explored solving this problem each offering their own advantages and disadvantages.

Solution 1: Page Content Localization

Our first solution involved enabling localization of the Content field of the Page model. While this seemed like a promising solution, we quickly realized that it was possible a Page could need to exist for Canada but not necessarily Belgium. In addition, an Author would have to create separate content for Canada and Belgium and link each up to their specific Locale eithin the Content Field on a Page. As an Author, this seemed like a lot of extra work and still didn't solve the problem of being able to separate URLs per Country.

Content model for a Solution 1

Solution 2: Page Wrapper Localization

The next solution we explored was creating a Locale Wrapper to wrap the Page Model. This would allow us to create Pages for each country individually and then hook them up to a Wrapper.

Content model for a Locale Wrapper
  • Pages field is a one to one reference which accepts only Page and is localized.

Once a page was created it would be linked into the designated Locale within the Pages field. This allowed us to achieve our Country specific content while using different URLs for each page.

Locale Wrapper filled out

However, there were a few issues here.

  1. The same issue as Solution 1 was happening - the Authors would still have to link things up to multiple references once created.
  2. As the amount of pages grew the object we would request from Contentful would get bigger and bigger creating performance issues.

When a user requests the Locale Wrapper model by Locale (en-CA) they are returned every Page that exists within the Pages field. That means that if the Canada website has 50 pages well that's what you are going to receive on every request. Remember that this will be used as the main Entry we will be fetching to ensure we only get pages that match our Locale.

As you can imagine, request times were slowly going up and up. Contentful allows you to filter requests by a field in the Entry you are requesting (fields.slug=/), but it does not let you filter anything within a reference which is past the first level of the object (fields.content.fields.slug=/). Therefore all the Pages for a Country were requested and we would filter the object by the slug of the current slug.

This lead us to think of our final solution, which we are currently using hapily in production today!

Solution 3: The Hybrid Approach

For this solution, we scrapped the Locale Wrapper model and we ended up adding a new field to the Page model called Country.

New content model for Page with Country dropdown
  • Country field is required, displayed as a Dropdown and has the validation of Specific Values: CA and BE

When an Author created a new Page, they would have to select which Country this Page belonged to by selecting from the Country dropdown. This was a simple solution to our problem. This allowed us to go back to requesting a single Page from Contentful and we would now just filter by the Country as well as the Page.

In addition to this, we had Authors only fill out the languages associated with the Country they were creating the content for. For example: If an Author was creating a Hero Banner for Belgium, they would fill out the content for Dutch (Belgium), French (Belgium), German (Belgium) and leave English (Canada) amd French (Canada) blank. Though there does exist a problem with this - and it has been a big pain point for us as we work through this exercise. If a field is Requied (for example: the Description of the Hero Banner) then a user is forced to fill out the Default Language translation (English (Canada) in our case).

There are two ways we discovered to get around this, neither of which are elegant.

  1. The Authors for Belgium must fill out dummy content in the English (Canada) field.
  2. Change the field from Rich Text to a Reference (that is Required), Create a new Content Model for the Rich Text field (with fields Entry Title as Text and Rich Text as Rich Text which is Localized and NOT required) and then reference your new Content Model from inside the Reference.

On one of our Projects we went with Option 2 but it seems that most Authors like Option 1 better. When you change the field to a Reference, you get to enforce the Required field, while keeping the same translation structure of sticking to your own Countries languages.

Once it was all set up, we added a small function that gets the Country Code from the Locale (en-CA = CA) and we pass that to Contentful along with the slug and locale to fetch the correct content the User is requesting.

export const getCountryCode = (localeString: string): string => {
  return localeString.split('-')[1];
};

There is one other problem that we ran into during this whole exercise. When we set up the Page's Slug field to Display as Slug, it wasn't apparent to us until Soltion 2 that by doing this Contentful actually enforces Unique field even if it wasn't checked in the Validation. We opted to just display as a Single Line and keep track of URLs through a spreadsheet that were created per Country.

There are many ways to achieve this type of localization strategy but this was the way that we were successful in achieving our desired results. I'd love to hear about any ways that you have achieved something similar and the steps you took to do so.

I hope you enjoyed my rambling and maybe learned a thing or two about Contentful and Next.js.

Final Thoughts

There are still some unknowns regarding how the localization will look and feel like for more Countries and Languages. For just Canada and Belgium you can see that there are already 5 fields for Authors to sift through to find the corrent Language.

Some pain points still exist and it would be awesome for Contentful for look into the following:

  • Allowing for “Required” fields to have one translation required rather than just the Default Locale.
  • Appearance Slug without Unique Fields Validation
  • Case studies or patterns of localization that they know have been successful in Country by Country content with 1:1 language translations.

Feel free to reach out to me with any questions or feedback.

~Dale 🚀

Resources