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 need | Detail |
|---|---|
| The CMS enabled | See Enable the CMS. |
| Your tenant code | Find it. |
Published translations documents | In 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.
| Field | What to put |
|---|---|
slug | The group name, such as sign-in. Keep it free of dots. |
strings | One row per text. The key is the name, and the value is the text. |
value | Plain text, written once per language. A key with dots, such as errors.required, nests under the group. |
status | published. 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
- HTTP, any framework
- Web SDK
- Native SDK
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.
useTranslations loads the groups for the current language and returns the tree. Add it to i18next as a bundle.
import i18n from 'i18next';
import * as React from 'react';
import { initReactI18next, useTranslation } from 'react-i18next';
import { toCmsLocale, useTranslations } from '@ovok/core';
i18n.use(initReactI18next).init({
lng: 'de-DE',
fallbackLng: 'en',
resources: { en: { translation: { app: { name: 'Acme' } } } },
react: { bindI18nStore: 'added' }, // re-render when a bundle is added
});
export function CmsTranslations() {
const { i18n } = useTranslation();
const { data } = useTranslations({ tenantCode: 'big-health-company', locale: i18n.language });
React.useEffect(() => {
if (data) i18n.addResourceBundle(toCmsLocale(i18n.language), 'translation', data, true, false);
}, [data, i18n]);
return null;
}
Render <CmsTranslations /> inside your OvokProvider.
| Option | Description |
|---|---|
tenantCode | Pass it when nobody is signed in, or the SDK refuses to send the request. |
locale | An app language such as de-DE, or a CMS language. The SDK reads it as de. Defaults to the browser's language. |
environment | dev, staging or prod. Leave it out for the host's default. |
allowSignedOut | Allow the read without a signed-in user. On by default for translations. |
useTranslations returns { data, loading, error, reload }. A change to another CMS language refetches; a change from de-DE to de-CH does not, because both read de.
useCmsTranslations loads the groups and merges them into the i18next instance your app already uses. Call it once, near the root.
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import { useCmsTranslations } from '@ovok/native';
i18n.use(initReactI18next).init({
resources: { en: { translation: { app: { name: 'Acme' } } } },
fallbackLng: 'en',
compatibilityJSON: 'v4',
});
export function CmsTranslations() {
useCmsTranslations({ tenantCode: 'big-health-company' });
return null;
}
- It merges for you. The CMS text goes into the
translationnamespace under the CMS language, such asde. i18next resolvesde-DEthroughde. - Pass
tenantCodewhen nobody is signed in, for example on the sign-in screen. - Use the same i18next instance as your app. The SDK imports
i18nextitself, so there must be one. - A language change refetches, and re-renders the screens without switching the language again.
Precedence
When the same key exists in several places, the first of these wins:
| Priority | Source |
|---|---|
| 1 | Your app's own i18next strings |
| 2 | The CMS |
| 3 | The 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 for | The CMS answers |
|---|---|
de | German. |
de-DE, through an SDK | German: the SDK reads it as de. |
de-DE, raw ?locale=de-DE | English. The CMS has no de-DE, and an unknown language answers in the default one. |
it, or another language the CMS does not have | English. The SDKs read it as en. |
| A key with no German text | The English text for that key. |
| A group your tenant has not written | Ovok'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
erroris 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
devis not visible inprod. - 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
titleandtitle.short, the SDK keepstitleand skips the nested key. - Region tags need an SDK. Send
de, notde-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.