ObjectStackObjectStack

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:

src/translations/crm.translation.ts
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: '行业' },
        },
      },
    },
  },
});
objectstack.config.ts
export default defineStack({
  // ...
  translations: [CrmTranslationBundle],
  i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'] },
});

What you can translate

SurfaceWhere it lives in the bundle
Object label / plural / descriptionobjects.<name>.label / pluralLabel / description
Field labels, help text, placeholdersobjects.<name>.fields.<field>.label / help / placeholder
Picklist option labelsobjects.<name>.fields.<field>.options.<value>
View titles, descriptions, empty statesobjects.<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 messagesobjects.<name>._actions.<action>
Action result dialogs (title / description / acknowledge / field labels)objects.<name>._actions.<action>.resultDialog
Form sectionsobjects.<name>._sections.<section>
Custom validation-rule messagesobjects.<name>._validations.<rule>.message
App navigationapps.<app>.navigation.<id>.label
Dashboard label / descriptiondashboards.<name>.label / description
Dashboard widget title / description / sub-captiondashboards.<name>.widgets.<widgetId>.title / description / subCaption
Analytics dataset label / descriptiondatasets.<name>.label / description
Dataset dimension and measure labelsdatasets.<name>.dimensions.<dimension>.label / datasets.<name>.measures.<measure>.label
Page labels and page:header copypages.<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, messagesglobalActions, 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:

  1. The Accept-Language header (the client SDK sends it; setLocale() sets it)
  2. A ?locale= query parameter
  3. The stack's defaultLocale

Within the bundle, matching walks: exact (zh-CN) → case-insensitive → base language (zh-CNzh) → variant expansion (zhzh-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 defaultLocale consults that locale's own bundle and then answers with the inline label: — it never walks the fallback chain. A stack declaring defaultLocale: 'zh-CN' with fallbackLocale: 'en' and only an en bundle serves its authored Chinese to a zh-CN request, and its en bundle to everyone else. Shipping a zh-CN bundle (os i18n extract --locales=zh-CN) still works and still wins; it is optional.
  • Nothing falls to en unless it was declared. The chain is what the stack declares; a caller of the @objectstack/spec/system resolvers that declares no fallbackChain gets "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:

  • locale is 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 --check

os 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 maplabel: { en: 'Members', 'zh-CN': '成员' } — written out on the metadata and resolved at render time (pickLocalized in the UI, resolveI18nLabel on 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 extract scaffolds 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 lint reads the map's own locales: the ones it carries count as covered, the ones it omits are reported against supportedLocales. A map written { en, 'zh-CN' } under supportedLocales: ['en', 'zh-CN', 'ja-JP'] therefore produces a real missing 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 under objects.<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 validationMessages group 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 flows group 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 as planned and 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 lint does not report flows.* as missing translations, and os i18n extract does not scaffold them into your bundle — both read the same planned row. Without that, the two halves of a single os lint run 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 to live, 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 translation item 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.

On this page