About three months ago, we decided to translate our company’s website sensoriumxr.com into several languages. The list of requirements included:
In a nutshell, sensoriumxr.com is a Nuxt.js application in SSR mode. We also use a content management system as a part of the back-end. It is a Strapi application - a headless CMS, which means it only stores content units, and doesn’t need to take into consideration how and where this content is displayed. Strapi stores for us blog posts, team members’ profiles, FAQ and roadmap entries, press releases and other useful content.
The decision was made to go with a 3rd party translation platform. Upon reviewing several web applications, we chose Lokalise.com. It can store translations as a list of key-language-value entries. You can take this for an illustration:
Eventually, this platform turned out to be a useful tool to handle translation tasks in sensoriumxr.com.
For example, it is possible to separate translators and content managers so the former doesn’t have to be granted any access to the website code or content management system at all. That makes this approach secure.
Certain texts can also be marked as ‘unverified’ so they won’t be available for publishing until ‘approval’.
It is possible to attach custom tags to keys to mark certain keys as belonging to a specific group.
And, more importantly, it has a rich API that allows us to perform all the tasks we need.
To begin with, let's think about the text content of the website that should be translated. If we look closer, we can notice that there are actually two types of texts.
The first one contains pieces of text scattered across markup files and therefore are stored in the website’s code repository. These are page and section headers, or simple descriptions that shouldn’t be edited by a content manager frequently. We call this type static.
The second type refers to everything related to dynamic CMS-stored content. For example: blog posts, team members’ names and positions, FAQ, i.e. everything that is editable via Strapi. We call this type of content dynamic.
The main problem to be solved afterwards is choosing a place to store translations. We can’t request the Lokalise API with each website page request. API docs for /keys endpoint (which fetches all the keys and their translations) clearly warn:
Do not engage a request to this endpoint with every your website/app visitor. Instead, fetch this endpoint from time to time, store the result locally and serve your visitors with static files/your database content.
Luckily, we already have a storage solution, our Strapi CMS, so we’ll use it as a caching layer between Lokalise.com and the website.
Let’s create a simple Strapi model called Locales with the following structure.
strapi/api/locales/models/locales.settings.json
{
"kind": "collectionType",
"collectionName": "locales",
"info": {
"name": "Locales"
},
"options": {
"increments": true,
"timestamps": true,
"draftAndPublish": true
},
"attributes": {
"Name": {
"type": "string"
},
"Lang": {
"type": "string"
},
"Data": {
"type": "json"
}
}
}
Name is the name of a corresponding CMS model (like “article”), or “static” for static content. Lang is language code (“en”, “it”, “ru”), and Data is a JSON object containing key-translation pairs. Example of a Locales item:
{
"id": 3,
"Name": "static",
"Lang": "es",
"Data": {
"headers::home": "Inicio",
"headers::product": "Producto"
}
}
You can think of such an item as a sort of dictionary for a specific content model.
We’ll talk about the reasons behind the “Data” keys naming later. Right now, we must set up a regular job to update this cache with the Lokalise API data. Here we can rely on a Strapi built-in cron module. First let’s write a job function in a standalone module, because we’ll need it later on, in another place.
strapi/lib/locale.js
const updateLocales = async () => {
const service = strapi.services["locales"];
const updateLocale = async (model, lang, data) => {
const localeParams = {
Name: model,
Lang: lang,
};
const locale = await service.findOne(localeParams);
if (locale) {
await service.update(localeParams, { Data: { ...locale.Data, ...data } });
} else {
await service.create({ ...localeParams, Data: data });
}
};
const lokaliseData = await fetchLocales();
for (const [model, modelData] of Object.entries(lokaliseData)) {
for (const [lang, data] of Object.entries(modelData)) {
await updateLocale(model, lang, data);
}
}
};
module.exports = {
updateLocales
};
We won’t cover the fetchLocales function here as it would go too far beyond the scope of this article. Let’s simply say that it requests Lokalise API /keys endpoint, then converts the response to a more appropriate format that fits into our Strapi Locale model.
{
"model1": {
"ru": {
"key1": "translation_ru1",
"key2": "translation_ru2"
},
"en": {
"key1": "translation_en1",
"key2": "translation_en2"
},
...
},
"model2" :{ ... }
}
Now we can use this both in the cron module ...
strapi/config/functions/cron.js
const { updateLocales } = require("../../lib/locale");
module.exports = {
// Every 10 minutes
"*/10 * * * *": () => {
updateLocales();
},
};
...and in the Strapi bootstrap function, that runs on an application startup.
strapi/config/functions/bootstrap.js
module.exports = async () => {
await updateLocales();
};
So we can find all the up-to-date translations in Strapi CMS. With this in mind, let’s look at how static content is handled.
To add translations for static texts we’ll be working mostly with the front-end part.
Most of the usual tasks here can be handled by the nuxt-i18n module. It generates routes for all the languages in use, handles the translation picking process depending on the current language, uses default language content for missing translations, etc. Its configuration is pretty straightforward.
Setting up nuxt-i18n
nuxt/nuxt.config.js
...
modules: [
...
"nuxt-i18n",
],
i18n: {
locales: [
{ code: "ja", iso: "ja-JP", name: "日本語" },
{ code: "ru", iso: "ru-RU", name: "Русский" },
...
{ code: "en", iso: "en-US", name: "English" }
];
defaultLocale: "en",
strategy: "prefix_except_default",
detectBrowserLanguage: false,
vueI18n: {
fallbackLocale: "en",
silentTranslationWarn: true
},
vuex: {
syncLocale: true
}
},
The only aspect to tweak here is how to provide a custom source of translations. By default nuxt-i18n expects all the translations to sit in separate JSON files or in your nuxt.config.js. As such, we should make it look up the CMS for translations when language changes. That can be easily done using a plugin system:
nuxt/nuxt.config.js
...
plugins: ["~/plugins/i18n.js"],
...
nuxt/plugins/i18n.js
export default async ({ app, store }) => {
const loadLocale = async (locale) => {
const { Data } = await app.$cms.getLocale(locale);
app.i18n.setLocaleMessage(locale, Data);
};
await loadLocale(app.i18n.defaultLocale);
if (app.i18n.defaultLocale != store.state.i18n.locale) {
await loadLocale(store.state.i18n.locale);
}
app.i18n.beforeLanguageSwitch = async (oldLocale, newLocale) => {
await loadLocale(newLocale);
};
};
This code runs on the server side of our SSR application. First, we load texts in the default language. It should always be loaded as Nuxt uses it in case the current language lacks some translation. Then we load texts in the currently selected language, which is saved in vuex storage.
Finally, we set a callback that runs on the client side every time language is switched.
In the loadLocale function above we make use of another custom Nuxt plugin $cms. It encapsulates all the work with Strapi API. Here, for example, getLocale(locale) simply makes a request to
/cms/locales?Name=static&Lang=${locale}
and returns the first entry of the resulting array (which should be the only entry with such parameters).
Replacing pieces of text
Now we have to rewrite our code to make it translatable. That means we will replace pieces of plain text with calls to nuxt-i18n built-in function - $t(key).
First, let’s create a JSON file static_en.json (the name doesn’t really matter) that holds all the static texts and should be uploaded to Lokalise. Then we populate it with texts to be translated. It would be convenient to group texts from one page or another conceptual block together in nested objects.
static_en.json
{
"pages": {
"home": "Home",
"product": "Product",
"partnerships": "Partnerships",
"senso": "SENSO",
"newsroom": "Newsroom",
"blog": "Blog"
},
"global": {
"readMore": "Read more",
"seeMore": "See more",
"faq": "FAQ"
},
"senso": {
"hero": {
"header": "Your virtual self, unlocked by SENSO",
},
}
}
When this file is uploaded to Lokalise, a set of keys is created with a name pattern as following:
{
"pages::home": "Home",
"global::readMore": "Read more",
"senso::hero::header": "Your virtual self, unlocked by SENSO"
}
It is this JSON object that eventually gets into the Strapi caching layer. So, when replacing former texts with keys we should use those flattened names, for example
pug template
h1 {{$t("senso::hero::header")}}
javascript
buttonText() {
const texts = {
default: this.$t("forms::subscription::subscribe"),
error: this.$t("forms::subscription::wrong"),
};
return texts[this.state];
}
Ok, so we replaced all texts with language-independent keys and prepared the caching job. The last thing to be handled is how that static_en.json gets to Lokalise. Let’s add some code to the already familiar Strapi bootstrap function.
strapi/lib/locale.js
const requestLokalize = (projectRelativeUrl, method = "get", data = {}) => {
const url = `https://api.lokalise.com/api2/projects/${process.env.LOKALISE_PROJECT_ID}/${projectRelativeUrl}`;
return axios({
method,
url,
data,
headers: {
"x-api-token": process.env.LOKALISE_API_TOKEN,
},
});
};
const uploadStaticLocales = async () => {
const buffer = fs.readFileSync("./migrations/locales/static_en.json");
const data = buffer.toString("base64");
const response = await requestLokalize(`files/upload`, "post", {
data,
filename: "static_%LANG_ISO%.json",
lang_iso: "en",
tags: ["static"],
replace_modified: false,
cleanup_mode: false,
convert_placeholders: false,
});
};
We must explicitly set replace_modified flag to false, as we don’t want to replace anything content managers might already have changed via Lokalise web interface. Please note also that we attach a “static” tag to these text pieces. That way we are able to separate them from CMS models‘ text pieces.
strapi/config/functions/bootstrap.js
module.exports = async () => {
await uploadStaticLocales();
await updateLocales();
};
The outline gets a bit more difficult in the case of CMS-controlled texts. Here is what we want to achieve:
The picture above also shows the preparation stage 0, when existing CMS content is uploaded to Lokalise.
There are two major tasks here. The first is extracting text pieces that should be translatable and uploading them to Lokalise. The second one is “patching” the original content with appropriate language translations at request time. To solve them we’ll first need a mechanism to mark certain Strapi model fields as “translatable” and then convert them to Lokalise keys and back.
The added difficulty here is that the Strapi content model system is quite flexible and a translatable field could belong not only to a model itself (you can think about it as a property of an object) but also to a component in a dynamic zone (think about a property of some objects inside an array). So, in turn, we also need flexible and powerful tools to describe such conversion rules.
If you’ve worked with Strapi CMS before, you'll have an easier time understanding this part. But don’t worry if you haven’t, I’ll try to keep things simple.
Let’s take a couple of models as an illustration.
The first one is a blog article. Among other things, it contains some meta fields, like title, publication date, or cover image. The article’s content itself is stored as a list of components inside a dynamic zone Content: some of these components might be plain text (article-text component), some a piece of HTML markup (article-html component), and others a picture or embedded video. For simplicity, let’s imagine that we decided to translate only the Title field, the Text field of the inner article-text component and the Html field of the inner article-html component.
Another example is a single-page model team. Strapi allows you to create some models as a so-called “single page” when there is no point in having multiple instances of such an entity. Team or FAQ sections are a good example of a “single page” model. Inside the team model there is a dynamic zone - a collection of team-member components, each one containing only a relation to a team-member model with fields like Name, Job, Photo. Suppose we want to translate only the Name and Job of each team member.
WE put all translatable fields in a configuration object that looks like this:
strapi/lib/locale.js
const I18nFields = {
article: {
fields: ["Title"],
zones: {
Content: {
"article.article-text": ["Text"],
"article.article-html": ["Html"],
},
},
},
"team-members": {
fields: ["Name", "Job"],
},
team: {
relations: {
zones: {
Members: {
"team.team-member": [{ TeamMember: "team-members" }],
},
},
},
},
};
And now we have a single place to refer in case we want to know what parts of content should go to Lokalise.
We must compose unique names for every piece of text that goes to Lokalise.
For simple fields belonging to a CMS object itself (Title for article) Lokalise key looks like the following:
${model}::${item.id}::${field}
Here model is model name, item.id is object’s id property value, field is translatable field name.
For example,
article::222::Title
For fields belonging to a component inside a dynamic zone (Text for article-text component) key pattern is
${model}::${item.id}::${zone}::${component.id}::${field}
Here zone is a zone name, component.id is the id of component within zone. For example,
article::222::Content::1::Text
When a content manager creates a new team entry or edits an article, Strapi should:
Let’s write a middleware that intercepts requests to the admin web interface and does these jobs. I’ll show you how to do it step by step.
First, we create a middleware and list it in Strapi config
/strapi/middlewares/i18n/index.js
const { checkItemNeedLokalize } = require("../../lib/locale.js");
module.exports = (strapi) => {
return {
initialize() {
strapi.app.use(async (ctx, next) => {
const editPathPattern = /^\/content-manager\/(single|collection)-types\/application::([^.]*)/;
const editPathMatch =
ctx.request.path && ctx.request.path.match(editPathPattern);
if (
editPathMatch &&
["post", "put"].includes(ctx.request.method.toLowerCase())) {
// it's a request to create or update content,
// call checkItemNeedLokalize when response is ready
await next();
const model = editPathMatch[2];
if (model && I18nFields[model]) {
await checkItemNeedLokalize(model, ctx.response.body);
}
} else {
// all other requests, skip
await next();
}
});
},
};
};
strapi/config/middleware.js
module.exports = {
settings: {
i18n: {
enabled: true,
},
},
};
content-manager/collection-types/application::team-members.team-members/18
(Important! Url patterns are valid for Strapi 3.6.2, later versions might change it),
Then we call a special function that receives a CMS API response, extracts translatable properties from it, composes key names and uploads to Lokalise.
strapi/lib/locale.js
const checkItemNeedLokalize = async (model, item) => {
const cmsLocale = await strapi.services.locales.findOne({ Name: model, Lang: "en" });
const keys = await composeItemKeys(model, item, cmsLocale);
await uploadKeysToLokalize(keys);
};
The trickiest part here is the composeItemKeys function. It extracts translatable keys from the item parameter (according to I18nFields config), compares their values with cached data and decides whether a field is new or an existing one. That defines the API request method and the way its body should be composed.
strapi/lib/locale.js
const visitItemI18nFields = (model, item, cb) => {
//check fields
for (const field of I18nFields[model].fields || []) {
cb(`${model}::${item.id}::${field}`, item, field);
}
//check dynamic zones
for (const [zone, zoneConfig] of Object.entries(I18nFields[model].zones || {})) {
if (item[zone]) {
for (const component of item[zone]) {
const componentConfig = zoneConfig[component.__component];
if (componentConfig) {
const fields = componentConfig.fields || componentConfig;
for (const field of fields) {
const key = `${model}::${item.id}::${zone}::${component.id}::${field}`;
cb(key, component, field);
}
}
}
}
}
};
// creates a single entry of Lokalise API /keys POST request
const createLokalizeKey = (keyName, translation) => ({
key_name: keyName,
platforms: ["web"],
tags: ["cms"],
translations: [
{
language_iso: "en",
translation,
},
],
});
const composeItemKeys = (model, item, cmsLocale) => {
const keysToCreate = [];
const keysToUpdate = [];
visitItemI18nFields(model, item, (key, fieldOwner, field) => {
if (fieldOwner[field]) {
if (cmsLocale && cmsLocale.Data && cmsLocale.Data[key]) {
//we already have such key, update it
if (cmsLocale.Data[key].value !== fieldOwner[field]) {
keysToUpdate.push({
key_id: cmsLocale.Data[key].keyId,
...createLokalizeKey(key, fieldOwner[field]),
});
}
} else {
//create a new key
keysToCreate.push(createLokalizeKey(keyName, fieldOwner[field]));
}
}
});
return {
create: keysToCreate,
update: keysToUpdate,
};
};
As a result, we have two arrays of keys: those with changed values and those that are currently missing in Lokalise. Notice, we attach a “cms” tag for all CMS-controlled texts, so they are easily distinguished from “static” texts.
Let’s omit the final uploadKeysToLokalize function as it simply sends a specific API request, uploading changes to Lokalise.
A similar job should also be done on the Strapi startup: check all CMS content and extract all the keys to translate. We omit the description of such a function here, but it should be pretty easy to implement now.
Finally, the last thing to do is to receive content in a specific language using Strapi API. Now we can be sure the up-to-date texts on all the languages are stored inside the caching layer - Strapi itself.
The plan here is to introduce a new query parameter for Strapi API - language. When the front-end requests content is in a non-default language, Strapi should first process this request normally and after the response is ready, substitute translatable parts with those in a requested language.
We already have most of the tools to solve this task.
The obvious place to catch all specific requests is middleware, we’ll enhance our i18n middleware a bit;
/strapi/middlewares/i18n/index.js
const { DefaultLocale, I18nFields, localizeModel, checkItemNeedLokalize } = require("../../lib/locale.js");
const LangParam = "_lang";
// returns model name based on request url and possible plural form
const getModelName = (request) => {
let model = request.path.match(/([^\/]+)/)[0];
//check plural form
if (model.endsWith("s") && I18nFields[model.substring(0, model.length - 1)]) {
model = model.substring(0, model.length - 1);
}
return model;
};
module.exports = (strapi) => {
return {
initialize() {
strapi.app.use(async (ctx, next) => {
...
if (...) {
...
} else if (ctx.request.query[LangParam] && ctx.request.method.toLowerCase() === "get") {
// it's a request for a model with '_lang' param,
// pick translation when response is ready
const lang = ctx.request.query[LangParam];
//remove '_lang' param so strapi won't report an unknown query
delete ctx.request.query[LangParam];
await next();
let model = getModelName(ctx.request);
if (lang !== "en" && I18nFields[model]) {
//we must change the response fields
await localizeModel(lang, model, ctx.response.body);
}
} else {
// all other requests, skip
await next();
}
});
},
};
};
And the localizeModel function that does all the hard work. It should check not only properties and dynamic zones of the response object itself but also its relations if there are any (remember the team and team-member relation). The resulting set of loops looks menacing but it is actually simple if you look at the I18nFields config object and follow its structure carefully.
strapi/lib/locale.js
const localizeModel = async (lang, model, data) => {
const localizeModelItem = async (item) => {
visitItemI18nFields(model, item, (key, fieldOwner, field) => {
if (cmsLocale.Data[key]) {
fieldOwner[field] = cmsLocale.Data[key].value;
}
});
//check relations with other models
if (I18nFields[model].relations) {
for (const [zone, zoneConfig] of Object.entries(I18nFields[model].relations.zones || {})) {
for (const component of item[zone]) {
if (zoneConfig[component.__component]) {
for (const field of zoneConfig[component.__component]) {
for (const [relation, relationModel] of Object.entries(field)) {
await localizeModel(lang, relationModel, component[relation]);
}
}
}
}
}
}
};
const cmsLocale = await strapi.services.locales.findOne({
Name: model,
Lang: lang
});
if(cmsLocale) {
if (Array.isArray(data)) {
for (const item of data) {
await localizeModelItem(item);
}
} else {
await localizeModelItem(data);
}
}
};
Now we should only attach the _lang query parameter when fetching data. In our front-end application, nuxt-i18n module is used, so we can always get the current language from vuex store as
this.store.state.i18n.locale.
The rest of the work is done by Strapi and Lokalise.
The approach followed proved to be the most time-efficient, cost-conscious and accurate as Lokalise is able to address common translation and localization trouble points.
Moreover, it empowers translators, proofreaders and content managers through a structured distribution of workflow and flags potential errors before they can ever be visible to users. By enabling a better optimization of our website across languages and cultural nuances, Lokalise was able to meet our localization needs while also positively impacting performance and improving business objectives.
As such, this guide can be a great blueprint with a step-by-step explanation for others who, like us, value rapid but accurate results when scaling their businesses to reach online global audiences.