Translated labels: translate your whole site in one place
A translated page tells only part of the story if everything around it stays in the original language. The header and footer links, the buttons, the search box, the footer headings, the sidebar headings, the forum's category names and the space's name are words you wrote once. You can give all of them in other languages from one place: a label dictionary per language, edited in the Translations panel or sent in one API call.
{
"es": {
"Docs": "Documentación",
"Get started": "Empezar",
"Search Numestri": "Buscar en Numestri",
"Product": "Producto",
"Getting started": "Primeros pasos"
},
"pt": { "Docs": "Documentação" }
}The left side is the exact text you wrote. The right side is what a reader in that language sees. One entry covers that text everywhere it appears: a header link, a dropdown entry and its description, a footer column heading or link, a button, the search placeholder, the tagline, a collection, a category, a space name and the breadcrumb that starts with it.
The translation is applied on the server. The Spanish words are in the page the reader downloads, with JavaScript off too. Nothing is swapped in the browser, so there is no flash of English, no layout shift and no hydration error.
The label dictionary
A site keeps its dictionary in its theme, at config.theme.i18n.labels. A space that is not part of a site keeps its own at theme.i18n.labels. A space inside a site reads the site's dictionary, and its own words on top of it, word by word.
You don't need to touch the theme to write it. These calls read and write the dictionary directly:
Call | What it does |
|---|---|
| Every translatable word of the site, per language, with its source |
| Replaces the whole dictionary: |
| Merges words in; |
| The same listing for one space |
| Replaces that space's own dictionary |
| Merges words into that space's own dictionary |
The strings listing answers one row per distinct text. For every language it says what a reader sees and where that comes from:
object: the item's own translation (see below), which wins over the dictionary;dictionary: the label dictionary answers it;missing: at least one place still shows the original words.
It also counts, per language, how many of the site's words no longer show the original. The languages are the ones your spaces declare under Languages, plus any the dictionary already holds.
How a word is matched
Exactly, on the trimmed text.
"Docs"matches a link labelledDocsorDocs. It does not matchdocsorDocs and guides.By language, then by base language. An entry under
ptalso servespt-BR. An entry underpt-BRserves onlypt-BR.Never your content. Page titles, post titles and anything a reader wrote are not labels. The dictionary does not touch them, even when the words match. Translate a page by publishing its language version.
The Translations panel
The panel is the dictionary as a table. There is one row per word you wrote and one column per language. Cells that still show the original are highlighted, and each language shows how many words it covers.
On a site: Sites, open the site, then Translations.
On a space that is not part of a site: the studio's Settings, then Translations.
Type in a cell and leave it to save. Clear it to remove the word. When a cell's placeholder shows a translation, that one comes from the item itself or from the site's dictionary, and is already what readers see.
Export JSON downloads the dictionary in the shape above. Import JSON reads that shape back, or a PUT body ({ "labels": … }), or one language written as { "es": { "labels": { … } } }. Choose whether the file replaces the dictionary or merges into it.
Replacing a label-swapping script
Some sites used customJs to swap their own labels after the page loaded. The script kept a dictionary such as {"es":{"labels":{"Docs":"Documentación",…}}}, walked the page and replaced each label it found. It worked, but every page paid for it. Readers saw English first, the header changed width while they read, and React reported a hydration error (#418) because the page no longer matched what the server sent.
Here is how to move to the label dictionary. The example is a site whose publisher builds chrome.es.json from its own docs repository.
Export your labels. Take the
labelsobject your script reads, one per language, for exampledocs-site/i18n/chrome.es.json.Send them as the dictionary. One call for every language:
curl -sS -A 'my-app/1.0' -X PUT "https://forumulate.com/api/orgs/$ORG/sites/$SITE/i18n/labels" \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "labels": { "es": { "Docs": "Documentación", "Search Numestri": "Buscar en Numestri", "Product": "Producto" } } }'A
PUTreplaces the whole dictionary, so a word you deleted from your file is deleted here too. Run it from the same build step that wrote the file. You can also use Import JSON in the Translations panel.Check the listing.
GET …/i18n/stringsshould show nomissingcell for the words your script used to swap. A word still missing is either spelled differently from what is on the page, or is not owner text (a page title, for example).Delete the script. Remove the swapping code from
customJs(PATCH …/themeor the site'sconfig.theme.customJs). Load a translated page with JavaScript off: the header, footer, search box and sidebar are already in Spanish.
The rest of such a script is also native now. Intros in other languages are the translations of the intro (see below). Every language has its own address (/docs/es/…), and an old ?locale=es link redirects to it. With i18n.switcher.remember, a later page offers the reader's chosen language (see the language switcher).
While a customJs still writes textContent, innerText or nodeValue, the custom code section of the console shows a note pointing to the Translations panel. The note does not block anything.
Precise overrides: each item's own translations
The dictionary is keyed by words, so one word has one translation across the site. When one place needs different words, give that item its own translation. It wins over the dictionary:
"translations": {
"es": { "name": "Guías", "description": "Todo lo que necesitas para empezar." },
"pt-BR": { "name": "Guias" }
}A missing language or field falls back to the dictionary, and then to the original.
Label | Write it with |
|---|---|
A collection's name and summary |
|
A forum category |
|
The space's name and description |
|
The intro above a listing |
|
A hub's intro (collection, series, category, lane) |
|
A lone space's header and footer |
|
A site's header and footer |
|
Intros are documents rather than labels, so the dictionary never translates them. Write each language's intro as its own document.
translations is replaced whole on a collection, a category, the space and the intro. Send null or {} to remove every language. A PUT …/home-doc that carries only translations keeps the original intro as it is. On a site config and through PATCH …/theme, the map is deep-merged like the rest of the config.
Each of these editors has its own Translations section: a collection's settings, a category's settings, the space name in the inspector, the intro, the theme's header and footer form, and a site's navigation editor.
Header and footer links in another language
On a translated page, every header and footer link that points at another space of the same site goes to that space in the page's language. On /docs/es/introduction, a footer link written as https://numestri.com/community renders as https://numestri.com/community/es, /legal/terms as /legal/es/terms, and /blog as /blog/es. You keep writing each link once, in the original.
A link counts as one of your spaces when it is relative (
/community) or absolute on one of the site's own addresses: a space's custom domain or folder, the site's address, or the space's address on forumulate.com (every address a space answers on is listed byGET …/spaces/{spaceId}/addresses). Links to anywhere else are left alone.Each space's language is spelled the way that space spells it: a path segment after its mount (
/blog/es), or?locale=esfor a space set toi18n.urlStyle: "query". A status page takes the interface language,?lang=es.A space that has not declared the language keeps the original link, so a link never leads to a page that does not exist.
The rewrite happens on the server. The right address is in the HTML; no script swaps it after the page loads.
For a link the site cannot localise on its own (an external page with its own Spanish edition, say), give the address per language in the chrome's translations, keyed by the link's original href. It is used as written and wins over the automatic rewrite:
"translations": {
"es": {
"labels": { "Pricing": "Precios" },
"href": { "https://numestri.com/pricing": "https://numestri.com/es/precios" }
}
}When a translation is shown
The page's address sets the language:
The page's language. A page served in Spanish (
/docs/es/…) shows Spanish labels around it.?lang=in the address.
Nothing about the reader is used: not the language they picked on another page, not their account's language, not the browser's Accept-Language. An English page is English for everyone, which is also what lets it be cached by its URL. A reader's earlier choice can only offer a link to the translation (i18n.switcher.remember). It never changes the strings on a page.
The public API returns the maps as stored, next to the original words, so your own client can apply them the same way. On the docs overview, ?locale=<code> (or the language in the address) also resolves each collection's name and description. The forum's category lists do the same: GET …/forum/categories?locale=es (public or signed-in) answers each category's name and description in Spanish from its own entry, then the dictionary. Without ?locale= you get the original words, which is what an editor should show.
Lists the reader's browser loads after the page has rendered use this too: the new-topic category dropdown, the # category autocomplete, a moderator's "move to" picker and a reader's category subscriptions.
In the API
Every operation below is also reachable from the other surfaces without a dedicated command. forumulate api <Operation> calls it from the CLI, the JavaScript SDK has a typed method for each one (the last column names it; the Python and Go clients are generated from the same spec), and an agent reaches the organization-scoped ones through the MCP call_api tool. Where a CLI command or MCP tool exists too, the last column names it. Operation names link to the API reference.
Site Labels
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List strings in an organization |
| |
| Replace labels in an organization |
| |
| Update labels in an organization |
|
Space Labels
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List strings in a space |
| |
| Replace labels in a space |
| |
| Update labels in a space |
|