Translated labels: translate your whole site in one place

Embed
Preview of the card
<a href="https://forumulate.com/guides/translated-labels"><img src="https://forumulate.com/guides/_api/public/orgs/046a9bc0-d530-43ae-9bae-af7945008cda/spaces/36aa4df0-1e95-4cef-8ae9-795169441345/pages/7de464be-e749-462b-ac03-47aea54347e5/social-card.svg?v=3e7ae33eec8b" alt="Translated labels: translate your whole site in one place" width="600" height="315"></a>

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

GET /orgs/{orgId}/sites/{siteId}/i18n/strings

Every translatable word of the site, per language, with its source

PUT /orgs/{orgId}/sites/{siteId}/i18n/labels

Replaces the whole dictionary: { "labels": { "es": { … } } }

PATCH /orgs/{orgId}/sites/{siteId}/i18n/labels

Merges words in; null removes a word, { "es": null } a language

GET /orgs/{orgId}/spaces/{spaceId}/i18n/strings

The same listing for one space

PUT /orgs/{orgId}/spaces/{spaceId}/i18n/labels

Replaces that space's own dictionary

PATCH /orgs/{orgId}/spaces/{spaceId}/i18n/labels

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 labelled Docs or Docs. It does not match docs or Docs and guides.

  • By language, then by base language. An entry under pt also serves pt-BR. An entry under pt-BR serves only pt-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.

  1. Export your labels. Take the labels object your script reads, one per language, for example docs-site/i18n/chrome.es.json.

  2. 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 PUT replaces 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.

  3. Check the listing. GET …/i18n/strings should show no missing cell 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).

  4. Delete the script. Remove the swapping code from customJs (PATCH …/theme or the site's config.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

POST / PATCH …/spaces/{spaceId}/collections/{id} { "translations": { "es": { "name", "description" } } }

A forum category

POST / PATCH …/spaces/{spaceId}/forum/categories/{id} { "translations": { "es": { "name", "description" } } }

The space's name and description

PATCH /orgs/{orgId}/spaces/{spaceId} { "translations": { "es": { "name", "description" } } } (stored as settings.translations)

The intro above a listing

PUT …/spaces/{spaceId}/home-doc { "translations": { "es": { "markdown": "# Hola" } } }. Send doc for block JSON instead

A hub's intro (collection, series, category, lane)

PUT …/spaces/{spaceId}/hubs/{kind}/{key}/intro { "doc": …, "translations": { "es": { "doc": … } } }. The hub page at /<mount>/es/… shows it

A lone space's header and footer

theme.chrome.translations (PUT / PATCH …/theme): labels, href, footerDescription, copyright

A site's header and footer

config.translations (PATCH /orgs/{orgId}/sites/{siteId}): labels, href, footerDescription, copyright, tagline

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.

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 by GET …/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=es for a space set to i18n.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:

  1. The page's language. A page served in Spanish (/docs/es/…) shows Spanish labels around it.

  2. ?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

SiteLabels_strings

GET /orgs/{orgId}/sites/{siteId}/i18n/strings

List strings in an organization

client.sites.labels.strings()

SiteLabels_replace

PUT /orgs/{orgId}/sites/{siteId}/i18n/labels

Replace labels in an organization

client.sites.labels.replace()

SiteLabels_merge

PATCH /orgs/{orgId}/sites/{siteId}/i18n/labels

Update labels in an organization

client.sites.labels.merge()

Space Labels

Operation

Request

What it does

Shortcuts

SpaceLabels_strings

GET /orgs/{orgId}/spaces/{spaceId}/i18n/strings

List strings in a space

client.spaces.labels.strings()

SpaceLabels_replace

PUT /orgs/{orgId}/spaces/{spaceId}/i18n/labels

Replace labels in a space

client.spaces.labels.replace()

SpaceLabels_merge

PATCH /orgs/{orgId}/spaces/{spaceId}/i18n/labels

Update labels in a space

client.spaces.labels.merge()

The Forumulate teamUpdated