ObjectStackObjectStack

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:

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 (the card-header subtitle)dashboards.<name>.widgets.<widgetId>.title / description
Dashboard global-filter label and static option labelsdashboards.<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 / 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 — on a kind: 'slotted' page the header under slots.header is the page's header
Page component copy, by component idpages.<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, messagesglobalActions, 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:

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

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 typeStrings compared, and how each is matched
Dashboardits 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
Viewits 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:

  • 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. settings is 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 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.

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 (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 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 title and description are {{ }} 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 }}”创建成功!'), and description is translated only where the screen authors one;
    • each field's label, placeholder and inlineHelpText is applied by the console's screen-flow runner;
    • the flow's own label names 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 successMessage authored 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 lint reports a missing flows.* key against supportedLocales, and os i18n extract scaffolds the group into your bundle. A flow's own label is 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.

  • 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