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.

Translations

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>
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>
App navigationapps.<app>.navigation.<id>.label
Dashboards and widgetsdashboards.<name>
Global actions, settings, messagesglobalActions, settings, messages

The metadata types resolved per request are object, view, action, app, and dashboard — a field's labels are translated as part of its object document.

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 chain falls back to en, and finally to the literal label on the metadata. Translation lookup never throws — a missing string degrades to the next best text.

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

i18n.fileOrganization picks the layout:

  • per_locale (default) — 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.

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

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.

Current boundaries

Honest limits worth knowing before you plan around them:

  • validationMessages has no runtime consumer today. The schema accepts it and the coverage tooling counts it, but nothing reads it back — validation errors are not translated through bundles yet.
  • messageFormat: 'icu' is declared but not implemented — plural/gender formatting isn't available; simple is what runs.
  • Two bundle shapes exist. File-authored bundles use the objects.<name> shape documented here, which is what the resolver reads. The translation metadata type authored at runtime uses an object-first (o.<object>) shape, and the two are not bridged — author translations as files for now.
  • AI-suggested translation fields (aiSuggested, aiConfidence) are schema only.

On this page