Translations
Labels and UI text as metadata — one bundle per locale, resolved per request, with CLI tooling to draft and to gate coverage in CI.
Every label in an ObjectStack app — object and field names, picklist options, view titles, action buttons, app navigation — is metadata, not a string baked into a component. Adding a language means adding a bundle, not touching the app.
Single-locale apps need none of this. translations and i18n are both
optional; with no bundle the runtime falls back to the labels declared on your
metadata. Built-in system fields (owner_id, created_at, …) carry their own
labels either way.
Your first bundle
A bundle is a map of locale → translations, registered on your stack:
import { defineTranslationBundle } from '@objectstack/spec';
export const CrmTranslationBundle = defineTranslationBundle({
en: {
objects: {
crm_account: {
label: 'Account',
pluralLabel: 'Accounts',
fields: {
name: { label: 'Account Name' },
industry: { label: 'Industry' },
},
},
},
},
'zh-CN': {
objects: {
crm_account: {
label: '客户',
pluralLabel: '客户',
fields: {
name: { label: '客户名称' },
industry: { label: '行业' },
},
},
},
},
});export default defineStack({
// ...
translations: [CrmTranslationBundle],
i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'] },
});What you can translate
| Surface | Where it lives in the bundle |
|---|---|
| Object label / plural / description | objects.<name>.label / pluralLabel / description |
| Field labels, help text, placeholders | objects.<name>.fields.<field>.label / help / placeholder |
| Picklist option labels | objects.<name>.fields.<field>.options.<value> |
| View titles, descriptions, empty states | objects.<name>._views.<view> |
| Bulk-action copy on a list view (button, confirm prompt, dialog fields) | objects.<name>._views.<view>.bulkActions.<def>.label / .confirmText / .confirmLabel / .params.<param>.label / .help / .placeholder — a bulk param's hint is help, not helpText |
| Action labels, confirm text, success messages | objects.<name>._actions.<action> |
| Action result dialogs (title / description / acknowledge / field labels) | objects.<name>._actions.<action>.resultDialog |
| Form sections | objects.<name>._sections.<section> |
| Custom validation-rule messages | objects.<name>._validations.<rule>.message |
| App navigation | apps.<app>.navigation.<id>.label |
| Dashboard label / description | dashboards.<name>.label / description |
| Dashboard widget title / description / sub-caption | dashboards.<name>.widgets.<widgetId>.title / description / subCaption |
| Analytics dataset label / description | datasets.<name>.label / description |
| Dataset dimension and measure labels | datasets.<name>.dimensions.<dimension>.label / datasets.<name>.measures.<measure>.label |
Page labels and page:header copy | pages.<name>.label / description / title / subtitle |
| Screen-flow wizards (flow label, screen headings, screen field copy) | flows.<flow>.label / flows.<flow>.screens.<node_id>.title / .fields.<field>.label / .placeholder — see the boundary note below |
| Global actions, settings, messages | globalActions, settings, messages |
A label written as an inline locale map (label: { en: 'Members', 'zh-CN': '成员' }) | Nowhere — it is written on the metadata and resolved at render time; see Current boundaries below |
The metadata types resolved per request are object, view, action, app,
dashboard, dataset, and page — a field's labels are translated as part of
its object document, and so are the labels of any actions the object declares
inline
(#3370). Before that, an object document went out with its authored action
labels untouched: sys_approval_request's Approve / Reject rendered in English
in a zh-CN workspace for every consumer except the Console, which happened to
re-resolve them client-side against its own copy of the bundle.
A widget's subtitle alias resolves to subCaption, not description
(#5428 item 4, #7862). The metric widget's sub-caption — the string under
the number — is the authored widget.options.description, a different
authored field from widget.description (the copy under the card header).
Two authored fields get two keys (「两个作者字段两个 key」): description
translates widget.description, subCaption translates
widget.options.description, and neither reaches the other's field.
Dataset copy is keyed at the top level, not under the dashboard
(#14253). A dataset is the analytics definition — its dimensions and its
measures — that widgets bind to by reference, and a measure's label is drawn
on the dashboard: under every metric tile and on every chart axis. It gets
its own datasets.<name> group rather than a slot under dashboards.<name>
because the same measure is drawn by many widgets across many dashboards —
keying it per presentation would ask for the same string once per widget, and
would leave a dataset no dashboard references unaddressable. Below the
dataset the copy is label and nothing else: a dimension and a measure each
declare exactly one display string, so description belongs to the dataset
itself and writing one on a dimension or measure is rejected with a message
saying so.
Page headers are keyed by page name (#3589). A page's page:header
component has no stable id, so its properties.title / properties.subtitle
are addressed through the page itself: pages.<name>.title / subtitle.
title falls back to pages.<name>.label, so a page whose header title
matches its nav label needs only label. Every page:header in the page's
regions receives the same copy.
One-shot result dialogs are translatable (#3347). The post-success
resultDialog shown by actions like create_user (temporary password), 2FA
backup codes, and OAuth client-secret rotation carries its own
_actions.<action>.resultDialog slot (title / description / acknowledge
and fields keyed by the literal result-field path, e.g. "user.email").
os i18n extract emits these keys; the shipped platform dialogs ship
en / zh-CN / ja-JP / es-ES copy. Separately, platform notification and
storage strings localize to the recipient's locale — collaboration
assignment / @mention bell titles resolve in the locale of whoever reads the
bell (not the actor), and sys_file / sys_upload_session ship their own
bundles so the file-detail page and its status pipeline are localized (#3354).
How a locale is chosen
Per request, in this order:
- The
Accept-Languageheader (the client SDK sends it;setLocale()sets it) - A
?locale=query parameter - The stack's
defaultLocale
Within the bundle, matching walks: exact (zh-CN) → case-insensitive →
base language (zh-CN → zh) → variant expansion (zh → zh-CN). If
nothing matches, the request walks the stack's declared fallback locale
(fallbackLocale, else defaultLocale), and finally the literal label on the
metadata. Translation lookup never throws — a missing string degrades to
the next best text.
Two rules keep the declaration honest (#14882, #15711):
- The authored label is the default locale's text. A request for
defaultLocaleconsults that locale's own bundle and then answers with the inlinelabel:— it never walks the fallback chain. A stack declaringdefaultLocale: 'zh-CN'withfallbackLocale: 'en'and only anenbundle serves its authored Chinese to azh-CNrequest, and itsenbundle to everyone else. Shipping azh-CNbundle (os i18n extract --locales=zh-CN) still works and still wins; it is optional. - Nothing falls to
enunless it was declared. The chain is what the stack declares; a caller of the@objectstack/spec/systemresolvers that declares nofallbackChaingets "requested locale, then the authored label".
Resolved labels are served straight from the REST metadata endpoints (the locale is part of the ETag), so the Console and any SDUI client get translated metadata without doing lookups themselves.
Organizing the files
How you lay out translation source files is an authoring convention — your import graph assembles whichever layout you choose into the bundles you register. Common layouts:
- per locale — one file per language, combined in the bundle.
This is what the Todo example does (
en,zh-CN,ja-JP). - bundled — every locale in one file, like the CRM example above. Fine for two locales; unwieldy past that.
- per namespace — split by module.
Authoring in the product
Files are not the only door. A translation metadata item — created in the
Studio, through the metadata API, or by an agent — carries one locale's worth
of the same groups a file bundle uses, plus the locale it translates:
import { defineTranslation } from '@objectstack/spec/system';
export default defineTranslation({
locale: 'zh-CN',
objects: {
crm_account: {
label: '客户',
fields: { name: { label: '客户名称' } },
},
},
});Published items are picked up at boot and again on every publish, without a restart. They layer over the file bundles, so an authored value wins over a shipped one for the same key, and deleting the item restores the shipped value.
Two things to know:
-
localeis required. An item whose locale can't be resolved is skipped, and a silent skip is the hardest kind of missing translation to diagnose. -
Only the groups on this page are accepted, and since #4001 that is literally true: a key none of them declares is rejected, in a runtime item and in a file-authored bundle. Keys from the retired
o.<object>shape (o,app,nav,dashboard,_globalOptions,_meta, …) carry a message naming the group to use instead — they used to save cleanly and then render nothing (#3778). Everything else gets the nearest declared key suggested.Before that, only those ten keys were checked and only on the runtime door, so a misspelled group in a bundle file was dropped in silence. On this surface that is the worst possible outcome: a translation that resolves to nothing looks exactly like a translation nobody has written yet — no wrong string appears, just the source language, indefinitely.
Draft with the CLI, gate in CI
# Scaffold entries for everything translatable that isn't yet
npx os i18n extract --locales zh-CN --fill todo --out src/translations
# Report coverage; fail the build when it slips
npx os i18n check --strict --threshold 95
# Fail if the committed bundles have fallen behind the schema
npx os i18n extract --locales zh-CN --fill todo --out src/translations --checkos i18n check exits non-zero on violations, so it works as a CI gate.
A missing string in the default locale is an error; missing strings in
other locales are warnings until you set --strict / --threshold. The Todo
example ships a completeness test alongside its bundles — worth copying.
Which locales get checked
Your project decides, and the tooling never assumes. os lint, os i18n check
and os i18n extract read the i18n block above — supportedLocales is the
set they gate, defaultLocale the one that must be complete. Without that
block they fall back to whatever locales your bundles already cover, and
finally to en.
The consequence worth stating plainly: a project that does not do i18n
reports nothing. No i18n block and no bundles means one active locale, the
default one, and your inline label: is already that locale's text — so there
is no gap to report and no need to reach for --skip-i18n. The same holds if
your source language isn't English: declare defaultLocale: 'zh-CN' and the
tooling stops asking for English you never promised. The runtime reads the
declaration the same way (#15711): a request for the default locale answers
with the authored label, so what the gate counts as covered is what gets
served.
Translating is therefore opt-in, but once you opt in it covers the whole
declared surface — every row of the table above, including action labels
declared inline on an object. That surface used to be narrower than what
os i18n extract would scaffold, which is how untranslated approval buttons
shipped without any lint noticing (#3370).
The two gates answer different questions, and you want both. os i18n check
asks are the strings translated? — a coverage number about human work.
os i18n extract --check asks are the generated bundles still what the schema
produces? — a freshness check about machine output. Renaming a field's label,
adding an object, or removing a spec key leaves coverage at 100% while the
bundles quietly go stale, which is exactly how this repo's own bundles ended up
carrying translations for keys the schema had deleted (#3670).
--check writes nothing: it re-renders and diffs against --out, naming each
stale file and printing the regenerate command. It runs in the same merge
mode as a normal extract, so it never asks anyone to re-translate — an
up-to-date bundle re-extracts byte-identically.
Current boundaries
Honest limits worth knowing before you plan around them:
-
Both forms of a label are authorized — and an inline locale map is rendered but never extracted. A label may be a plain string, translated in a bundle under the key from the table above, or an inline locale map —
label: { en: 'Members', 'zh-CN': '成员' }— written out on the metadata and resolved at render time (pickLocalizedin the UI,resolveI18nLabelon the server). The map is the localisation route for the props that have no bundle key at all, and a page localised that way is fully localised. But it never reaches the bundle:os i18n extractscaffolds no row for it, so a translator working from your bundles will not find those strings — every locale a map is to carry is one you write in the map itself.Coverage still counts it (#14749).
os lintreads the map's own locales: the ones it carries count as covered, the ones it omits are reported againstsupportedLocales. A map written{ en, 'zh-CN' }undersupportedLocales: ['en', 'zh-CN', 'ja-JP']therefore produces a realmissing translation for locale "ja-JP"finding — close it by adding the locale to the map. Two tools treat the same prop differently and both are right: the gate reports what the author did and did not write, while the extractor refuses to invent a key that a later reordering of two sibling components would silently reassign.No bundle key exists for a map, and none is synthesised from a node's position in the component tree. That is a settled refusal (maintainer ruling 2026-09-03, #14749) — not a gap awaiting a fix, and not something to plan around.
-
Validation messages are translatable, but substituted whole — there is no interpolation. Author the message on the rule (
object.validations[].message), which the engine returns on every rejected write, and translate it underobjects.<name>._validations.<rule>.message(the row in the table above) — live since 17.3.0. The write path swaps the whole sentence for the bundle's, so only the language changes: an authored message has no parameter contract, so{variable}placeholders in it are not filled, and a key the bundle does not carry falls back to the authored literal.The retired top-level
validationMessagesgroup is not what came back. It was removed in 17.0.0 (#4667) because nothing read it — a translated rule message was stored and never shown — and that retirement stands. It was keyed by rule name alone and so could not tell two objects' rules apart; the route above is object-scoped and shipped with its reader. ADR-0049's 2026-09-04 amendment carries that record. -
The
flowsgroup is declared, not yet applied. A screen flow's copy has somewhere to live (#7646) and the keys are addressed the way the runner resolves them — flow name, screen node id, screen field name — but no shipped screen-flow runner reads the group yet, so a wizard still renders the strings authored on the flow. The liveness ledger carries it asplannedand the compile lint warns when you author it. Two related limits are deliberate: a screen field has no help text to translate (it declares none), and the runner's own chrome — the Cancel and Submit buttons — belongs to the console's message catalog rather than your app's bundle.So the tooling does not ask you for these keys either.
os lintdoes not reportflows.*as missing translations, andos i18n extractdoes not scaffold them into your bundle — both read the sameplannedrow. Without that, the two halves of a singleos lintrun contradicted each other: omitting the keys was reported as a coverage gap, and adding them was reported as authoring a group nothing reads. If you author the copy anyway you get the liveness warning and nothing else — it is telling you the truth, not asking you to delete a key you will need later. The day the runner lands and the row flips tolive, both the coverage report and the extract skeleton pick the group up on their own; there is no flag to turn on. -
No ICU MessageFormat — plural/gender formatting isn't available; interpolation is always simple
{variable}substitution. -
Runtime authoring is process-wide. The authored layer is synced across all organizations into one i18n map, so a
translationitem published in one tenant resolves in every tenant on the same process. Ship per-tenant translations as files until this is scoped. -
AI-suggested translation fields (
aiSuggested,aiConfidence) are schema only.
Related
- Views · Apps · Actions — the surfaces you're translating
- Objects — where the fallback labels are declared
- i18n standard — the normative protocol
- Translation schema reference — every field, generated from the spec