Article summary
During a recent software development project, we used Payload CMS for its Admin UI and its local API for querying data in other parts of our Next.js app. For this project, multi-language support was a major priority both in the Payload Admin UI and throughout the rest of the application.
Luckily, Payload offers great support for i18n (translations for the app UI elements); the multi language support works well, and their docs around custom translations and working with TypeScript are super helpful. To expand on this great starting point, I’ll walk through a few decisions we made and the helper functions we added to make working with custom translations even easier.
Working with Custom Translations in Payload Collection Config + Custom Components
Custom translations inside of Payload collection configs and custom components worked well for us and didn’t require any major changes. We just added a few types + functions to get some extra type checking and consolidate some of the type coercion needed into a single spot.
Payload’s docs on working with i18n + TypeScript already call out how to define the CustomTranslationsObject
and CustomTranslationsKeys
types, and how to type the t
function for use in places like collection config labels.
We opted to add the following:
export type CustomTranslationFn = TFunction<
ClientTranslationKeys | Extract<CustomTranslationKeys, string>
>;
export const appT = (
t: TFunction,
k: ClientTranslationKeys | Extract<CustomTranslationKeys, string>,
opts?: Record<string, any>,
) => {
const customFn = t as CustomTranslationFn;
return customFn(k, opts);
};
This allows us to simplify typing label functions to just the following:
{
label: ({ t }) => appT(t, "customKey"),
}
Working with the Custom Translation Object Outside of Payload-Generated Pages
We decided to use the same custom translation object passed to the Payload config for translations of UI elements outside of Payload-generated pages. We made this decision because we wanted to use many of the same translations in both locations. It was also important to us to have all translations live in the same file to make providing translations easier for a translator down the road.
With that in mind, we wanted components to have access to a translation function that worked similarly to Payload’s t
function that is passed to collection label functions and to custom components. Here’s the set of functions we landed on to make that possible.
// Import the same custom translations + keys used by Payload
import { CustomTranslationKeys, customTranslations } from "@/translations";
type SupportedLocale = "en" | "es";
export type AppTFunc = (
k: CustomTranslationKeys,
subs?: Record<string, string>,
) => string;
export const getTFunc =
(locale: SupportedLocale): AppTFunc =>
(k: CustomTranslationKeys, subs?: Record<string, string>) =>
localeString(k, locale, subs);
export const localeString = (
k: CustomTranslationKeys,
locale: SupportedLocale,
subs?: Record<string, string>,
) => {
const dictionary = customTranslations[locale];
const paths = k.split(":");
return lookupStringRecursive(dictionary, paths, k, subs);
};
type FourDeep = Record<
string,
| string
| Record<string, string | Record<string, string | Record<string, string>>>
>;
const lookupStringRecursive = (
obj: FourDeep | string,
paths: string[],
fullPath: string,
subs?: Record<string, string>,
): string => {
if (typeof obj === "string") {
if (subs) {
return Object.entries(subs).reduce((acc, [key, value]) => {
return acc.replace(`{{${key}}}`, value);
}, obj);
}
return obj;
}
const [first, ...rest] = paths;
if (first in obj) {
return lookupStringRecursive(obj[first], rest, fullPath, subs);
}
throw new Error(
`Failed to look up localization for string '${first}' (of '${fullPath}')`,
);
};
The localeString
and its recursive helper handles retrieving a localized string, with nested keys separated by colons and substitutions for placeholders in the form of {{key}}
. This replicates the behavior of Payload’s t
function.
With all that in place, getting a translated string in a component is as simple as this, and doesn’t require us to recall any conventions different than what Payload uses:
const t = getTFunc(props.lang);
const translatedString = t("catalog:status", { status: "open" });
Resolving Language Preference Selection for Payload Admin UI vs. Non-Payload Generated Pages
Next.js’s default support for internationalization is centered around path-based routing. That is, routes are prefixed by the locale code. Next.js suggests adding middleware to set the locale in the path based on the provided request headers. This is different than in Payload’s Admin UI, where all admin UI pages start with /admin
, and the accept-language
request header is only used to determine the initial default language selection (the user can then change the preferred language as a profile setting saved to the DB). So, we wanted to avoid the Payload user account language preference conflicting with the locale set by Next.js middleware for path-based locale routing.
To handle this, we opted always to select the locale based on the request headers. For the non-Payload side of things, this didn’t require any additional configuration beyond what’s outlined in Next’s internationalization docs. And, for the Payload side, we made this possible by hiding the language selection preference from the Payload edit account page by hiding the element with custom CSS. This direction was the simplest and quickest to implement, but it comes with the drawback of not allowing user selection if the language specified by the headers is not supported in the app.
A better, but slightly more expensive implementation, would entail updating the Next.js middleware to check for the language preference collected and stored by Payload. And, if there is not a logged-in user (or if that user has not yet selected a language preference), then falling back to the language based on headers + other defaults.
Payload CMS Custom Translations Overall
Overall, we were really impressed with Payload’s support for custom translations. The interfaces for getting translated strings made a lot of sense and provided a lot of inspiration in how we wanted translations to work outside of the Payload Admin UI as well.