---
url: https://nsh.dev/es/docs/i18n
description: "Translating an app into every language its users speak, switching languages at run time, and formatting numbers and dates for each one."
---

# I18n

An NSH app speaks its users' languages in two ways: **translations**, the app's own text in each language, and
**formatting**, numbers, currencies and dates written as each language and region writes them. Both follow the
language the app shows, which is the user's own until the app picks another.

Translations use [rust-i18n](https://github.com/longbridge/rust-i18n)'s files and its API, in TSX and in Rust alike:
put the files in `locales/`, call `t("hello")`, and the text shows in the user's language.

```tsx
import { Text } from "nsh";
import { t } from "nsh/i18n";

export default function App() {
  return <Text>{t("messages.hello", { name: "Jason" })}</Text>;
}
```

```rust
use nsh_core::prelude::*;

fn greeting() -> impl IntoElement {
    text(move || t!("messages.hello", name = "Jason"))
}
```

## Translation files

Translations live in a `locales/` directory beside the app's `nsh.json` (beside `Cargo.toml` in a Rust app). Nothing
needs configuring: the build reads every file there and the app carries them. The files are rust-i18n's, so the
same files serve an app's Rust crates, its server or its other tools.

```
my-app/
  nsh.json
  App.tsx
  locales/
    app.yml
```

One file can hold every language (`_version: 2`), each key followed by its text per language:

```yaml
# locales/app.yml
_version: 2

hello:
  en: Hello
  zh-CN: 你好
  ja: こんにちは

messages.hello:
  en: Hello, %{name}!
  zh-CN: 你好，%{name}！
  ja: こんにちは、%{name}！
```

Or one file per language (`_version: 1`, the default), its name ending with the language (`en.yml`, `app.zh-CN.yml`)
and keys nested as objects:

```yaml
# locales/zh-CN.yml
hello: 你好
messages:
  hello: 你好，%{name}！
```

Files may be YAML (`.yml`, `.yaml`), JSON (`.json`) or TOML (`.toml`), and sit in subdirectories of `locales/`. Nested
keys join with dots: `messages.hello` above. A large app splits them by area (`locales/settings.yml`,
`locales/checkout.yml`); keys of one language from several files merge.

During `nsh run` and `nsh dev`, saving a file in `locales/` rebuilds the app like saving its code: the running app
shows the new text.

## t

`t(key, options?)` is the text of `key` in the language the app shows.

```tsx
import { t } from "nsh/i18n";

t("hello");                                  // "Hello", "你好", ...
t("messages.hello", { name: "Jason" });      // "Hello, Jason!": %{name} filled
t("hello", { locale: "ja" });                // "こんにちは": another language
```

```rust
t!("hello");
t!("messages.hello", name = "Jason");
t!("hello", locale = "ja");
```

- **Arguments** fill the `%{name}` patterns of the text. Values are written as text (`String(value)` in TSX,
  `Display` in Rust); a pattern without an argument stays as it is.
- **`locale`** asks for the text in another language than the app shows.
- **Fallback**, as rust-i18n's: a key a language lacks comes from the language without its last subtag (`zh-Hant-TW`,
  then `zh-Hant`, then `zh`), then from English (`en`). Where no language has the key, `t` returns
  `<locale>.<key>` (`"fr.messages.hello"`), so a missing translation shows on screen rather than an empty space.
- **Reactive**: `t` reads the language, so a text, a style or a computed value that calls it updates when the
  language changes, and when the translations change during development. Call it where the text is shown
  (`<Text>{t("hello")}</Text>`, `text(move || t!("hello"))`), not once into a constant.

Translations are ready before any of the app's modules runs, so `t` works at the top level of a module too (its
result is then computed once).

## The language the app shows

By default the app's text shows in the user's language: the first of the user's preferred languages
(`localeSettings().languages`, most preferred first, as the system lists them) that the app has translations for,
itself or a shorter tag of it. A user who prefers Swiss German, then Chinese, then English sees an app's `zh` text
when it has no German, and its `de` text when it has. Where the app has none of them, the text shows in `locale()`'s
language (the device's region format), which falls back to English as described above.

`setLocale` shows the app in a language the user picks inside it, whatever the device's:

```tsx
import { Button } from "nsh/base";
import { Text, View } from "nsh";
import { availableLocales, setLocale, t } from "nsh/i18n";

export function LanguagePicker() {
  return (
    <View>
      <Each of={availableLocales()}>
        {(locale) => (
          <Button onPress={() => setLocale(locale())}>
            <Text>{t("language.name", { locale: locale() })}</Text>
          </Button>
        )}
      </Each>
    </View>
  );
}
```

```rust
use nsh_core::{platform::Locale, prelude::*};

fn use_chinese() {
    set_locale(Locale::parse("zh-CN"));
}

fn follow_the_device() {
    set_locale(None);
}
```

- `setLocale("zh-CN")` changes everything that follows the language at once: `t`, `locale()`, the first of
  `localeSettings().languages` and `Intl`'s default locale. A tag that is not
  BCP 47 throws a `RangeError`.
- `setLocale(null)` (Rust: `set_locale(None)`) follows the device again.
- `availableLocales()` (Rust: `available_locales()`) lists the languages of the app's translations, sorted.

The choice lasts while the app runs; an app that remembers it saves it ([`Storage`](https://nsh.dev/modules/nsh.md#storage)) and
calls `setLocale` when it starts. Each surface has its own language: an app embedded twice can show two.

## Numbers and dates

`Intl` and `toLocaleString` format for the language the app shows by default. Pass `locale()` as their locale, so
that what writes a number reads the language and updates when `setLocale` changes it:

```tsx
import { locale } from "nsh";

const price = (amount: number) =>
  amount.toLocaleString(locale(), { style: "currency", currency: "EUR" });
// en-US: "€1,234.50"   de: "1.234,50 €"   zh-CN: "€1,234.50"
```

Native Shell's runtime formats English (`en-US`) and its regional variants itself. Every other language's data (its
separators, compact units, date patterns, month and weekday names) comes with the app: the languages of its
translations by default, or the ones `nsh.json`'s `locales` lists.

```json
{
  "id": "com.example.shop",
  "locales": ["de", "fr", "ja", "zh-CN"]
}
```

- **Without `locales`**, the app formats in the languages it has translations for (`locales/` above), and in English.
- **With `locales`**, it formats in exactly the languages listed, and English: list the languages your users
  format in when they differ from those you translate (an English-only app that writes German prices), or to leave
  some out.
- A language not carried formats as English (`en-US`), as a locale without data always has. The build warns about a
  listed language Native Shell has no data for.

Each language adds a few kilobytes to the bundle, and only the app that lists it carries it: an app formats in the
languages its users speak, never in every language Native Shell knows.

The languages with data: German (`de`), Spanish (`es`), French (`fr`), Hindi (`hi`), Indonesian (`id`), Italian
(`it`), Japanese (`ja`), Korean (`ko`), Dutch (`nl`), Polish (`pl`), Portuguese (`pt`), Russian (`ru`), Swedish
(`sv`), Thai (`th`), Turkish (`tr`), Vietnamese (`vi`) and Chinese (`zh`, simplified and traditional), with their
regional variants (`de-CH`, `es-MX`, `pt-PT`, `zh-TW`, ...). A new language is one data file in Native Shell's
`nsh-i18n` crate (`crates/nsh-i18n/data/`), contributed like any other change.

On the web, the browser's own `Intl` formats every language, whatever `locales` lists.

`nsh package` also declares these languages to iOS and macOS (the bundle's `CFBundleLocalizations`), so the system's
own UI in the app, such as the edit menu of selected text, shows in the language the app does: a user whose system
is in Chinese sees `全选` in an app that has Chinese, and English in one that does not.

See [Number and date formatting](https://nsh.dev/modules/globals.md#number-and-date-formatting) for what `Intl` formats, and
[Device and environment](https://nsh.dev/docs/device-and-environment.md#locale-settings) for the user's other settings: the time zone,
12- or 24-hour time, the first day of the week and the units.

## Rust apps

A Rust app's `locales/` sits beside its `Cargo.toml`; `nsh run`, `nsh test` and `nsh package` read it and hand
the translations to the root (`Root::translations`), so `t!` works without any setup. A Rust app built by Cargo
alone (a host that embeds the crate) passes its own:

```rust
use nsh_core::{element::Root, i18n::Translations};

let translations = Translations::from_json(include_str!("../translations.json")).unwrap();
let root = Root::of::<App>().translations(translations);
```

`t!` here is Native Shell's, not rust-i18n's: it reads the language of the surface it runs on (each surface has its
own), and a view that shows it follows `set_locale`. Its forms are rust-i18n's: `t!("key")`,
`t!("key", name = value)`, `t!("key", locale = "ja")`.

A TSX app's Rust components translate with the same `t!`: they share the app's `locales/`.

## Testing

`nsh test` opens an app in `en-US` unless `open`'s `locale` says otherwise; a test of another language opens it in
that one, or calls `setLocale` as the app does:

```tsx
import { open, test } from "nsh/test";

test("the greeting is translated", async () => {
  const app = await open("./App.tsx", { locale: "zh-CN" });
  await app.waitFor("text:你好");
});
```

## Checklist

- Every string the user reads comes from `t` (labels, placeholders, accessibility labels, alerts' buttons), never a
  literal in a component.
- Keys name what the text is for (`checkout.pay`, `errors.network`), not the English text, so English can change
  without renaming keys.
- Whole sentences go in one key with `%{name}` arguments, never pieced together from several: word order differs
  between languages.
- Numbers, prices and dates go through `Intl` / `toLocaleString`, never written with `+` and `toFixed`.
- Layouts leave room: German and Finnish text runs much longer than English, Chinese and Japanese shorter.
- `nsh.json`'s `locales` lists the languages users format in when they differ from the translated ones.
