QUESTPIE
Client

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.

View markdown

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

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.

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

MemberWhat it does
localeThe current locale. Read only, so go through setLocale.
localesThe 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.

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

TopicPage
Plural forms, placeholders, and every rule enforcedMessage catalogs
client.setLocale() and per-call locale optionsClient SDK
Declaring the locales your content hasConfiguration
Marking one field .localized()Fields
The language the admin panel itself speaksAdmin 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.

On this page