Appearance
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 togetherAvoid 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
mainor 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 —
useT,t.dynamic, function props, metadata, and fallback behavior - useResolvedLocale — full locale for date and number formatting
- Sidebar Navigation — localized groups, sections, and item titles