Docs

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 ?lang= query parameter (an explicit ask), then a cookie (a remembered choice), then Accept-Language (quality-ranked, with fr-CAfrbase-subtag fallback), else your default. The answer is always drawn from your locales allow-list - request input is matched, never echoed - so a hostile ?lang= can't reach the response. Resolve it in a loader and return just that locale's messages.

TS
// 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 }: { request: Request }) {
  const locale = negotiateLocale(request, { locales, defaultLocale: "en", queryParam: "lang", cookie: "lang" })
  return { locale, messages: catalogs[locale] }   // ?lang= → cookie → Accept-Language → default
}

On the server, localeDetector() (from @nifrajs/i18n/detector, needs @nifrajs/core) wraps the same negotiation as a plugin: handlers read c.locale/c.localeSource, responses carry Content-Language. With persist: true it writes the locale cookie only when an explicit ?lang= choice differs from the cookie - a header-derived guess is never pinned, and plain requests never grow a Set-Cookie, so responses stay cacheable. (@nifrajs/middleware's language() is the header-only sibling; use one or the other.)

TS
// Or as a server plugin: c.locale on every handler, Content-Language on every response.
import { server } from "@nifrajs/core"
import { localeDetector } from "@nifrajs/i18n/detector"

const app = server().use(localeDetector({
  locales: ["en", "fr", "de"],
  defaultLocale: "en",
  queryParam: "lang",
  cookie: "locale",
  persist: true,        // pin an explicit ?lang= choice into the cookie
})).get("/", (c) => c.json({ locale: c.locale, via: c.localeSource }))

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.

TS
// 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():

TS
// 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; use n()/d() for inline numbers/dates (no {n, number}skeletons). Intl.MessageFormat isn'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 agnostic createFormatter.