# Client internationalization (/docs/client/i18n)

---
title: Client internationalization
description: createSimpleI18n builds a typed message catalog for any client, with plural selection and Intl formatting and no React. The questpie/client-react entry adds the provider and three hooks.
kind: guide
package: questpie
---

Your rows can be localized. Your buttons cannot. "Save", "No results" and
"3 items" live in your code, and no collection will translate them.

`createSimpleI18n` is the piece that does. It ships in `questpie/client`, the
same entry as `createClient`. It imports neither React nor `@questpie/admin`,
so it runs in a browser, a worker, a test or a server render.

## Declare the catalogs

```ts title="src/lib/i18n.ts"
import { createSimpleI18n } from "questpie/client";

export const i18n = createSimpleI18n({
	locale: "en",
	locales: ["en", "sk"] as const,
	messages: {
		en: {
			"nav.home": "Home",
			"items.count": {
				one: "{{count}} item",
				other: "{{count}} items",
			},
		},
		sk: {
			"nav.home": "Domov",
			"items.count": {
				one: "{{count}} položka",
				few: "{{count}} položky",
				other: "{{count}} položiek",
			},
		},
	},
	fallbackLocale: "en",
	onLocaleChange(locale) {
		localStorage.setItem("interface-locale", locale);
	},
});
```

`locales` is the whole list. `locale` is the one you start on. Every locale
needs a catalog, and every catalog needs the same message keys. Plural
categories are per message. That is why `sk` carries a `few` and `en` does not.

```ts
i18n.t("nav.home"); // "Home"
i18n.t("items.count", { count: 3 }); // "3 items"
i18n.formatNumber(1234.5); // "1,234.5"

i18n.setLocale("sk");
i18n.t("nav.home"); // "Domov"
```

The locale tuple and the message keys stay literal types. `i18n.t("missing")`
and `i18n.setLocale("de")` are compile errors. Only one of them fails at
runtime as well. `setLocale` throws, and `t` hands the key straight back.

The factory rechecks the catalog rules every time you call it. That covers
catalogs you fetched as JSON, and calls from plain JavaScript.

## What the adapter gives you

| Member                          | What it does                                                                                                                |
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `locale`                        | The current locale. Read only, so go through `setLocale`.                                                                   |
| `locales`                       | The declared list, frozen.                                                                                                  |
| `t(key, params?)`               | The message, placeholders filled, plural category picked.                                                                   |
| `setLocale(locale)`             | Switches and notifies. Throws `RangeError` on an undeclared locale.                                                         |
| `onLocaleChange(fn)`            | Subscribes. Returns the function that unsubscribes.                                                                         |
| `formatDate(date, options?)`    | `Intl.DateTimeFormat` in the current locale. Takes a `Date` or a timestamp.                                                 |
| `formatNumber(value, options?)` | `Intl.NumberFormat` in the current locale.                                                                                  |
| `formatRelative(date)`          | "in 3 minutes", "yesterday". Picks the unit for you.                                                                        |
| `getLocaleName(code)`           | The name of `code`, written in the current locale. `"sk"` reads `"Slovak"` in en.                                           |
| `isRTL()`                       | Takes nothing. True when the current locale is ar, fa, he, ps, sd, ur or yi. It reads the base language, so `ar-EG` counts. |

### Switching to the current locale does nothing

`setLocale` returns early when the locale you pass is already current. Neither
the `onLocaleChange` option nor the subscribers fire.

### formatRelative picks its own unit

Seconds under a minute, minutes under an hour, hours under a day, days past
that. It formats with `numeric: "auto"`, so one day back reads "yesterday"
rather than "1 day ago".

## Two locales, not one

`i18n.setLocale()` and `client.setLocale()` are different switches. Moving one
does not move the other.

The adapter's locale picks which catalog `t()` reads. That is your interface
language. It is never sent to the server.

The client's locale picks which translation of a row the server sends back for
`.localized()` fields. `client.setLocale("sk")` writes `accept-language` on
every later request. A `locale` passed to a single call beats that header for
that call. See [Client SDK](/docs/client/sdk).

Most apps move the two together:

```ts
i18n.onLocaleChange((locale) => client.setLocale(locale));
```

<Callout type="warn" title="An unknown locale falls back in silence">
	An app with no `locale` config has exactly one locale, `en`. The server
	coerces any other code to that default and answers in it. Declare your content
	locales in `config/app.ts` first.
</Callout>

## React

React is an optional peer, 18 or 19. The bindings sit in a separate entry,
`questpie/client-react`, so the universal one stays free of it.

```tsx title="src/app.tsx"
import { I18nProvider, useTranslation } from "questpie/client-react";

import { i18n } from "./lib/i18n";

function Navigation() {
	const { locale, setLocale, t } = useTranslation();

	return (
		<nav>
			<span>{t("nav.home")}</span>
			<button onClick={() => setLocale(locale === "en" ? "sk" : "en")}>
				{locale}
			</button>
		</nav>
	);
}

export function App() {
	return (
		<I18nProvider adapter={i18n}>
			<Navigation />
		</I18nProvider>
	);
}
```

Click the button and every consumer under the provider rerenders in Slovak.

The provider subscribes through `useSyncExternalStore` and drops the
subscription on unmount. It pins the first render's locale as the server
snapshot. A server render and its hydration then agree. Build the adapter with
the same starting locale on both sides.

### The three hooks

`useTranslation()` returns `locale`, `locales`, `t`, `setLocale`,
`formatDate`, `formatNumber`, `getLocaleName` and `isRTL`. Watch that last one.
Here `isRTL` is a boolean, already called for you.

`useI18n()` returns the whole adapter, including `formatRelative` and
`onLocaleChange`. The adapter type marks `formatRelative` optional, so narrow
it before you call it. `useI18n()` throws outside a provider. `useSafeI18n()`
does the same lookup and returns `null` instead. Reach for it in a component
that has to work with or without a provider.

## Where each topic lives

| Topic                                               | Page                                             |
| --------------------------------------------------- | ------------------------------------------------ |
| Plural forms, placeholders, and every rule enforced | [Message catalogs](/docs/client/i18n/messages)   |
| `client.setLocale()` and per-call locale options    | [Client SDK](/docs/client/sdk)                   |
| Declaring the locales your content has              | [Configuration](/docs/ship/configuration)        |
| Marking one field `.localized()`                    | [Fields](/docs/schema/fields)                    |
| The language the admin panel itself speaks          | [Admin configuration](/docs/admin/configuration) |

## Next

**[Message catalogs](/docs/client/i18n/messages)** covers what may go in a
message, and what `t()` does when a value is missing. It also lists every rule
the factory enforces before it hands you an adapter.
