Client internationalization
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.
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
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.
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.
Most apps move the two together:
i18n.onLocaleChange((locale) => client.setLocale(locale));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.
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.
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 |
client.setLocale() and per-call locale options | Client SDK |
| Declaring the locales your content has | Configuration |
Marking one field .localized() | Fields |
| The language the admin panel itself speaks | Admin configuration |
Next
Message catalogs 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.