Skip to main content

Multi-language Strategies with Next.js & Drupal

Original post: Multi-language Strategies with Next.js & Drupal

Author: Mike Hubbard
Contributors: Franco Shum, Jingsheng Wang

There are many strategies for implementing a multi-language website. This is how we approached multi-language in the PHSA Long Covid project.

Scenarios Addressed

  • Static content — Content coded in the Next.js UI

  • Internal links — Ensure internal links maintain the current language

  • Dynamic content — Content from the Drupal CMS

  • Images — Handling images with translations

  • Metadata — Getting translated metadata into pages

Static Content Translations

We store translations in JSON files in the public directory, and pull them in as needed based on the selected language.

Here's how it's done:

  1. First, we need to configure Next.js for translations. In our Next.js frontend, we're using the next-i18next library. I won't get into the nitty-gritty of setting it up here because the official documentation is what you would want to follow. In our implementation, we wanted users to select their language through a dropdown language selector. We modeled our solution off of this excellent blog post which builds on that initial setup.

    There is one important thing worth mentioning here regarding Drupal which is that the Drupal language code is slightly different from the i18n language code. Therefore, we must add fallbackLng configuration to map the language code correctly in some cases. For more information on language resolution, refer to the i18next documentation. The example is as follows in the next-i18next.config.js file:

module.exports = {
i18n: {
defaultLocale: "en",
localeDetection: false,
locales: ["en", "zh-hans", "tl"],
},
fallbackLng: {
"zh-hans": ["zh-Hans"],
ti: ["tl"],
},
};
  1. Once the app is configured, our next step is to add translation files in the way the next-i18next wants. Create a locales directory in the Next.js public/assets/ directory. Within that directory, create additional directories for each language, using the language code as the directory name. Store JSON translation files in these directories. The result looks like this:
public/assets/locales/en
- common.json
- footer.json
public/assets/locales/tl
- common.json
- footer.json
public/assets/locales/zh-Hans
- common.json
- footer.json
  1. In the JSON files, add an object with all of the translations in key/value pairs. Each file should contain the same object but with different values. Here's an example of the different files:
// public/assets/locales/en/common.json
{
"home": "Home",
"menu": "Menu",
"contact_us": "Contact Us",
"close": "Close"
}

// public/assets/locales/zh-Hans/common.json
{
"home": "主页",
"menu": "菜单",
"contact_us": "联系我们",
"close": "关闭"
}

Now that we have a common object with matching keys, we can pull in the translated values wherever needed using the useTranslation() hook provided by the next-i18next library. Just don't forget that all the translation data must come from the page level for it to be available at the component level.

For example, here we have a page and a component:

components/MyComponent.tsx
pages/home.tsx

At the page level, we need to pull the translation file using getStaticProps or getServerSideProps:

// pages/home.tsx
import type { GetServerSideProps, InferGetServerSidePropsType } from "next";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import MyComponent from "~/components/MyComponent";

const Home = () => {
return <MyComponent />;
};

export const getServerSideProps: GetServerSideProps<{
locale: userLanguageType;
}> = async (context) => {
const { locale } = context;
const props: { locale: userLanguageType } = {
locale: (locale as userLanguageType) || EN,
...(await serverSideTranslations(locale ?? EN, ["common", "footer"])),
};
return {
props,
};
};

In MyComponent, we can then use useTranslation() as follows:

// components/MyComponent.tsx
import { useTranslation } from "next-i18next";
import NextLink from "next/link";

const MyComponent = () => {
// Pull in a single translation file.
const { t } = useTranslation("common");

// Or pull in multiple translation files.
// const { t } = useTranslation(['common', 'footer']);

return (
<>
<NextLink href="/">{t("home", { ns: "common" })}</NextLink>
<NextLink href="/contact-us">
{t("contact_us", { ns: "common" })}
</NextLink>
</>
);
};

export default MyComponent;

The Next.js router's useRouter() hook provides everything we need to maintain the current language when linking to internal pages or calling the Drupal API. It includes a locale option to maintain the active locale. To use it, you simply need to update any hardcoded URL with the locale.

Here's an example that demonstrates various ways to maintain the current language in internal links:

import { useRouter } from "next/router";
import { useTranslation } from "next-i18next";
import NextLink from "next/link";

const MyComponent = () => {
const { t } = useTranslation("common");
const router = useRouter();
const { locale } = router;

return (
<>
{/* Using next/link with locale prop */}
<NextLink href="/" locale={locale}>
{t("home", { ns: "common" })}
</NextLink>
<NextLink href="/contact-us" locale={locale}>
{t("contact_us", { ns: "common" })}
</NextLink>

{/* If you're not using next/link, you need to add locale into the href path */}
<a href={`/${locale}/`}>{t("home", { ns: "common" })}</a>
<a href={`/${locale}/contact-us`}>{t("contact_us", { ns: "common" })}</a>

{/* Locale can also be used with next/router */}
<button
onClick={() => {
router.push("/contact-us", "/contact-us", { locale: locale });
}}
>
{t("contact_us", { ns: "common" })}
</button>
</>
);
};

export default MyComponent;

In this example, the contact link will render as "Contact Us" if English is the active language. The locale is automatically handled by Next.js when using next/link or next/router, ensuring that the current language is maintained when navigating between pages.

For more information on using the useRouter hook, you can refer to the Next.js documentation on useRouter.

Dynamic Content Translations

Last but not least is how we handled translations coming dynamically from the Drupal CMS. First, we needed to configure Drupal to use translations. Then, we needed to call the correct API to retrieve the translated content.

Configuring Drupal for Translations

Drupal has multi-lingual modules built into its core software, so getting translations up and running is quite simple. Here's what we did:

  1. First, enable the modules. We enabled the main Language module and the Content Translation module since we only needed to be able to have content translated.

  2. Next, go to /admin/config/regional/language and add the languages that your site requires.

  3. Next, switch to the Detection and Selection tab and choose how you want the Drupal interface and content languages detected. In our case, we were mostly concerned with calling translated content via the JSON API, so we enabled "URL - Language from the URL (Path prefix or domain)" in the Content Language Detection section and configured the path prefix for each language.

  4. Next, for content creators to be able to translate content, you must choose which fields are translatable. This is done on the Content Language and Translation page at /admin/config/regional/content-language. You simply check the boxes for all of the entities and their fields that should be translatable.

  5. Once that is done, any new or existing content will now have a Translate tab. Content creators can go to this tab to add translations for their content.

For more detailed information on choosing and installing multilingual modules in Drupal, you can refer to the Drupal Multilingual Guide.

Retrieving Translated Content from Drupal API

Include the language code in the API query:

export const getServerSideProps: GetServerSideProps = async (context) => {
const { locale, query } = context;
const baseUrl = process.env.NEXT_PUBLIC_DRUPAL_BACKEND_API;
const getIdAPIURL = `/router/translate-path?path=/resource/${query.resourceUrl as string}`;

// ... other code ...

try {
const entityData = await axios.get(BASE_URL + getIdAPIURL);
const entityId = entityData.data.entity.uuid;
const data = await axios.get(
`${baseUrl}/${locale}/jsonapi/node/page?filter[id][value]=${entityId}&include=field_body,your_other_fields&filter[langcode]=${locale}`,
);
// ... process data ...
} catch (e) {
// ... error handling ...
}

// ... return props ...
};

Images and Translations

Another thing worth touching on is how you can go about translating images. Perhaps a logo file changes for each language or an infographic in a blog needs to change. You can use the same strategies here as you would for text content.

Static Images

Use translation files for image paths:

// public/assets/locales/en/graphics.json
{
"logo": "/assets/static/logo/logo_en.svg",
"logo_alt": "Company name"
}

// public/assets/locales/zh-Hans/graphics.json
{
"logo": "/assets/static/logo/logo_zh-hans.svg",
"logo_alt": "Company name translated"
}

Usage in components:

<Image
src={t("logo", { ns: "graphics" })}
width={300}
height={100}
alt={t("logo_alt", { ns: "graphics" })}
/>

Dynamic Images

In Drupal, simply allow an image field to be translated. This will allow content creators to upload a separate file per translation which will automatically be pulled in through the API.

Metadata Translations

One last thing, each translated page should also include translated metadata — page titles, descriptions, etc. Here’s how you can make that happen.

Static Page Metadata

For static pages, you can again use the same approach as text and images. Easy-peasy.

Dynamic Metadata from Drupal

Dynamic content from Drupal requires a bit more setup. Fortunately, Drupal provides excellent metadata tools through the Metatags module. This module can be added to content entities to provide fields for various types of metadata. What makes this module particularly useful is its ability to transfer data from any field into the metadata fields, simplifying the process for content creators once it's set up. Here's how we implemented it in our project:

  1. Install the Metatags module. There are several sub-modules available, so choose the ones that fit your needs.

  2. Add the Meta Tags field to each content type that will generate a standalone page. We also added some additional fields to make adding metadata easier for content creators.

    We recommend using the Field Group module to group these metadata fields in an expandable accordion. Grouping similar fields improves the end-user experience.

  3. Configure the Metatags module. While the full process is beyond the scope of this guide, here's the key point: Start by configuring the "Global" settings, which will apply to all content entities, and use the SEO field tokens to populate the metadata. This means that when the SEO Title field is used, that field can populate all metatag fields that require a title. Enter it once and use it everywhere! As long as all of your content types have the same fields, the global settings will work for them all.

  4. Configure the translation. This step is similar to configuring other Drupal fields. Go to the Content Language and Translation page and check the fields that you want your content creators to be able to translate. In this case, check the 3 SEO fields and, since we're using the JSON API, also check Metatags (Hidden field for JSON support) to allow the metadata to come through the API call.

  5. That completes the Drupal setup! On the frontend, we can now receive the data and process it into actual metadata. We built a component that does this and puts it all into the site header via the next/head component. Here's what the component looks like:

import React from "react";
import Head from "next/head";
import { v4 as uuid } from "uuid";
import { MetadataProps } from "./Metadata.types";

const Metadata = ({ metadata }: MetadataProps) => {
if (!metadata || metadata.length === 0) {
return null;
}

const tags = metadata.map((item) => {
const metadataAttributes: { [key: string]: string } = {};
if (item?.attributes && Object.keys(item.attributes).length > 0) {
Object.entries(item.attributes).forEach(([key, value]) => {
metadataAttributes[key] = value ?? "";
});
}
if (item.attributes.name === "title") {
return <title key={uuid()}>{metadataAttributes.content}</title>;
}
return <item.tag key={uuid()} {...metadataAttributes} />;
});

return <Head>{tags}</Head>;
};

export default Metadata;

This setup allows for efficient management and translation of metadata in a Drupal-Next.js project, ensuring that your content is properly tagged for SEO and other purposes across all supported languages.

Gotchas

This adventure isn't without its challenges! Here are some of the gotchas that we've encountered so far:

  1. Font choice is crucial: We couldn't use the original site fonts because they didn't support all of the required characters for multi-language. We instead used a stack of system fonts:

    '-apple-system, BlinkMacSystemFont, avenir next, avenir, segoe ui, helvetica, Ubuntu, arial, sans-serif';
  2. PDF generation and character encoding: We have a feature where website visitors can download PDF versions of pages. Drupal handles generating the PDF via the Entity Print module, but we found that some language characters were not being rendered correctly. We needed to include different text encoding libraries that handle a wider array of languages.

  3. Translation file inclusion: When working on a component, you may see the key being rendered for a translation instead of the value, something like contact_us. The reason for this is likely that the translation file hasn't been included at the page level containing the component. Update the array there to include the file and you should be good to go.

  4. Language switching and caching: Switching languages sometimes requires a refresh if the translation JSON file isn't cached. We still need to look at how to get around that.

  5. URL path metadata setup: Setting up metadata for URL paths can be tricky with decoupled frontends. Since the frontend is decoupled, we can't use Drupal's node URL. We instead have to insert the frontend URL and pass the rest of the node path afterward. The Pathauto module is handy for getting your backend URLs to match what is expected in the frontend.

  6. Google Text to Speech limitations: We used Drupal's Google Text to Speech module and have had issues saving some content when there is a lot of text to translate. The API used is for generating audio clips that are 60 seconds or less and would crash the save process of an article when the generated audio clip is longer. The error message suggests using a different long audio API, but we haven't investigated what switching entails. For now, we've disabled that feature until we have time to investigate further.

  7. Content pasting issues: Some content creators were having issues pasting in translations from documents. Punjabi seemed to be the most problematic for whatever reason. Some people had spaces between words pasted in as &nbsp; which caused the rendered paragraphs to push beyond the boundaries of their container because it thought it was a long word without breaks. Others had some characters pasted in as entirely different characters. We still don't know exactly the issue, but an initial investigation is pointing toward the browser or operating system that is used.