i18n
@nifrajs/i18n is framework-agnostic and dependency-free — locale negotiation plus a tiny ICU message formatter built on the platform Intl. It runs on every runtime; you bring JSON catalogs.
Negotiate the locale
negotiateLocale picks the best supported locale from a cookie (an explicit choice), then Accept-Language (quality-ranked, with fr-CA→frbase-subtag fallback), else your default. Resolve it in a loader and return just that locale's messages.
// In a loader: resolve the locale + return only that catalog's messages.
import { negotiateLocale } from "@nifrajs/i18n"
import { catalogs, locales } from "../catalogs"
export async function loader({ request }) {
const q = new URL(request.url).searchParams.get("lang")
const locale = q && locales.includes(q) ? q : negotiateLocale(request, { locales, defaultLocale: "en", cookie: "lang" })
return { locale, messages: catalogs[locale] } // cookie → Accept-Language → default
}Format messages
createFormatter(locale, messages) → { t, n, d }. thandles interpolation ({name}), plural (with =N exact cases and # → the number) and select, nested — via a hand-written parser + Intl.PluralRules. n/d are memoized Intl.NumberFormat/DateTimeFormat. A missing key returns the key.
// catalogs: plain JSON per locale (ICU strings). Bring your own.
export const catalogs = {
en: { greeting: "Hello, {name}!", cart: "{count, plural, =0 {empty} one {# item} other {# items}}" },
fr: { greeting: "Bonjour, {name} !", cart: "{count, plural, =0 {vide} one {# article} other {# articles}}" },
}In React, provide it once and read it with useT():
// The page provides the formatter; components read it with useT().
import { I18nProvider, useT } from "@nifrajs/web-react/i18n"
export default function Page({ data }) {
return <I18nProvider locale={data.locale} messages={data.messages}><Body/></I18nProvider>
}
function Body() {
const { t, n, d } = useT()
return <>
<p>{t("greeting", { name: "Ada" })}</p>
{/* ICU plural with # substitution */}
<p>{t("cart", { count: 3 })}</p> {/* "3 items in your cart" */}
<p>{t("price", { amount: n(1299.99, { style: "currency", currency: "EUR" }) })}</p>
<p>{d(Date.now(), { dateStyle: "long" })}</p>
</>
}Both locale and messages are serializable, so SSR renders the negotiated catalog and the client rebuilds the same formatter on hydrate — no mismatch. Switching language re-navigates (a cookie or ?lang=); the loader returns the new catalog and the page re-renders.
Notes
- For many locales, load catalogs lazily per request — don't bundle every catalog.
- The supported ICU subset is interpolation +
plural/select; usen()/d()for inline numbers/dates (no{n, number}skeletons).Intl.MessageFormatisn't widely available yet, so this is the portable core. <I18nProvider>+useT()ship for all five adapters(React, Preact, Vue, Solid, Svelte) — import from@nifrajs/web-<framework>/i18n; each is a thin binding over the agnosticcreateFormatter.