Skip to content

Internationalization

ARDOR ships a react-admin I18nProvider built on ra-i18n-polyglot. You configure it with one options object bound in the container. Components read translations through useTranslate, and useRequestHeaderLocale forwards the current locale to the API as a request header.

Prerequisites

An application built on BaseArdorApplication with a container - see Application.

Quick Reference

ExportKindPurpose
DefaultI18nProviderclassProvider that builds the react-admin I18nProvider from II18nProviderOptions
II18nProviderOptionsinterfacei18nSources and listLanguages
englishMessagesconstEnglish bundle with the react-admin ra.* keys
vietnameseMessagesconstVietnamese bundle with the react-admin ra.* keys
useTranslatehookTyped translate(key, options?)
TUseTranslateFntypeSignature of the function returned by useTranslate
TUseTranslateKeystypeAccepted keys: default keys plus IUseTranslateKeysOverrides
TUseTranslateKeysDefaulttypeTFullPaths<typeof englishMessages>
IUseTranslateKeysOverridesinterfaceEmpty interface you augment to add application keys
TFullPathstypeDotted paths of a nested object type
useRequestHeaderLocalehookSets the locale header on the default REST data provider
CoreBindings.I18N_PROVIDER_OPTIONSbinding keyWhere the options object is read from
HeaderConsts.X_LOCALEconstDefault header name used by useRequestHeaderLocale

DefaultI18nProvider

DefaultI18nProvider extends BaseProvider<I18nProvider>. Its constructor receives II18nProviderOptions from the container under CoreBindings.I18N_PROVIDER_OPTIONS. Its value() returns a polyglotI18nProvider configured as follows:

  • messages for a locale come from i18nSources[locale], falling back to englishMessages
  • missing keys are allowed; the provider returns the key itself as the translated text
  • the initial locale is chosen from the browser language (see below)
ts
class DefaultI18nProvider extends BaseProvider<I18nProvider> {
  constructor(i18nProviderOptions: II18nProviderOptions);
  value(container: Container): I18nProvider;
}

You do not construct it yourself. The application resolves it from the container; see Binding keys.

II18nProviderOptions

ts
interface II18nProviderOptions {
  i18nSources?: Record<string | symbol, AnyType>;
  listLanguages?: Locale[]; // { locale: string; name: string }
}
OptionDefaultMeaning
i18nSources{ en: englishMessages }Message bundle per locale code
listLanguages[{ locale: 'en', name: 'English' }]Locales offered to react-admin, with a display name

Both defaults apply independently. If you pass listLanguages with vi but no i18nSources.vi, the vi locale is selectable and resolves to englishMessages.

Build the options object and bind it under CoreBindings.I18N_PROVIDER_OPTIONS:

ts
import {
  type II18nProviderOptions,
  englishMessages,
  vietnameseMessages,
} from '@venizia/ardor';

export const i18nOptions: II18nProviderOptions = {
  i18nSources: {
    en: englishMessages,
    vi: vietnameseMessages,
  },
  listLanguages: [
    { locale: 'en', name: 'English' },
    { locale: 'vi', name: 'Tiếng Việt' },
  ],
};
ts
// inside your application's bindContext()
this.container.bind({ key: CoreBindings.I18N_PROVIDER_OPTIONS, value: i18nOptions });
...

The binding API is described in Application.

Initial locale selection

The provider reads navigator.language once, at module load, and keeps the part before the first -. en-US becomes en, vi-VN becomes vi. If navigator.language is empty, en-US is assumed.

The initial locale is that language if it appears in listLanguages; otherwise it is 'en'. The fallback is always the literal 'en', even when 'en' is not in listLanguages.

Browser languagelistLanguages localesInitial locale
vi-VNen, vivi
fr-FRen, vien
fr-FRvien

Message bundles

englishMessages and vietnameseMessages are complete bundles for the react-admin ra.* keys (ra.action.*, ra.page.*, ra.message.*, and so on). Use them as the base of every locale you offer. The bundle contents are not reproduced here.

englishMessages is also the source of the default key type: TUseTranslateKeysDefault is TFullPaths<typeof englishMessages>, so every ra.* path is a valid useTranslate key out of the box.

Extending the bundles with application messages

Spread a bundle and add your own top-level namespace. Keep application keys in their own namespace so they never collide with ra.

ts
import { type II18nProviderOptions, englishMessages, vietnameseMessages } from '@venizia/ardor';

export const appEn = {
  ...englishMessages,
  app: {
    welcome: 'Welcome',
    products: {
      empty: 'No products yet',
    },
  },
};

export const appVi = {
  ...vietnameseMessages,
  app: {
    welcome: 'Chào mừng',
    products: {
      empty: 'Chưa có sản phẩm',
    },
  },
};

export const i18nOptions: II18nProviderOptions = {
  i18nSources: { en: appEn, vi: appVi },
  listLanguages: [
    { locale: 'en', name: 'English' },
    { locale: 'vi', name: 'Tiếng Việt' },
  ],
};

The spread is shallow. If you add a ra key on the application object, it replaces the whole ra subtree from the bundle. To override a single ra.* message, spread that subtree too.

useTranslate

ts
type TUseTranslateFn = (key: TUseTranslateKeys, options?: AnyType) => string;

const useTranslate: () => TUseTranslateFn;

useTranslate reads the current react-admin I18nProvider and returns a memoized translate function. options is forwarded unchanged to the provider's translate. When no provider is present in the tree, the hook returns an identity function that gives the key back, so components render without crashing outside the admin tree.

tsx
import { useTranslate } from '@venizia/ardor';

export const SaveLabel = () => {
  const translate = useTranslate();

  return <span>{translate('ra.action.save')}</span>;
};

Because missing keys are allowed by the provider, a key that exists in no bundle renders as the key string itself.

Typing application keys

TUseTranslateKeys is the union of TUseTranslateKeysDefault and keyof IUseTranslateKeysOverrides. IUseTranslateKeysOverrides is empty by default. Augment it in @venizia/ardor-admin with the dotted paths of your application object, computed by TFullPaths, and translate('app.welcome') type-checks.

ts
import { type TFullPaths, englishMessages } from '@venizia/ardor';

export const appEn = {
  ...englishMessages,
  app: {
    welcome: 'Welcome',
    products: {
      empty: 'No products yet',
    },
  },
};

type TAppKeys = TFullPaths<typeof appEn>;

declare module '@venizia/ardor-admin' {
  interface IUseTranslateKeysOverrides extends Record<TAppKeys, string> {}
}

Only the property names of IUseTranslateKeysOverrides matter; the value type is ignored. Put the augmentation in a .d.ts or a module that is always included in the build. See Module augmentation.

With the augmentation in the same build, the application key is accepted by translate. The snippet below repeats the augmentation so it stands on its own:

tsx
import { type TFullPaths, englishMessages, useTranslate } from '@venizia/ardor';

const appEn = {
  ...englishMessages,
  app: {
    welcome: 'Welcome',
    products: {
      empty: 'No products yet',
    },
  },
};

type TAppKeys = TFullPaths<typeof appEn>;

declare module '@venizia/ardor-admin' {
  interface IUseTranslateKeysOverrides extends Record<TAppKeys, string> {}
}

export const EmptyProducts = () => {
  const translate = useTranslate();

  return <p>{translate('app.products.empty')}</p>;
};

useRequestHeaderLocale

ts
const useRequestHeaderLocale: (params?: { key?: string }) => void;

useRequestHeaderLocale watches the react-admin locale (useLocaleState) and, on every change, calls setHeaders({ [key]: locale }) on the network service of the DefaultRestDataProvider bound at CoreBindings.DEFAULT_REST_DATA_PROVIDER. The header name defaults to HeaderConsts.X_LOCALE (x-locale); pass key to use another name.

Call it once, in a component that lives inside both the application context and the admin tree.

tsx
import { useRequestHeaderLocale } from '@venizia/ardor';

export const LocaleHeaderSync = () => {
  useRequestHeaderLocale();

  return null;
};

With a custom header name:

tsx
import { useRequestHeaderLocale } from '@venizia/ardor';

export const AcceptLanguageSync = () => {
  useRequestHeaderLocale({ key: 'accept-language' });

  return null;
};

The header is set on the default REST data provider only. Other network services are not affected; see Network.

Common pitfalls

  • Locale listed, bundle missing. A locale in listLanguages without an entry in i18nSources silently uses englishMessages.
  • Fallback is 'en', not the first listed locale. When the browser language is not offered, the provider starts in en even if your listLanguages does not contain it.
  • Browser language is read once. The language is captured when the module loads, not per render. Changing the browser setting needs a reload.
  • Missing keys do not throw. With allowMissing: true the key string is rendered. A typo shows up as app.welcom on screen, not as an error. Typing the keys through IUseTranslateKeysOverrides catches this at compile time.
  • Shallow spread. { ...englishMessages, ra: { ... } } replaces the entire ra subtree.
  • useTranslate outside the admin tree. It returns the identity function, so text appears untranslated rather than failing.
  • useRequestHeaderLocale needs both contexts. It calls useInjectable and useLocaleState, so it must run under the application context and inside react-admin.

Released under the MIT License.