Skip to main content

Use Ovok CMS with i18n

Keep your app's UI text in the Ovok CMS instead of in the build. Translators edit it in the Ovok Console, and your app loads it into i18next when it starts. Changing a label, or adding a language, no longer needs a release.

What you need​

You needDetail
The CMS enabledSee Enable the CMS.
Your tenant codeFind it.
Published translations documentsIn the same environment the app reads.
An SDK, optionally@ovok/core 0.4.9 or later for the web, or @ovok/native 1.6.51 or later for mobile. The plain API works with any framework.

The translations collection is open by default, so a signed-out screen such as sign-in can load its text with only the tenant code. No API key is needed.

1. Author the translations​

In the Console, create one translations document for each group of text, usually one per screen or feature.

FieldWhat to put
slugThe group name, such as sign-in. Keep it free of dots.
stringsOne row per text. The key is the name, and the value is the text.
valuePlain text, written once per language. A key with dots, such as errors.required, nests under the group.
statuspublished. A draft is not delivered.

Write the English text first, then add the other languages: de, fr and es. A string you have not translated falls back to English by itself, string by string, so a half-translated group still works.

A group of yours replaces Ovok's shared group of the same slug whole. If you reuse a shared group's name, such as sign-in, add every key you use, not only the ones you want to change. See shared content.

2. Load them into i18next​

Read the translations from the API, build a tree, and add it to i18next as a resource bundle.

curl --get 'https://api.sandbox.ovok.com/v1/public/cms/collections/translations/items' \
--data-urlencode 'tenant=big-health-company' \
--data-urlencode 'locale=de' \
--data-urlencode 'limit=50'
{
"docs": [
{
"slug": "sign-in",
"title": "Sign-in screen",
"status": "published",
"strings": [
{ "key": "title", "value": "Anmelden" },
{ "key": "errors.required", "value": "Pflichtfeld" }
]
}
],
"totalDocs": 1,
"hasNextPage": false
}
const CMS_LOCALES = ['de', 'en', 'fr', 'es'];

// The CMS has bare codes: de-DE reads as de, and anything else as en.
const toCmsLocale = (language: string) => {
const primary = language.split(/[-_]/)[0].toLowerCase();
return CMS_LOCALES.includes(primary) ? primary : 'en';
};

type Tree = { [key: string]: string | Tree };
const FORBIDDEN = new Set(['__proto__', 'constructor', 'prototype']);

// Puts a text at a dotted path. The first text wins, and a text is never turned into a group.
function setText(root: Tree, path: string[], value: string) {
let node = root;
for (const part of path.slice(0, -1)) {
if (FORBIDDEN.has(part)) return;
const next = (node[part] ??= {});
if (typeof next === 'string') return;
node = next;
}
const last = path[path.length - 1];
if (FORBIDDEN.has(last) || Object.hasOwn(node, last)) return;
node[last] = value;
}

async function loadCmsTranslations(i18n: any, tenant: string, language: string) {
const locale = toCmsLocale(language);
const tree: Tree = {};

for (let page = 1; page <= 20; page++) {
const url = new URL('https://api.sandbox.ovok.com/v1/public/cms/collections/translations/items');
url.search = new URLSearchParams({ tenant, locale, page: String(page), limit: '50' }).toString();
const body = await (await fetch(url)).json();

for (const group of body.docs) {
for (const { key, value } of group.strings ?? []) {
if (typeof value !== 'string' || !value) continue; // an empty text is not a translation
setText(tree, [group.slug, ...String(key).split('.')], value);
}
}
if (!body.hasNextPage) break;
}

// deep: merge into what is there. overwrite: false keeps the app's own strings.
i18n.addResourceBundle(locale, 'translation', tree, true, false);
}

Call it at start-up, and again when the user changes language.

Precedence​

When the same key exists in several places, the first of these wins:

PrioritySource
1Your app's own i18next strings
2The CMS
3The SDK's built-in English text

The native SDK ships English text for common, profile, register, reset-password, settings and sign-in. A CMS group with the same name overrides it, and your app's own strings override both. In the HTTP and web examples above, addResourceBundle(…, true, false) gives the same order.

Languages and fallback​

Your app asks forThe CMS answers
deGerman.
de-DE, through an SDKGerman: the SDK reads it as de.
de-DE, raw ?locale=de-DEEnglish. The CMS has no de-DE, and an unknown language answers in the default one.
it, or another language the CMS does not haveEnglish. The SDKs read it as en.
A key with no German textThe English text for that key.
A group your tenant has not writtenOvok's shared group of that name, if there is one.

So when you add a fifth language to your app, the CMS must be given it first: until then, the app shows English from the CMS and nothing else.

Staleness and offline​

  • Edits show up within about a minute, and longer behind a CDN. See caching.
  • Nothing is stored on the device. Offline, or when the CMS cannot be reached, the app shows its own strings and the SDK's English. The hook's error is set.
  • Removing a key does not remove its text on mobile. A text the app already merged stays until the app restarts.

Gotchas​

  • Publish the group. A draft is not delivered, and looks the same as a missing group.
  • Read the environment you wrote to. Content written in dev is not visible in prod.
  • Keep slugs free of dots. A group slug with a dot would nest under a different path.
  • A key that is also a prefix wins as a text. With both title and title.short, the SDK keeps title and skips the nested key.
  • Region tags need an SDK. Send de, not de-DE, when you call the API yourself.
  • Signed-out screens need the tenant code. Otherwise the web SDK refuses to send the request, and the API answers 400.

This is not the project localisation API​

Ovok also has a localisation API of its own, under /localization/ and /locales, that stores your strings in your project's data and needs a signed-in session. The SDK hooks on this page do not use it: they read the CMS. The two have separate text, separate language lists and separate fallback rules, so do not mix them in one app.