Internationalization

@silgi/i18n is the first-party i18n module. It copies your pages per locale, translates messages with an ICU MessageFormat subset that is compiled at build time, and gives every UI library the same useI18n() and useLocale(). It has no dependency. Install it next to silgi:

bun add @silgi/i18n

One rule shapes everything: a page's locale comes from its URL only. /fr/a-propos is French and /about is English, always. The cookie and the Accept-Language header do one job: they choose where a visitor to / is sent. So every page has one language, a search engine can crawl and cache it, and route rules and the CDN keep working.

Set it up

Add the module and set its options under the i18n key:

import { defineConfig } from "silgi";

export default defineConfig({
  framework: "react",
  modules: ["@silgi/i18n"],
  i18n: {
    locales: ["en", { code: "fr", language: "fr-FR" }, { code: "ar", language: "ar-EG" }],
    defaultLocale: "en",
    strategy: "prefix_except_default",
    baseUrl: "https://example.com",
    detect: { cookie: "silgi_locale", redirect: "root" },
    pages: { about: { fr: "/a-propos", ar: "/hawl" } },
    timeZone: "UTC",
    fallback: "default",
  },
});

defaultLocale is an entry of locales. Messages live in locales/<code>.json in the app root (and in each layer):

{
  "home": { "title": "Home", "hello": "Hello {name}" },
  "cart": { "items": "{count, plural, =0 {Your cart is empty} one {# item} other {# items}}" },
  "posts": { "at": "Posted {at, date, long}" },
  "status": {
    "pick": "{s, select, open {Open} closed {Closed} other {Unknown}}",
    "open": "Open",
    "closed": "Closed"
  },
  "api": { "saved": "Saved" },
  "error": { "notFound": "Page not found", "failed": "Something went wrong" }
}
{
  "home": { "title": "Accueil", "hello": "Bonjour {name}" },
  "cart": { "items": "{count, plural, =0 {Votre panier est vide} one {# article} other {# articles}}" },
  "posts": { "at": "Publié {at, date, long}" },
  "status": {
    "pick": "{s, select, open {Ouvert} closed {Fermé} other {Inconnu}}",
    "open": "Ouvert",
    "closed": "Fermé"
  },
  "api": { "saved": "Enregistré" },
  "error": { "notFound": "Page introuvable", "failed": "Une erreur est survenue" }
}

Use them in any page:

import { useI18n } from "@silgi/i18n/react";

export default function Cart() {
  const { t, n, d } = useI18n();
  return (
    <main>
      <h1>{t("home.hello", { name: "Ada" })}</h1>
      <p>{t("cart.items", { count: 3 })}</p>
      <p>{n(1234.5, { style: "currency", currency: "EUR" })}</p>
      <p>{d(new Date(), { dateStyle: "long" })}</p>
      <p>{t("status.pick", { s: "open" })}</p>
    </main>
  );
}

Options

KeyTypeDefaultMeaning
locales(string | { code, language?, dir?, name? })[]requiredcode is the URL prefix and the route-name suffix. language (default code) is the BCP 47 tag for Intl, lang and hreflang. dir defaults to the language's text direction. name defaults to the language's own name for itself ("English", "français (France)"), for a language switcher
defaultLocalestringrequiredone of the codes
strategy"prefix_except_default" | "prefix""prefix_except_default"see Strategies
baseUrlstring | { runtimeConfig: string }nonethe site origin for hreflang and canonical links; see Head and SEO
detect.cookiestring | false"silgi_locale"the cookie the browser writes when a navigation changes the locale and the root redirect reads. false writes and reads none
detect.redirect"root" | false"root"whether GET / redirects; see The root redirect
pagesRecord<string, Partial<Record<string, string>>>{}localized paths by route name and locale code
timeZonestring"UTC"the time zone of every d() and every date and time argument, on the server and in the browser
fallback"default" | false"default"what a key missing in a locale gives: the default locale's message, then the key; or, with false, the key

A wrong value is a build error that names the key. silgi build and generate exit with 1, and silgi dev shows it in the overlay:

ValueMessage
locales: []i18n.locales must list at least one locale
a code that is not lower-case letters, digits and -i18n.locales[1].code "FR" must be lower-case letters, digits and "-" (like "fr" or "pt-br")
a code twicei18n.locales: "fr" is listed twice
a language that is not a BCP 47 tagi18n.locales[1].language "fr_FR" is not a BCP 47 language tag
defaultLocale that is not a codei18n.defaultLocale "de" is not in i18n.locales (en, fr, ar)
strategy: "no_prefix" or "prefix_and_default"i18n.strategy "no_prefix" is not supported; use "prefix_except_default" or "prefix"
a baseUrl that is not an http: or https: origini18n.baseUrl "example.com" must be an origin such as "https://example.com" (app.baseURL adds the path)
a timeZone the runtime does not knowi18n.timeZone "Mars/Base" is not a time zone this runtime knows
a detect.cookie that is not a cookie namei18n.detect.cookie "a b" is not a valid cookie name

Strategies and route copies

The module copies your pages with the extendPages kit function, so a silgi dev rescan copies again. For each page and each locale of the copy set it adds a route named <name>___<code> whose path is /<code> plus the path, with the same file and loader. app/pages/about.tsx and the locale fr give about___fr at /fr/about. Layouts, route middleware and meta apply to a copy as to its original. Typegen lists the copies in SilgiRoutes because they are routes.

StrategyCopy setThe original routes/
prefix_except_defaultevery locale but the defaultstay, as the default locale's routesthe default locale's home page, unless the redirect sends the visitor on
prefixevery localeare removed, so /about matches no pageanswered by the root redirect

Nested routes are copied the same way: each child gets its own suffixed name and its own localized full path. useRoute().name of a copy is about___fr, and routeBaseName("about___fr") gives about. A route name that already contains ___ is a build error, because copies use it.

no_prefix (a locale outside the URL) and prefix_and_default (the default locale at two URLs) are not in v1.

Localized paths with pages

pages maps a route name and a locale code to the path in that locale. It is config and not page meta, because meta is read when the page loads and the copies are made at build time.

i18n: { pages: { about: { fr: "/a-propos", ar: "/hawl" } } }

about___fr is then served at /fr/a-propos and about___ar at /ar/hawl. A path keeps the params of the original (/blog/:slug may become /article/:slug, not /article/:id). Under prefix_except_default a pages entry for the default locale replaces the original route's path. These are build errors:

CaseMessage
a key that is not a route namei18n.pages: no route named "abuot"
a code that is not a localei18n.pages.about: "de" is not in i18n.locales
a path that does not start with /i18n.pages.about.fr "a-propos" must be a route path starting with "/"
different paramsi18n.pages.blog-slug.fr "/article/:id" must keep the params of "/blog/:slug" (slug)
a copy with the shape of another routei18n: route copy "/fr/about" (about___fr) clashes with "/fr/about" (fr-about, app/pages/fr/about.tsx)

Link is not localized: its href is the core's. Build the target with localePath(to) and switchLocalePath(code) from useI18n():

  • localePath("/about") is the same page in the current locale, with the same params, query and hash. On a French page it gives /fr/a-propos. A path that no page matches (a server route, a file) comes back unchanged. A route location takes a base name: localePath({ name: "blog-slug", params: { slug: "x" } }).
  • switchLocalePath("ar") is the current route in Arabic. Switching the locale is a navigation: put the result in Link's to.

An unknown locale code throws i18n: "de" is not in i18n.locales, and an unknown route name throws i18n: no route named "x".

The root redirect

When detect.redirect is "root", a GET or HEAD of / and of nothing else is handled by a server middleware. It chooses a locale, first match wins:

  1. the cookie detect.cookie, when its value is a configured code;
  2. the Accept-Language header: the ranges in q order, each matched to a locale whose language or code is equal, or else to the first locale whose language subtag is equal.

With prefix_except_default, a target that is not the default answers 302 to /<code> (the query is kept). Otherwise the request goes on to the home page of the default locale. With prefix, a target answers 302 to its root. With no target, the answer is the locale root page: a 200 page with a <meta http-equiv="refresh"> to the default root, canonical and one link per locale, and a small inline script that reads the cookie and then navigator.languages and calls location.replace. This is what a crawler and the prerender get, since neither sends a cookie or Accept-Language.

Why only /. Googlebot sends no Accept-Language header, and a page that answered differently by header would show crawlers one language and visitors another, and could not be cached. Because only / depends on the visitor, every other URL is one language and one document. Every answer of this middleware carries Vary: Cookie, Accept-Language and Cache-Control: private, no-store, so the anonymous page cache never stores a redirect.

The browser writes the cookie, with Max-Age of one year and SameSite=Lax, when a navigation changes the locale. The server never writes it. Set detect.redirect: false to turn the redirect off.

A page written as a static file is served before middleware. If / is prerendered while detect.redirect is "root" under prefix_except_default, the build warns once: [silgi] i18n: "/" is prerendered, so detect.redirect "root" never runs for it.

Messages

locales/<code>.json is a JSON object. Nested objects are flattened with ., so { "home": { "title": "Home" } } is the key home.title. A file in the app wins over a file of a layer, and an earlier layer over a later one, key by key. A locale with no file anywhere warns [silgi] i18n: no locales/fr.json in the app or a layer; every key of fr falls back.

These are build errors that name the file and the key: a value that is neither a string nor an object, a key that contains . or is empty, and two entries that flatten to one key.

The message format

Messages are a subset of ICU MessageFormat 1, compiled at build time to functions. The locale's language decides the plural rules.

SyntaxMeaning
text, '', '{…}'literal text; '' is '; text between ' quotes is literal
{name}an argument: a number is formatted with n(), a Date with d(), anything else with String()
{n, number}, {n, number, integer}, {n, number, percent}, {n, number, ::currency/EUR}a number
{d, date}, {d, date, short|medium|long|full}, {d, time, …}a date or a time; the default style is medium
{n, plural, offset:1 =0 {…} one {# …} other {…}}a plural; # is n - offset, formatted as a number
{n, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}an ordinal plural
{x, select, a {…} other {…}}a choice by string

Plural keys are =<integer> and the CLDR categories zero, one, two, few, many and other; =N wins over a category, and other is required. A # outside a plural is text.

{
  "rate": "{ratio, number, percent} done",
  "price": "{amount, number, ::currency/EUR}",
  "when": "{at, date, short} at {at, time, short}",
  "rank": "{n, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}",
  "inbox": "{count, plural, offset:1 =0 {Nobody wrote} =1 {{name} wrote} one {{name} and # other wrote} other {{name} and # others wrote}}",
  "quote": "Use '{'braces'}' and it''s fine"
}

A syntax error is a build error with the column: locales/fr.json "cart.items": plural needs an "other" case (column 14). The reasons are an unclosed { or }, an unknown type such as choice, an unknown style or a number skeleton other than ::currency/<code>, a duplicate case, offset outside a plural, a missing other, and a plural key that is neither =N nor a category. A category the language never selects is a warning: locales/en.json "x": plural case "few" never applies to en.

Chunks and the fallback

Each locale is one client chunk. The server bundle carries all of them. The browser loads the chunk of the page's locale before it hydrates, so a translation never travels in the payload, and the server writes a modulepreload link for that chunk. A client navigation to another locale loads the other chunk once, before the page renders. With fallback: "default", the chunk of a locale is built with the default locale's message for every key it lacks, so the server and the browser render the same text and no second chunk loads.

In silgi dev, a missing key and a missing argument are logged once per key and locale ([silgi] i18n: missing "cart.items" in fr, [silgi] i18n: "home.hello" in fr needs the argument "name"); a missing argument renders as {name}. Production logs nothing. Editing a locales/*.json shows in an open page within two seconds. A file that is the lower layer of the same locale, or created after the start, shows after a restart.

Formatting numbers and dates

n(value, options?) is Intl.NumberFormat and d(value, options?) is Intl.DateTimeFormat, both for the page's language, with the formatters cached per language and options. d() and the date arguments of messages use i18n.timeZone unless the call's options set one. This is not optional: workerd runs with the UTC zone, so a server and a browser in different zones would render different text and hydration would fail.

The time zone is not the only difference. Intl text comes from the ICU data each runtime carries, so a server and a browser can print the same date or number differently: Bun on macOS uses Apple's ICU, which writes Jan 3, 2026 at 12:30 PM where Chrome writes Jan 3, 2026, 12:30 PM. So while the page hydrates, the browser reuses the server's text. Every n(), d(), date or number argument and plural category the page render prints travels in the payload (payload.modules.i18n), including text inside a Suspense boundary that streams in after the shell, and the browser's n(), d() and t() read it first. Middleware, loaders and plugins format without recording, and an error page sends only its own text. It stays for re-renders of that page and for router.refresh(), and is dropped at the first client navigation; from there the browser formats with its own Intl, so all the text on one page comes from one ICU. This covers n(), d() and t() only: Intl or toLocaleString() called in a component formats on each side with its own data, so use n() and d() for text that hydrates.

In components

Every library's adapter entry, @silgi/i18n/<library>, exports useI18n() and useLocale() with the same names. The locale is computed from the route's path, so the route, which is already reactive in every library, is the only store, and no component remounts when a navigation changes the locale.

const { t, n, d, localePath, switchLocalePath, routeBaseName } = useI18n();
const locale = useLocale(); // { code, language, dir, name, locales }

locale.locales is every configured locale, for a language switcher. The six libraries differ only in the shape of the value:

LibraryuseI18n()useLocale()
React, Preactan object, the same while the locale code is the samethe locale
Vue{ locale: ComputedRef, t, n, d, … }; each function reads locale.value, so a template re-rendersComputedRef of the locale
Sveltean object with a locale getter; each function reads the route when called, so markup re-runsan object with getters
Solid{ locale: Accessor, t, n, d, … }; each function reads the accessorAccessor of the locale
htmlan object for this renderthe locale; t, n and d return HTML-escaped text (&, <, >, ", ')

A layout with a language switcher in each library:

import { Link, Outlet } from "@silgi/react";
import { useI18n, useLocale } from "@silgi/i18n/react";

export default function App() {
  const { switchLocalePath, localePath } = useI18n();
  const { code, locales } = useLocale();
  return (
    <div>
      <nav>
        <Link to={localePath("/about")}>About</Link>
        {locales.map((locale) => (
          <Link key={locale.code} to={switchLocalePath(locale.code)} aria-current={locale.code === code ? "true" : undefined}>
            {locale.name}
          </Link>
        ))}
      </nav>
      <Outlet />
    </div>
  );
}

Preact:

import { Link, Outlet } from "@silgi/preact";
import { useI18n, useLocale } from "@silgi/i18n/preact";

export default function App() {
  const { switchLocalePath, t } = useI18n();
  const { locales } = useLocale();
  return (
    <div>
      <h1>{t("home.title")}</h1>
      <nav>
        {locales.map((locale) => (
          <Link key={locale.code} to={switchLocalePath(locale.code)}>
            {locale.name}
          </Link>
        ))}
      </nav>
      <Outlet />
    </div>
  );
}

Solid:

import { For } from "solid-js";
import { Link, Outlet } from "@silgi/solid";
import { useI18n } from "@silgi/i18n/solid";

export default function App() {
  const { locale, switchLocalePath, t } = useI18n();
  return (
    <div>
      <h1>{t("home.title")}</h1>
      <nav>
        <For each={locale().locales}>{(item) => <Link to={switchLocalePath(item.code)}>{item.name}</Link>}</For>
      </nav>
      <Outlet />
    </div>
  );
}

Vue:

<script setup lang="ts">
import { Link, Outlet } from "@silgi/vue";
import { useI18n } from "@silgi/i18n/vue";

const { locale, switchLocalePath, t } = useI18n();
</script>

<template>
  <div>
    <h1>{{ t("home.title") }}</h1>
    <nav>
      <Link v-for="item in locale.locales" :key="item.code" :to="switchLocalePath(item.code)">{{ item.name }}</Link>
    </nav>
    <Outlet />
  </div>
</template>

Svelte:

<script lang="ts">
  import { Link, Outlet } from "@silgi/svelte";
  import { useI18n } from "@silgi/i18n/svelte";

  const { locale, switchLocalePath, t } = useI18n();
</script>

<div>
  <h1>{t("home.title")}</h1>
  <nav>
    {#each locale.locales as item (item.code)}
      <Link to={switchLocalePath(item.code)}>{item.name}</Link>
    {/each}
  </nav>
  <Outlet />
</div>

html:

import { Link, Outlet } from "@silgi/html";
import { useI18n, useLocale } from "@silgi/i18n/html";

export default function App(): string {
  const { switchLocalePath, t } = useI18n();
  const { locales } = useLocale();
  const nav = locales.map((locale) => Link({ to: switchLocalePath(locale.code), children: locale.name })).join("");
  return `<div><h1>${t("home.title")}</h1><nav>${nav}</nav>${Outlet()}</div>`;
}

Calling useI18n() without the module throws useI18n() needs the "@silgi/i18n" module: app.i18n is missing.

The server

The module adds a request middleware to the middleware lists, ahead of the app's own entries (a module's list runs before the app's), that sets event.context.locale for every request. First match wins:

  1. the first segment of the path below app.baseURL, when it is a locale that has route copies;
  2. ?locale=<code>;
  3. the cookie, when detect.cookie is not false;
  4. Accept-Language;
  5. the default locale.

For a page request the module's route middleware then sets it again to the page's locale, so a load's ctx.event carries the locale its page renders in. useTranslation(event) of @silgi/i18n/server gives the translator of that locale. Pass the event explicitly: there is no AsyncLocalStorage.

import { defineHandler } from "silgi/server";
import { useTranslation } from "@silgi/i18n/server";

export const save = defineHandler({
  route: "/api/save",
  method: "POST",
  async handler(_, { event }) {
    const { t, locale } = await useTranslation(event);
    return { locale: locale.code, message: t("api.saved") };
  },
});

A load uses it the same way:

import type { Load } from "silgi/app";
import { useTranslation } from "@silgi/i18n/server";

export const load = (async (ctx) => {
  const { t } = await useTranslation(ctx.event!);
  return { id: ctx.params.id, title: t("home.title") };
}) satisfies Load;

A locale's messages load once per process. See @silgi/i18n/server.

Error pages

An error page for a path that matches no route, such as a 404 under /fr/, has no route copy, but useI18n() takes the locale from the path, so app/error.<ext> renders French under /fr/ and the default locale elsewhere. An error in a loader or a render of a French page renders the error page in French. The module sets no head on such a page, so the error page sets htmlAttrs itself from useLocale():

import { useHead } from "@silgi/react";
import { useI18n, useLocale } from "@silgi/i18n/react";
import type { ErrorPageProps } from "silgi/app";

export default function ErrorPage({ error }: { error: ErrorPageProps }) {
  const { t } = useI18n();
  const { language, dir } = useLocale();
  useHead({ htmlAttrs: { lang: language, dir } });
  return (
    <main>
      <h1>{error.status === 404 ? t("error.notFound") : t("error.failed")}</h1>
    </main>
  );
}

Head and SEO

A page's head gets, from the module:

  • htmlAttrs with lang (the locale's language) and dir;
  • <meta property="og:locale"> (the language with _, as in fr_FR) and one og:locale:alternate for each other locale;
  • when baseUrl is set, one <link rel="alternate" hreflang="…"> for each locale plus hreflang="x-default" for the default locale's URL, and <link rel="canonical"> for the current page. Every href is <baseUrl><app.baseURL><path> without the query or the hash, so the set is absolute and each page lists the others.

A page's own head entry with the same key wins. The head follows a client navigation, so lang, dir and canonical change with the locale.

Without baseUrl the module writes htmlAttrs and og:locale only, and the build warns once: [silgi] i18n.baseUrl is not set; hreflang and canonical links are not written. baseUrl is the origin without a path (app.baseURL adds that); one trailing / is dropped. To read it at run time, name a public runtime config key; an environment variable then overrides it as in Configuration:

import { defineConfig } from "silgi";

export default defineConfig({
  framework: "react",
  modules: ["@silgi/i18n"],
  runtimeConfig: { public: { siteUrl: "" } },
  i18n: {
    locales: ["en", "fr"],
    defaultLocale: "en",
    baseUrl: { runtimeConfig: "siteUrl" },
  },
});

If the value is empty at run time, the server logs the same line once per process.

Prerender and the static preset

With prerender, every seed that matches a page also adds the same page in every other locale, with localized paths applied: /posts/1 adds /fr/posts/1. The crawl finds the rest because localized pages link with localePath; hreflang links are not crawled. A seed that matches no page, such as /feed.xml, gets no copy.

With the static preset and prefix_except_default, / is the default locale's page and there is no redirect. With prefix, the prerender asks for / without a cookie or Accept-Language, so it receives the locale root page, which is written as /index.html; it sends the visitor on in the browser.

import { defineConfig } from "silgi";

export default defineConfig({
  framework: "react",
  preset: "static",
  modules: ["@silgi/i18n"],
  i18n: {
    locales: ["en", "fr"],
    defaultLocale: "en",
    strategy: "prefix",
    baseUrl: "https://example.com",
  },
  prerender: { routes: ["/posts/1", "/posts/2"] },
});

The prefix strategy without the static preset:

import { defineConfig } from "silgi";

export default defineConfig({
  framework: "react",
  modules: ["@silgi/i18n"],
  i18n: {
    locales: ["en", "fr"],
    defaultLocale: "en",
    strategy: "prefix",
    baseUrl: "https://example.com",
  },
});

Types

Typegen reads the default locale's files and writes .silgi/types/i18n.d.ts, which fills two registries:

declare module "@silgi/i18n" {
  interface SilgiI18nMessages {
    "home.title": {};
    "home.hello": { "name": string | number };
    "cart.items": { "count": number };
    "posts.at": { "at": Date | number };
    "status.pick": { "s": "open" | "closed" | (string & {}) };
    "status.open": {};
    "status.closed": {};
    "api.saved": {};
    "error.notFound": {};
    "error.failed": {};
  }
  interface SilgiI18nLocales {
    "en": true;
    "fr": true;
  }
}

After that t checks the key and its arguments, and SilgiLocale is the union of the codes. A wrong key, a missing argument and a number given as a string all fail tsc. A template literal key over a union, such as t(`status.${s}`) with s: "open" | "closed", is checked; a key of type string fails. A select argument is the union of its case keys other than other, plus string. A text argument is string | number. While the registries are empty, t takes any string key and { [name]: unknown }.

Typegen also compares the locales, and these show in silgi build, silgi dev and silgi typegen:

CaseResult
a key of the default locale missing in another localeone warning per locale, listing the keys
a key only in another localea warning
an argument a translation uses that the default does nota build error: locales/fr.json "cart.items": argument "total" is not in the default locale's message
an argument of another kind than the default'sa build error: locales/fr.json "cart.items": argument "count" is date, the default locale's message has number

Not in v1

  • The no_prefix and prefix_and_default strategies, and domains per locale (differentDomains).
  • Automatic localization of Link. Use localePath.
  • Messages split per route or per component, and stripping unused keys from chunks.
  • MessageFormat 2, rich-text tags in messages, number skeletons other than ::currency/<code>, and gender and variants.
  • Message extraction from source, translation-platform sync, a CMS loader, and catalogs loaded at run time.
  • Redirects on paths other than /, and detection from navigator.languages outside the locale root page.
  • Locale fallback chains beyond the default locale (es-AR, then es, then en), and region files layered per locale.

What it costs

bun bench/run.ts i18n measures the module on the React app of docs/benchmarks.md: requests per second of /fr/posts with the module against /posts without it, on the bun and node presets, and the client JS of the localized page with 2 and with 6 locales. The table is in the "i18n overhead" section there, with the load average of the run. In the run recorded there, the localized page served about 19 to 23 % fewer requests per second than the same page without the module, on both presets, which is more than the spread between rounds. Most of the difference is the head tags the module writes (hreflang, canonical, og:locale) and the route middleware that loads the locale's messages; the section lists the stages. Client JS grew by about 1.1 KB gzipped with two locales and by no more with six, because the browser fetches only the chunk of the page's locale. The numbers are information, not a target, and they come from a shared machine, so repeat them on a quiet one before you quote them.