---
url: https://docs.tailor.tech/app-shell/patterns/i18n-labels.md
description: >-
  Decide UI language before implementation and keep display labels separate from
  stable data
---

# 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](../api/define-i18n-labels).

## 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 data                                      | Frontend treatment                                                                                                                    |
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- |
| Stable enum or status code                       | Keep the value unchanged; map it to a display label                                                                                   |
| Domain error code plus structured parameters     | Validate the response, select a known label by code, and pass parameters to a function-valued label                                   |
| Unknown error code or unexpected response shape  | Show a localized generic failure message; preserve a safe support identifier when available                                           |
| User-authored text, identifiers, or proper names | Display as data, not as a label key                                                                                                   |
| English prose with no usable code/parameters     | It 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.

## Related

* [defineI18nLabels](../api/define-i18n-labels) — `useT`, `t.dynamic`, function props, metadata, and fallback behavior
* [useResolvedLocale](../api/use-resolved-locale) — full locale for date and number formatting
* [Sidebar Navigation](../concepts/sidebar-navigation) — localized groups, sections, and item titles
