Docs

i18n

@nifrajs/i18n is framework-agnostic and dependency-free - one locale registry, locale-prefixed routing, locale negotiation and a tiny ICU message formatter built on the platform Intl. It runs on every runtime; you bring the catalogs.

Declare your locales

defineLocales is the one place a locale is declared: its URL segment, the BCP-47 tag Intl formats with, the hreflang value search engines match, its writing direction and its own name for a language switcher. Each defaults from the segment, and the direction is derived from the tag (an explicit script, a right-to-left language such as Urdu, Arabic or Hebrew, or the runtime's likely script). A draft locale keeps its catalogs but is never served, so a half-translated language cannot be indexed under its own URL. Bad tags, unsafe segments and two locales sharing an hreflang throw at definition.

TS
// shared/i18n.ts - each locale declared once; routing, alternates and <html lang>/<html dir> read it.
import { defineLocales } from "@nifrajs/i18n"
import { defineI18nRouting } from "@nifrajs/i18n/routing"

export const locales = defineLocales({
  default: "en",
  locales: {
    en: { hreflang: "en-IN" },
    hi: { hreflang: "hi-IN" },
    ur: { tag: "ur-PK", hreflang: "ur" }, // dir "rtl", derived from the tag
    gu: { draft: true }, // catalogs in progress: never routed, never an alternate
  },
})
export const urls = defineI18nRouting(locales, {
  // The pages that exist in some locales only, declared once; undefined = every served locale.
  pageLocales: (pathname) => (pathname.startsWith("/guides/") ? ["en", "hi"] : undefined),
})

urls.localizePathname("/kundli", "hi") // "/hi/kundli"
urls.localizePathname("/guides/setup", "ur", { fallback: "default" }) // "/guides/setup"
urls.localesOf("/hi/guides/setup") // ["en", "hi"]
locales.chain("hi") // ["hi", "en"] - the catalog fallback order

Locale-prefixed URLs

@nifrajs/i18n/routing binds the registry to a URL scheme where the locale is the first path segment and the default is unprefixed (or prefixed too, with prefixDefaultLocale). A [lang] route segment matches any string, so guard it: matchSegment answers not-found for an unknown or draft value and a redirect for the default's prefix or a wrong case, and the middleware export of a _layout.backend.ts in the [lang] directory turns that into a 404 through your _404 page or a permanent redirect - on full page loads, client navigations and form posts alike.

TS
// routes/[lang]/_layout.backend.ts - 404 unknown and draft locales, redirect /en/... to /...
import { defineLocales } from "@nifrajs/i18n"
import { defineI18nRouting } from "@nifrajs/i18n/routing"
import { notFound, type RouteMiddleware, redirect } from "@nifrajs/web"

const urls = defineI18nRouting(
  defineLocales({ default: "en", locales: { en: {}, hi: {}, gu: { draft: true } } }),
)

export const middleware: RouteMiddleware = (ctx) => {
  const { pathname, search } = new URL(ctx.request.url)
  const match = urls.matchSegment(ctx.params.lang, pathname + search)
  if (match.kind === "not-found") notFound()
  if (match.kind === "redirect") return redirect(match.location, { status: 308 })
  return undefined
}

alternates(path, { origin, locales }) gives the page's canonical URL and its hreflang links, built exactly as localizePathname builds them. Without origin the URLs are root-relative, for a language switcher. A page that exists in only some languages passes those locales; the links come out in registry order, so every page of the cluster lists the same set and search engines accept it. A redirect or link it builds can never start with //.

TS
// routes/[lang]/kundli.tsx - canonical + hreflang links and the document language.
import { defineLocales } from "@nifrajs/i18n"
import { defineI18nRouting } from "@nifrajs/i18n/routing"
import type { Meta, MetaArgs } from "@nifrajs/web"

const urls = defineI18nRouting(
  defineLocales({ default: "en", locales: { en: {}, hi: { hreflang: "hi-IN" }, ur: { tag: "ur-PK" } } }),
)

export function meta({ params, origin }: MetaArgs): Meta {
  const lang = params.lang ?? ""
  const locale = urls.locales.isServed(lang) ? lang : urls.locales.default
  // Every page of this cluster passes the same locales, so the alternates stay reciprocal.
  const { canonical, links } = urls.alternates(urls.localizePathname("/kundli", locale), { origin })
  return {
    ...urls.locales.documentMeta(locale),
    link: [
      { rel: "canonical", href: canonical },
      ...links.map((link) => ({ rel: "alternate", hreflang: link.hreflang, href: link.href })),
    ],
  }
}

A page that exists in some languages only is declared once, with pageLocales (given the path without its locale prefix; it must be pure). Then matchSegment answers not-found for the other locales, alternates lists only the page's own (none for a page in one locale), localesOf(path) names them for a prerender's static paths, and localizePathname(path, locale, { fallback: "default" }) links a menu entry to the default locale's page where the page is missing. A pageLocales that throws, or names a locale the registry does not declare, throws rather than serving every locale.

Register the routing once

Export the router as i18n from backend/framework.ts (re-export it from nifra.config.ts when both exist), or pass it to a hand-written createWebApp({ i18n }). Every document then takes its locale from the URL:

  • <html lang dir> come from documentMeta(localeOf(path)), unless the route's meta sets lang, on a hard load and after each client navigation alike.
  • The response carries Content-Language.
  • Each adapter's useLocale() names the page's locale key, and <I18nProvider> formats in its tag when no locale is passed; no loader has to hand either over.
  • A route under a [lang] or [[lang]] directory runs matchSegment before any middleware: an unserved locale, or a page missing from it, is a 404 through your _404, and a non-canonical prefix (/en/x when the default is unprefixed) is a 308 to the canonical URL. The guard middleware above is then only needed for checks of your own.
TS
// doc-check: skip - imports the app's own routing module.
// backend/framework.ts - nifra dev, nifra build and the generated server entry read it from here.
import { urls } from "../shared/i18n"

export const i18n = urls

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-CA→frbase-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
// routes/index.backend.ts - resolve the locale + return only that catalog's messages.
import { negotiateLocale } from "@nifrajs/i18n"
import { t } from "@nifrajs/schema"
import { catalogs, locales } from "../shared/catalogs"
import type { Route } from "./+types/index"

// A catalog is open-ended, so its schema is the explicit opt-out of the strict default.
export const loaderOutput = t.object({ locale: t.string(), messages: t.looseObject({}) })
export async function loader({ request }: Route.LoaderArgs) {
  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, options) → { t, get, n, d }. t handles interpolation ({name}), plural (with =N exact cases), selectordinal (1st, 2nd, 3rd) and select, nested - via a hand-written parser + Intl.PluralRules. Inside a plural, # is the number in the locale's own format. n/d are memoized Intl.NumberFormat/DateTimeFormat.

A catalog can nest: values are messages, lists or blocks, read with dotted keys (t("home.title")); a flat key containing a dot is still found first. get(key) returns a list or block whole, for an FAQ or a page's copy. A key the catalog lacks is tried in each fallback catalog in order (locales.chain(locale) gives fr-CA → fr → the default); only when none has it does onMissing fire, once per key, and t return the key. timeZone and numberingSystem set defaults for every d(), n() and #. Formatters are cached per catalog and options, so pass a stable onMissing.

TS
// shared/messages/en.ts - the default locale's catalog: ICU strings, lists and nested blocks.
import { createFormatter, type PartialMessages } from "@nifrajs/i18n"

export const en = {
  cart: "{count, plural, =0 {empty} one {# item} other {# items}}",
  place: "You finished {rank, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}",
  home: { title: "Welcome back, {name}" },
  faq: [{ q: "Is it free?", a: "Yes." }],
}

// Another locale is partial; a key it lacks falls back to the next catalog.
export const fr: PartialMessages<typeof en> = {
  cart: "{count, plural, =0 {vide} one {# article} other {# articles}}",
}

const t = createFormatter<typeof en>("fr", fr, {
  fallback: [en],
  onMissing: (key, locale) => console.warn(`missing ${locale} translation: ${key}`),
  timeZone: "Europe/Paris",
})
t.t("cart", { count: 1200 }) // "1 200 articles"
t.t("home.title", { name: "Ada" }) // "Welcome back, Ada", from en
const questions = t.get("faq")?.map((item) => item.q)
console.log(questions)

To type every key once, register the default catalog. A typo in t() is then a compile error everywhere, and other locales' catalogs (typed Translation) are checked against its shape.

TS
// doc-check: skip - declares the app-wide catalog type, which would retype every other sample here.
// shared/register.ts - once per app: every t() key, useT() included, and every other catalog is checked.
import type { en } from "./messages/en"

declare module "@nifrajs/i18n" {
  interface Register {
    messages: typeof en
  }
}

In React, provide it once and read it with useT():

TS
// The page provides the formatter; components read it with useT().
import { localeCookie } from "@nifrajs/i18n"
import { I18nProvider, useT } from "@nifrajs/web-react/i18n"

export default function Page({ data }) {
  // data.fallback: the catalogs locales.chain(locale) names after the page's own, from the loader.
  return (
    <I18nProvider locale={data.locale} messages={data.messages} fallback={data.fallback}>
      <Body />
    </I18nProvider>
  )
}

function Body() {
  const { t, get, n, d } = useT()
  return <>
    <p>{t("home.title", { name: "Ada" })}</p>
    <p>{t("cart", { count: 1200 })}</p>          {/* "1,200 items" - # in the locale's format */}
    <p>{t("place", { rank: 2 })}</p>             {/* "You finished 2nd" */}
    <p>{n(1299.99, { style: "currency", currency: "EUR" })}</p>
    <p>{d(Date.now(), { dateStyle: "long" })}</p>
    <ul>{get("faq")?.map((item) => <li key={item.q}>{item.q}</li>)}</ul>
    {/* Same cookie name and attributes as localeDetector({ persist: true }) */}
    <button onClick={() => { document.cookie = localeCookie("locale", "hi"); location.reload() }}>
      हिन्दी
    </button>
  </>
}

In an app that registers its routing, locale may be left out: the provider formats in the page's locale, and useLocale() reads the page's locale key anywhere below the route. locale, messages and fallback are serializable, so SSR renders the negotiated catalogs 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. localeCookie(name, locale) builds the detector's own cookie for a switcher, so a choice made in the page and one made through ?lang= are the same cookie.

Copy tables

A catalog keys every message; a copy table keeps a page's text and its translations beside the code that shows it, with no key to name. defineCopy(locales) from @nifrajs/i18n/copy binds tables to the registry: the default locale's value is required, a locale the registry does not declare is a type error, and every other value has the default's shape - a list its length, a block its keys. Values are data (messages, lists, blocks); a message that takes values is ICU, read with copy.format().

TS
// shared/copy.ts - copy tables: the text and its translations beside the code that shows it.
import { defineLocales } from "@nifrajs/i18n"
import { defineCopy } from "@nifrajs/i18n/copy"

export const locales = defineLocales({ default: "en", locales: { en: {}, hi: {}, ta: {} } })
export const copy = defineCopy(locales, {
  onMissing: ({ id, locale, fallback }) => console.warn(`${id ?? "a table"}: no ${locale}, showing ${fallback}`),
})

// The default locale's value is required; every other locale is optional and shaped like it.
export const TITLE = copy({ en: "Bicycle", hi: "साइकिल", ta: "மிதிவண்டி" })
export const FAQ = copy.id("shop.faq", { en: ["Brakes?", "Gears?"], hi: ["ब्रेक?", "गियर?"] })
export const PEDALS = copy({ en: "{n, plural, one {# pedal} other {# pedals}}", hi: "{n} पैडल" })

copy.pick(TITLE, "ta") // "மிதிவண்டி"
copy.pick(FAQ, "ta") // ["Brakes?", "Gears?"] - the en value; onMissing fires once for ta
copy.format(PEDALS, "en", { n: 3 }) // "3 pedals"

copy.pick(table, locale) walks locales.chain(locale), and onMissing fires once per table and locale that falls back. A table's id is its file:line, or the one copy.id() names.

In a production client build, each table's values move out of the bundle into one chunk per locale, so a page's JavaScript carries the copy of the locale it renders in and none of the others; a chunk holds the fallback's value wherever its locale has none. The server bundle keeps every table whole. A page loads the chunk of its URL's locale (the first path segment when it is a served locale, or what defineCopy(locales, { localeOf }) returns) and of the locale its <html lang> names, both preloaded; a client navigation loads the next URL's chunk with the route's code.

TS
// doc-check: skip - continues a hand-written server.ts (adapter, manifest).
// nifra build wires this itself; a server entry you write passes the client build's copyChunks.
const assets = JSON.parse(await Bun.file("dist/manifest.json").text())
export const app = createWebApp({
  adapter, manifest, clientEntry: assets.entry,
  copyChunks: assets.copyChunks, // locale -> chunk URL; each page preloads its own
})
  • The build reads tables statically: string literals, lists and blocks of them, and constants the same module declares. A table built any other way, or written in a .svelte or .vue file, keeps every locale in the bundle and still works; nifra i18n check says which tables those are.
  • The build reads the source with the app's TypeScript (5.9 or 6). Without its compiler API (TypeScript 7 ships none) the build warns and keeps tables whole. It adds about 1 ms per module to a production build.
  • A server entry that calls createWebApp with a clientEntry and no copyChunks passes nifra dev, which never splits, and fails on the first production page that renders copy. nifra check reports that call as NF-C044; the generated server manifest exports copyChunks for it to import, and // nifra-expect NF-C044: <reason> on the call accepts a server that never serves those pages.

Rich text

rich(t, key, tags, vars) renders a message's tags with your components, never with HTML. A tag is a bare name - <b>…</b> or <icon/>, no attributes - so a translation, whoever or whatever wrote it, decides where emphasis or a link goes, and your handler decides what it is and where it points. A tag with no handler renders its content as plain text, an unclosed or stray marker stays literal text, <br/> is a line break unless you pass br, and interpolated values are always text, so a value containing <b> cannot open a tag. t() is unchanged and returns the markers as written.

TS
// shared/messages/en.ts: terms: "Read the <link>terms</link> and <b>privacy notice</b>,<br/>{name}."
import { rich, useT } from "@nifrajs/web-react/i18n"

function Terms({ name }: { name: string }) {
  const t = useT()
  return <p>{rich(t, "terms", {
    link: (content) => <a href="/terms">{content}</a>,
    b: (content) => <strong>{content}</strong>,
  }, { name })}</p>
}

React, Preact, Solid and Vue export rich() from their /i18n entry, returning one node (each handler gets its tag's content as one node too). Svelte has <Rich>, which takes one snippet per tag:

SVELTE
<!-- Svelte: each tag is a snippet that renders its content -->
<script>
  import { Rich } from "@nifrajs/web-svelte/i18n"
  let { name } = $props()
</script>

{#snippet link(content)}<a href="/terms">{@render content()}</a>{/snippet}
{#snippet b(content)}<strong>{@render content()}</strong>{/snippet}
<p><Rich key="terms" tags={{ link, b }} vars={{ name }} /></p>

The framework-free form is rich(formatter, key, tags, vars) from @nifrajs/i18n/rich: it returns the message as an array of strings and whatever your handlers returned, for building any other output.

Check your catalogs

nifra i18n check imports the module that exports your locales and catalogs (unlike nifra check, it runs that code) and checks every catalog the way t() reads it:

  • Coverage per locale, counting messages a regional locale inherits through chain(); each missing key, and each unused key the default catalog does not have.
  • ICU syntax, and placeholder and tag parity with the default message: a translation that drops {name} or <link> loses it, one that adds a placeholder renders it empty.
  • Plural cases: a missing other, and categories the locale's grammar uses (Russian few/many, Arabic zero/two) that a message never states.
  • Script purity: letters from a script the locale does not write in - a Telugu sign in a Gujarati word, a Cyrillic е in English - from Intl.Locale(tag).maximize().script, and, as a warning, words mixing Latin with the locale's script. Latin words (brands, units) stay allowed.
  • Untranslated messages identical to the default in another language, as a warning.
  • Partly translated text in a locale that does not write in Latin: a message of 6 words or more that is over 60% Latin-script words (latin-share; words split by Intl.Segmenter, so Japanese counts), with each locale's share in the coverage table and a warning above 10%; and a message of 80 graphemes or more translated to under 32% of its length (volume), a summary rather than a translation. Han scripts, which write a sentence in far fewer graphemes, are left out of volume. Every threshold is an option.
  • Your rules: each of rules runs on every message of every locale, and its findings read custom:<code>, which ignore takes.
TS
// doc-check: skip - imports the app's own catalog files.
// shared/i18n.ts - the module `nifra i18n check` imports.
import { defineLocales } from "@nifrajs/i18n"
import en from "./messages/en.json"

export const locales = defineLocales({
  default: "en",
  locales: { en: {}, hi: {}, gu: { draft: true } },
})
export const catalogs = {
  en,
  hi: () => import("./messages/hi.json"),
  gu: () => import("./messages/gu.json"),
}
// Keys a check skips: exact, a prefix ending in ".*", or "*".
export const ignore = { script: ["languages.*"], untranslated: ["brand"] }
// The rest of checkCatalogs()'s options: Latin words a translation keeps, and your own rules.
export const check = {
  latinWords: ["PDF", "OK"],
  rules: [
    ({ message }: { readonly message: string }) =>
      message === message.trim() ? undefined : { code: "spacing", message: "starts or ends with a space" },
  ],
}

When the module also exports a copy from defineCopy() (or only that), the check finds its tables across the project's source, turns them into a catalog per locale and runs the same checks on them, reporting each at its file:line. Two tables with one id are an error (a production build refuses them too); a table it cannot read is a warning that says why.

It exits 1 on an error, and with --strict on a warning too; --json prints the result. Draft locales report missing keys as info. The checks themselves are checkCatalogs() from @nifrajs/i18n/check:

TS
// Or as a test, with no CLI: the same checks, pure.
import { expect, test } from "bun:test"
import { defineLocales } from "@nifrajs/i18n"
import { checkCatalogs } from "@nifrajs/i18n/check"

const locales = defineLocales({ default: "en", locales: { en: {}, gu: {} } })

test("catalogs are consistent", () => {
  const { findings } = checkCatalogs({
    locales,
    catalogs: { en: { hi: "Hi {name}" }, gu: { hi: "નમસ્તે {name}" } },
  })
  expect(findings.filter((finding) => finding.severity === "error")).toEqual([])
})

Notes

  • For many locales, load catalogs lazily per request - don't bundle every catalog. A loader that does await import(`../shared/messages/${locale}.ts`) ships only the requested locale's table to the page, typed inline tables included.
  • The supported ICU subset is interpolation + plural/selectordinal/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.