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
| Key | Type | Default | Meaning |
|---|---|---|---|
locales | (string | { code, language?, dir?, name? })[] | required | code 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 |
defaultLocale | string | required | one of the codes |
strategy | "prefix_except_default" | "prefix" | "prefix_except_default" | see Strategies |
baseUrl | string | { runtimeConfig: string } | none | the site origin for hreflang and canonical links; see Head and SEO |
detect.cookie | string | 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 |
pages | Record<string, Partial<Record<string, string>>> | {} | localized paths by route name and locale code |
timeZone | string | "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:
| Value | Message |
|---|---|
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 twice | i18n.locales: "fr" is listed twice |
a language that is not a BCP 47 tag | i18n.locales[1].language "fr_FR" is not a BCP 47 language tag |
defaultLocale that is not a code | i18n.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: origin | i18n.baseUrl "example.com" must be an origin such as "https://example.com" (app.baseURL adds the path) |
a timeZone the runtime does not know | i18n.timeZone "Mars/Base" is not a time zone this runtime knows |
a detect.cookie that is not a cookie name | i18n.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.
| Strategy | Copy set | The original routes | / |
|---|---|---|---|
prefix_except_default | every locale but the default | stay, as the default locale's routes | the default locale's home page, unless the redirect sends the visitor on |
prefix | every locale | are removed, so /about matches no page | answered 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:
| Case | Message |
|---|---|
| a key that is not a route name | i18n.pages: no route named "abuot" |
| a code that is not a locale | i18n.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 params | i18n.pages.blog-slug.fr "/article/:id" must keep the params of "/blog/:slug" (slug) |
| a copy with the shape of another route | i18n: route copy "/fr/about" (about___fr) clashes with "/fr/about" (fr-about, app/pages/fr/about.tsx) |
Links between locales
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 inLink'sto.
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:
- the cookie
detect.cookie, when its value is a configured code; - the
Accept-Languageheader: the ranges inqorder, each matched to a locale whoselanguageorcodeis 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.
| Syntax | Meaning |
|---|---|
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:
| Library | useI18n() | useLocale() |
|---|---|---|
| React, Preact | an object, the same while the locale code is the same | the locale |
| Vue | { locale: ComputedRef, t, n, d, … }; each function reads locale.value, so a template re-renders | ComputedRef of the locale |
| Svelte | an object with a locale getter; each function reads the route when called, so markup re-runs | an object with getters |
| Solid | { locale: Accessor, t, n, d, … }; each function reads the accessor | Accessor of the locale |
| html | an object for this render | the 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:
- the first segment of the path below
app.baseURL, when it is a locale that has route copies; ?locale=<code>;- the cookie, when
detect.cookieis notfalse; Accept-Language;- 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:
htmlAttrswithlang(the locale'slanguage) anddir;<meta property="og:locale">(the language with_, as infr_FR) and oneog:locale:alternatefor each other locale;- when
baseUrlis set, one<link rel="alternate" hreflang="…">for each locale plushreflang="x-default"for the default locale's URL, and<link rel="canonical">for the current page. Everyhrefis<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:
| Case | Result |
|---|---|
| a key of the default locale missing in another locale | one warning per locale, listing the keys |
| a key only in another locale | a warning |
| an argument a translation uses that the default does not | a 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's | a build error: locales/fr.json "cart.items": argument "count" is date, the default locale's message has number |
Not in v1
- The
no_prefixandprefix_and_defaultstrategies, and domains per locale (differentDomains). - Automatic localization of
Link. UselocalePath. - 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 fromnavigator.languagesoutside the locale root page. - Locale fallback chains beyond the default locale (
es-AR, thenes, thenen), 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.