Translations — labels and UI text per locale
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 (the card-header subtitle) | dashboards.<name>.widgets.<widgetId>.title / description |
| Dashboard global-filter label and static option labels | dashboards.<name>.globalFilters.<filterName>.label / .options.<value> — the key is the filter's name, or its field when it authors no name; an option is keyed by its value spelled as a string |
| 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 — on a kind: 'slotted' page the header under slots.header is the page's header |
| Page component copy, by component id | pages.<name>.components.<id>.title / description / label / placeholder / emptyText — reached under regions[].components[] and slots.<slot>, through properties.children and a page:tabs / page:accordion panel's items[].children |
Page tab labels (page:tabs items) | pages.<name>.components.<tabsId>.items.<value>.label — keyed by the tabs component's id and the item's value; an item without a value has no key (localize it with an inline locale map), and page:accordion panels have none |
| Screen-flow wizards (flow label, screen headings and body text, screen field copy) | flows.<flow>.label / flows.<flow>.screens.<node_id>.title / .description / .fields.<field>.label / .placeholder / .inlineHelpText — a screen field's help text is inlineHelpText, the key the field itself declares, not help; see the boundary note below |
| Global actions, messages | globalActions, messages |
| Settings UI shell copy (the source badge on a settings row) | settingsCommon.sourceLabels.<layer> — the per-namespace settings copy under settings is platform-only: an app bundle carrying it is refused by name, and the platform's own strings are translated in @objectstack/service-settings's bundle |
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 has one translatable description; the metric sub-caption is
retired (#21257). widget.description renders as the card-header subtitle
and is translated by dashboards.<name>.widgets.<widgetId>.description.
The subCaption key, which used to overlay a caption under a metric's value
onto widget.options.description, is refused at parse with its prescription,
and the server no longer writes options.description. A subtitle entry is
refused too, naming both readings: card-header copy belongs under
description; a caption under the value has nowhere to render, so delete
it. os migrate meta --from 17 lists the subCaption entries to delete from
existing bundles.
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.
When title is omitted, the header keeps the title its author wrote, and
pages.<name>.label stands in only where the header authors no title or its
title restates the page's label. So a page whose header title matches its
nav label needs only label, while a header whose title says something else,
such as a dynamic '{name}', keeps it until the bundle supplies a title.
One exception: on a page of type: 'record' bound to an object, a header
that authors no title and keeps its record chrome (recordChrome not
false) derives its heading from the record, so label does not fill it and
the record's own name shows in every locale.
Every root-level page:header (a region's entry or a slots.<slot> entry)
is resolved by this one rule.
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.
An edit beats the packaged catalog
A package's bundle translates the strings that package shipped, so it applies only to a string the served metadata still carries unchanged. Dashboards and views are the translated types an organization can edit in place: once an org's edit to a packaged dashboard or view is published, the metadata reads serve each edited string as written, in every locale. Each string is compared with the same string in the item as the package shipped it:
| Packaged type | Strings compared, and how each is matched |
|---|---|
| Dashboard | its label and description; each widget's title and description, matched by widget id; each global filter's label and static option labels, matched by the filter key and the option value |
| View | its label and description; each bulk action's label, confirmText and confirmLabel, and each of its params' label, help and placeholder, matched by name |
- Only the edited strings move. A widget the org left alone on an edited dashboard, or a view nobody edited, is still translated.
- A widget, global filter or bulk action the package never shipped counts as edited: the org authored it, so its text is served as written.
- An edited string is served in the language it was written in, to every reader. The bundle entry for that string no longer applies to it.
- Resetting the overlay restores the shipped string and its translation.
Objects follow the same rule for their label, pluralLabel and
description: a value that differs from the owning package's declaration (a
label an object extension contributes,
for one) is served as written.
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, bar the one exception noted below,
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.
settingsis platform-only and both doors refuse it by name: it is keyed by a settings manifest's namespace, and only platform code declares a manifest, so the only namespaces it could address are the platform's own. On a runtime item it used to be accepted — and because published items layer over the shipped bundles, it overrode the platform's own Settings copy. A row stored with it before the door closed loads without that group, with a warning in the server log. Keys from the retiredo.<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.
What counts as your coverage
The Studio's own metadata forms (metadataForms.* — several hundred keys
across every metadata type) are translated by @objectstack/platform-objects
and served from there, so they are not in your coverage number: an app that
translated everything it declares reads 100%, not 39%. ⛔ Do not "fix" a low
number by shipping your own metadataForms bundle — yours would override the
platform's and go stale at the next upgrade.
The rule is ownership, read from your own bundles rather than assumed: ship
translations for that family and you are asked to complete them, which is how
the platform packages stay gated on the strings they do own. Pass
--include-platform to audit the baseline anyway (os lint's flag, same
meaning), or --no-include-platform to keep it out even though you ship part
of it. The command prints how many keys it left out, and --json carries the
same two numbers as platformMetadataForms.
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 applied where each string is rendered, and its face is deliberately small. The keys are addressed the way the runner resolves them — flow name, screen node id, screen field name — and every part renders in the active locale:- a screen's
titleanddescriptionare{{ }}templates the server renders for each run, so the server translates them: it picks the translated template in the locale of the person who started the run, then fills its holes, and the console draws the screen as served. A translation keeps the holes of the text it translates ('任务“{{ subject }}”创建成功!'), anddescriptionis translated only where the screen authors one; - each field's
label,placeholderandinlineHelpTextis applied by the console's screen-flow runner; - the flow's own
labelnames the flow in the runner's header, above the step's heading, and in the completion toast. A locale the bundle does not cover shows the label authored on the flow, and a backend that serves no label shows the flow's API name.
Two strings stay outside the group. A
successMessageauthored on the flow replaces the completion sentence and renders as authored, so the translated label appears in the toast only when the flow declares none. The runner's own chrome — the Cancel and Submit buttons — belongs to the console's message catalog rather than your app's bundle.The tooling asks for these keys like any other group's.
os lintreports a missingflows.*key againstsupportedLocales, andos i18n extractscaffolds the group into your bundle. A flow's ownlabelis asked for only when the flow has a screen node, at any depth, because the runner opens only on a screen: a scheduled or record-triggered flow with no screen has no surface that shows its translated label. - a screen's
-
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
Report Metadata
Analytics reports as metadata — the four report shapes, dataset binding, drill-through, and how a report differs from a list view and a dashboard widget
Forms (Public + Internal)
Render any FormView publicly at /f/:slug or internally at /forms/:name from one metadata source, with URL prefill, post-submit behavior and form actions.