i18n
A minimalist i18n (≈ 1.5KB gzip) built into the SDK. It covers the common case — 2 or 3 languages, interpolation and simple plurals — without the weight of a full-blown library.
Why a built-in i18n?
Most Tempest apps need only PT-BR + EN with a few dozen strings. Pulling in i18next (with its backend, detector and formatter plugins) for that adds kilobytes and configuration nobody will maintain. The SDK ships just enough for the cheap, simple case — and steps aside when you need more.
The catalog
Everything starts with a catalog: a { [locale]: { [key]: "text" } } map. The keys are yours — use flat names, namespaces ("auth.login"), or whatever matches your string pipeline. The SDK enforces no schema.
// src/i18n.ts
import type { Catalog } from "tempest-react-sdk";
export const messages: Catalog = {
"pt-BR": {
greet: "Olá, {name}",
"nav.home": "Início",
alos_one: "{count} Alô",
alos_other: "{count} Alôs",
},
en: {
greet: "Hi, {name}",
"nav.home": "Home",
alos_one: "{count} Alo",
alos_other: "{count} Alos",
},
};
Mounting the provider
Wrap your tree in I18nProvider, passing the initial locale, an optional fallbackLocale and the catalog. Here is a complete, runnable app:
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { I18nProvider } from "tempest-react-sdk";
import "tempest-react-sdk/styles.css";
import { messages } from "./i18n";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<I18nProvider locale="pt-BR" fallbackLocale="en" messages={messages}>
<App />
</I18nProvider>
</StrictMode>,
);
Free: <html lang> and persistence
I18nProvider writes <html lang="pt-BR"> automatically (good for SEO and screen readers) and persists the user's choice in localStorage["tempest-locale"]. To disable persistence, pass storageKey={null}.
Translating in the UI
useI18n() returns the translation helpers plus setLocale, locale and availableLocales. A complete component:
import { useI18n } from "tempest-react-sdk";
export function Header() {
const { t, plural, formatNumber, locale, availableLocales, setLocale } = useI18n();
return (
<header>
<p>{t("greet", { name: "Mau" })}</p> {/* "Olá, Mau" */}
<p>{plural("alos", 3)}</p> {/* "3 Alôs" */}
<p>{formatNumber(1234.5, { style: "currency", currency: "BRL" })}</p> {/* "R$ 1.234,50" */}
<select value={locale} onChange={(event) => setLocale(event.target.value)}>
{availableLocales.map((code) => (
<option key={code} value={code}>
{code}
</option>
))}
</select>
</header>
);
}
When you only need t, use the useTranslate() shortcut — it avoids the destructuring:
import { useTranslate } from "tempest-react-sdk";
function NavHome() {
const t = useTranslate();
return <a href="/">{t("nav.home")}</a>;
}
useI18n requires the provider
Calling useI18n() (or useTranslate()) outside an <I18nProvider> throws useI18n must be used inside an <I18nProvider>. Keep the provider above any component that translates.
useOptionalI18n() for code that must work without a catalog
Returns null outside the provider instead of throwing. That is what a reusable piece uses when it wants to be translated where a catalog exists and still work where one does not — i18n is opt-in in this SDK, and demanding a provider would turn "you did not configure translations" into a crash. useDescribeApiError in the http module is exactly that case.
const i18n = useOptionalI18n();
const title = i18n?.t("dashboard.title") ?? "Dashboard";
Interpolation
Placeholders in the {name} form are replaced by the values in params. A missing key falls back to fallbackLocale; if it is missing there too, the helper returns the key itself (it never blanks out the screen). A placeholder with no value stays literal — {name} — making the forgotten string easy to spot.
t("greet", { name: "Ana" }); // "Olá, Ana"
t("greet"); // "Olá, {name}" (no params → literal placeholder)
t("missing"); // "missing" (no fallback → the key itself)
Default text per key
When a key exists in neither locale, t returns the key itself — great for spotting a forgotten string in development, terrible for the user, who reads cart.empty on screen. The third argument fixes that:
t("cart.empty", undefined, { default: "Your cart is empty" });
// catalog defined it → the translation
// catalog did not → "Your cart is empty"
The default is interpolated like any other message, so it carries placeholders:
t("cart.count", { n: 3 }, { default: "{n} items" }); // "3 items"
plural takes the same, after both suffixed lookups:
plural("boxes", 2, undefined, { default: "{count} boxes" }); // "2 boxes"
Do not detect the miss by comparing against the key
The tempting shape is const v = t(k); const text = v === k ? myDefault : v;.
That is wrong for a catalog which legitimately maps a key to itself —
{ "cart.empty": "cart.empty" }, which is what a machine-generated or
placeholder catalog produces — and there your default beats a translation that
was actually there.
Only the i18n layer knows whether there was a miss, because only it saw the
catalog. Pass default and let it answer. This is the path the SDK's own
internal strings take (tempest.error.offline, in useDescribeApiError).
Plurals
plural(key, count, params?) picks between ${key}_one (when count === 1) and ${key}_other (any other value), with {count} available for interpolation:
plural("alos", 1); // "1 Alô" → uses alos_one
plural("alos", 5); // "5 Alôs" → uses alos_other
Rich plurals? Reach for i18next
This _one / _other scheme covers PT-BR and EN. Languages with more plural categories (Russian, Polish, Arabic) need Intl.PluralRules — not worth reimplementing here. For those, switch to i18next directly. The SDK assumes the simple case on purpose.
Without React (imperative)
createI18n is the foundation; the provider just layers React state on top. Use it in utilities, tests or loaders outside the component tree:
import { createI18n } from "tempest-react-sdk";
import { messages } from "./i18n";
const i18n = createI18n({ locale: "pt-BR", fallbackLocale: "en", messages });
i18n.t("greet", { name: "Mau" }); // "Olá, Mau"
i18n.formatDate(new Date(), { dateStyle: "long" });
const en = i18n.withLocale("en"); // new I18n, same catalog
en.t("greet", { name: "Mau" }); // "Hi, Mau"
Dynamic loading
Load one JSON per locale and pass it to the provider. To code-split by route, swap messages via state — missing keys fall back while the rest loads:
const ptBR = await fetch("/i18n/pt-BR.json").then((response) => response.json());
Recap
- The catalog is a
{ locale: { key: "text" } }map — free-form keys, no schema. I18nProvidertakeslocale,fallbackLocaleandmessages; it writes<html lang>and persists tolocalStorage["tempest-locale"](disable withstorageKey={null}).useI18n()exposest,plural,formatNumber,formatDate,setLocale,locale,availableLocales;useTranslate()is thet-only shortcut.tinterpolates{placeholder}, falls back, and finally returns the key itself — it never breaks.pluraluses the_one/_othersuffixes; complex plurals call fori18next.createI18nruns outside React;withLocaleclones the object into another language.
See also
- Theme — combining with
<html lang>is free - App Providers — mounting i18n alongside Query, Theme and ErrorBoundary
- Utils —
formatCurrency/formatDatefor PT-BR directly