Skip to content
View as Markdown

i18n Labels ​

When to Use ​

Read this cross-cutting pattern before implementing a screen, alongside its page or layout pattern. It applies to single-language apps as well as apps with a language selector. For the complete API and fallback rules, see defineI18nLabels.

Decide the UI Language First ​

Record the UI language in the screen or app specification independently of the language used to write the document. An English specification, field name, or enum does not imply English UI copy.

For a Japanese-only app, explicitly pass locale="ja" (or "ja-JP") to the root AppShell; do not rely on each developer's browser preferences. Keep the same policy across navigation, headings, forms, dialogs, accessibility labels, empty/loading/error states, validation, and notifications. Browser detection is appropriate only when that is the intended product behavior.

Use defineI18nLabels by default, including for single-language apps, to avoid a later screen-by-screen extraction. Its current API always requires an en base, even when the app selects ja. A project may explicitly opt out of catalog-based labels for a deliberately single-language app; record that decision and keep its chosen UI language consistent. AppShell does not force catalog adoption or generate missing translations.

Key Granularity and Naming ​

  • Use a flat map with semantic dotted keys, such as "requests.list.title", "requests.actions.approve", and "requests.status.PENDING". Dots are a naming convention, not nested objects.
  • Name the key after its purpose, not the current wording. Copy edits should not rename keys.
  • One key represents one independently translated message. Translate a whole sentence using typed props rather than joining fragments, so each language controls word order and pluralization.
  • Share genuinely equivalent operations such as "actions.cancel"; do not reuse a key merely because two English strings currently happen to match. A navigation group, page heading, and permission description can require different wording.
  • Keep IDs, route segments, field names, enum values, and comparison values untranslated. Translate only their user-facing presentation. User-entered content and proper names are data, not catalog keys.

Split by Feature, Not by Locale ​

Start with one i18n-labels.ts per feature, containing all of that feature's supported locales. Keep a small shared catalog for genuinely common copy. Export each factory result and its Hook; alias the Hook at import sites when a component needs several catalogs.

text
src/
  i18n-labels.ts                    # shared actions and generic feedback
  features/
    requests/
      i18n-labels.ts                # request keys, with en + ja together
      request-list.tsx
    users/
      i18n-labels.ts                # user keys, with en + ja together

Avoid a global catalog that every feature must edit, but do not create a separate catalog for every small component. Keep definitions at module scope, outside React render functions. Do not add a new wrapper, global translator, or dictionary-merging framework: each defineI18nLabels result already supplies its own typed resolvers.

Example: Stable Status, Localized Display ​

The shared setup for the runnable example is a feature catalog in features/requests/i18n-labels.ts:

tsx
import { defineI18nLabels } from "@tailor-platform/app-shell";

export const labels = defineI18nLabels({
  en: {
    "requests.list.title": "Requests",
    "requests.actions.approve": "Approve",
    "requests.status.PENDING": "Pending approval",
    "requests.status.APPROVED": "Approved",
    "requests.status.unknown": "Unknown status",
  },
  ja: {
    "requests.list.title": "申請一覧",
    "requests.actions.approve": "承認",
    "requests.status.PENDING": "承認待ち",
    "requests.status.APPROVED": "承認済み",
    "requests.status.unknown": "不明な状態",
  },
});

export const useRequestT = labels.useT;

Import useRequestT from that module in the screen below. Here, request stands in for a validated server record; its ID and status remain unchanged in every locale.

tsx
function LocalizedRequest() {
  const t = useRequestT();
  const request = { id: "REQ-1042", status: "PENDING" };
  const canApprove = request.status === "PENDING";

  return (
    <Layout>
      <Layout.Header
        title={t("requests.list.title")}
        actions={[
          <Button key="approve" disabled={!canApprove}>
            {t("requests.actions.approve")}
          </Button>,
        ]}
      />
      <Layout.Column>
        <div className="flex items-center gap-2">
          <span>{request.id}</span>
          <Badge variant={canApprove ? "warning" : "success"}>
            {t.dynamic(`requests.status.${request.status}`, t("requests.status.unknown"))}
          </Badge>
        </div>
      </Layout.Column>
    </Layout>
  );
}

The comparison uses "PENDING", never t("requests.status.PENDING"). The runtime-key fallback is itself translated. For a known key, prefer t(key) to t.dynamic() so TypeScript checks it. For a function-valued label, use t(key, props); t.dynamic() cannot supply props.

Navigation definitions use the same catalog's labels.t("requests.list.title"), not the component Hook. Pass it to meta.title or meta.breadcrumbTitle so AppShell resolves it with its current locale. Other component props that accept only strings use the Hook's t() instead.

Server-originated Text ​

Keep machine-readable meaning separate from display copy:

Server dataFrontend treatment
Stable enum or status codeKeep the value unchanged; map it to a display label
Domain error code plus structured parametersValidate the response, select a known label by code, and pass parameters to a function-valued label
Unknown error code or unexpected response shapeShow a localized generic failure message; preserve a safe support identifier when available
User-authored text, identifiers, or proper namesDisplay as data, not as a label key
English prose with no usable code/parametersIt cannot be reliably localized or used for matching; show a localized fallback and treat structured errors as a backend prerequisite

For example, an APPROVAL_ROUTE_BLOCKED code plus a separate routeName parameter can select a message like "requests.errors.routeBlocked" with { routeName }. Do not find the route name by parsing an English sentence, translate arbitrary server text as a catalog key, or compare translated error messages to decide behavior.

If a transport collapses all domain codes into PIPELINE_ERROR, the frontend cannot recover the lost meaning. Fixing error propagation is a platform concern, not something this pattern or defineI18nLabels solves. Do not pretend that a generic code identifies a specific domain failure. Avoid showing raw technical error details to end users.

Validation and E2E ​

  • Compile catalogs and call sites: explicitly declared locales must supply the English base keys and compatible props. This checks structure, not the language or quality of the wording.
  • Verify the intended locale in the browser, including navigation, dialogs, empty/error states, accessibility names, notifications, and unknown-code fallbacks. Do not assume successful type checks prove the absence of mixed-language UI.
  • Test business state with stable codes. Test rendered labels in the selected locale; do not leave English display expectations in Japanese UI tests.
  • Scope E2E locators by role and region, such as a heading in main or a button inside a named dialog. A translated phrase can legitimately occur in the sidebar, breadcrumb, and page; an unscoped text locator can become ambiguous.
  • When adding a locale, review the app's catalogs and built-in component coverage. Custom catalog translations do not automatically translate AppShell's built-in labels.