I18N
Internationalization (i18n) localizes your app.
- No hooks — call
t("key", params?)anywhere in frontend views - Typed keys / params — inferred from your default locale file
- SSR-safe / reactive across all major frontends
- Persistence — cookie (default), localStorage, sessionStorage, or none
- Intl-backed — numbers, dates, currency, lists, relative time, units
Setup
Enable i18n in your app config and import your catalogs.
import config from "primate/config";
import de from "../locales/de-DE.ts";
import en from "../locales/en-US.ts";
export default config({
i18n: {
defaultLocale: "en-US",
locales: {
"en-US": en,
"de-DE": de,
},
}
})import config from "primate/config";
import de from "../locales/de-DE.js";
import en from "../locales/en-US.js";
export default config({
i18n: {
defaultLocale: "en-US",
locales: {
"en-US": en,
"de-DE": de,
},
}
})Locales
Locales are TypeScript modules in the locales directory that export a catalog
via i18n.locale(...).
| File | Purpose |
|---|---|
<default>.ts |
catalog matching defaultLocale (drives types) |
<locale>.ts |
any other catalog mapping keys -> translated strings |
Example
defaultLocale is "en-US", so en-US.ts is the default
catalog. If you set defaultLocale: "de-DE", then de-DE.ts becomes the
default and drives type inference.import i18n from "primate/i18n";
export default i18n.locale({
switch_language: "Switch language",
english: "English",
german: "German",
all_posts: "All posts",
counter: "Counter: {count:n}",
title: "Title",
greet_user: "Hello, {name}",
added_n_items: "{n:n|one item added|{n} items added}",
price_line: "Total: {amount:currency}",
last_seen: "Last seen {secs:ago}",
when: "It happened on {d:date}",
distance: "Distance: {km:unit(kilometer)}",
list_example: "Tags: {tags:list}",
});import i18n from "primate/i18n";
export default i18n.locale({
switch_language: "Sprache wechseln",
english: "Englisch",
german: "Deutsch",
all_posts: "Alle Beiträge",
counter: "Zähler: {count:n}",
title: "Titel",
greet_user: "Hallo, {name}",
added_n_items: "{n:n|ein Eintrag hinzugefügt|{n} Einträge hinzugefügt}",
price_line: "Summe: {amount:currency}",
last_seen: "Zuletzt gesehen {secs:ago}",
when: "Es geschah am {d:date}",
distance: "Entfernung: {km:unit(kilometer)}",
list_example: "Stichwörter: {tags:list}",
});Frontend
Each frontend requires a bridge file that adapts Primate's headless translator
to the frontend's reactivity model. Create it once per project — conventionally
at lib/i18n.ts:
// lib/i18n.ts
import app from "#app";
import i18n from "@primate/svelte/i18n";
export default i18n(app.i18n);The bridge import follows the same pattern for every frontend —
@primate/FRONTEND/i18n. Views import t from wherever you place this file.
Each frontend adapter exposes a translator t and a locale setter:
| Frontend | Translator | Locale getter | Locale setter | Notes |
|---|---|---|---|---|
| React | t |
t.locale.get() |
t.locale.set(l) |
Call t() anywhere |
| Angular | t |
t.locale.get() |
t.locale.set(l) |
Injectable or import as needed |
| Vue | t |
t.locale.get() |
t.locale.set(l) |
Works in setup/options |
| Svelte | $t |
t.locale.get() |
t.locale.set(l) |
t is a store; use $t in markup |
| Solid | t |
t.locale.get() |
t.locale.set(l) |
Signal-like; t.subscribe() |
| Marko | t |
t.locale.get() |
t.locale.set(l) |
React
import t from "#lib/i18n";
export function Profile({ user }: { user: { name: string; since: number } }) {
return (
<section>
<h2>{t("profile_title")}</h2>
<p>{t("greeting", { name: user.name })}</p>
<button onClick={() => t.locale.set("de-DE")}>
{t("switch_to_german")}
</button>
</section>
);
}
Svelte
<script lang="ts">
import t from "#lib/i18n";
const change = () => t.locale.set("en-US");
</script>
<h1>{$t("dashboard_title")}</h1>
<p>{$t("notifications", { count: 3 })}</p>
<button onclick={change}>{$t("switch_to_english")}</button>
Vue
<script setup lang="ts">
import t from "#lib/i18n";
const switchLocale = () => t.locale.set("fr-FR");
</script>
<template>
<h1>{{ t("home_title") }}</h1>
<p>{{ t("items", { count: 1 }) }}</p>
<button @click="switchLocale">{{ t("switch_to_french") }}</button>
</template>
Backend
In route handlers, use app.i18n from the app facade. The locale is
automatically resolved from the user's request cookie.
// routes/api/greeting.ts
import app from "#app";
import route from "primate/route";
export default route({
get() {
return { message: app.i18n("greeting", { name: "World" }) };
},
});A German user visiting /api/greeting receives { message: "Hallo, Welt!" }.
Limitations
app.i18n is read-only on the server — locale switching is client-only.
Use cases
- Localized API responses — return translated error messages or labels
- Server-rendered content — generate locale-aware HTML without client JS
- Email templates — use
app.i18n()when building notification emails
Message syntax
Messages are simple strings with {placeholders}. Each placeholder can specify
a format spec after a colon.
| Spec | Meaning | Param type | Example |
|---|---|---|---|
| (no spec) | string | string |
{name} |
n or number |
numbers | number |
{count:n} |
d or date |
date | Date | number |
{created:d} |
c or currency |
currency | number |
{total:c} |
o or ordinal |
ordinals | number |
{rank:o} |
a or ago |
relative time | number |
{delta:a} |
l or list |
item list | string[] |
{tags:l} |
u(<u>) or unit(<u>) |
units | number |
{size:u(MB)} |
Plural selection
{n:n|...} optionally takes 2 or 3 choices:
n|one|other— uses the locale's plural rulesn|zero|one|other— adds an explicit zero branch
Inside choices you can backreference the formatted number using the same key.
// en: "You have 1 item" / "You have 3 items"
"You have {count:n|{count} item|{count} items}";
// de: "Du hast keine Artikel" / "Du hast 1 Artikel" / "Du hast 7 Artikel"
"Du hast {num:n|keine Artikel|{num} Artikel|{num} Artikel}";
Units
u(...) accepts many aliases mapped to Intl units (length, area, volume, mass,
temperature, speed, time, digital, energy, power, pressure, angle, frequency,
concentration, electric, force, luminous). Examples:
| Alias | Normalized Intl unit |
|---|---|
km/h kph kmh |
kilometer-per-hour |
MB mb |
megabyte/megabit |
°C celsius |
celsius |
kWh |
kilowatt-hour |
psi |
pound-force-per-square-inch |
Locale switching
Use t.locale.set(locale) to switch on the client. The active locale is
reactive; subscribers are notified and the UI updates automatically.
// read current
const current = t.locale.get();
// switch and persist
t.locale.set("de-DE");
Persistence
| Mode | Behavior |
|---|---|
"cookie" (default) |
Sends a fetch("/") and persists in cookie |
"localStorage" |
Writes __primate_locale |
"sessionStorage" |
Writes __primate_locale |
false |
No persistence |
Persistence only runs in the browser. t.loading is true while a cookie
persistence request is inflight.
Type inference
Keys and parameter types are inferred from the default catalog:
// locales/en-US.ts
import i18n from "primate/i18n";
export default i18n.locale({
greeting: "Hello {name}",
added: "Added {n:n|{n} item|{n} items}",
created_at: "Created on {date:d}",
});
t("greeting", { name: "Ada" }); // ok
t("added", { n: 3 }); // ok
t("created_at", { date: Date.now() }); // ok (number as epoch)
// t("greeting", {}); // TS error: missing "name"
// t("created_at", { date: "2024-01-01" });// TS error: wrong type
FAQ
- Where do the plural categories come from?
Intl.PluralRulesper active locale. When you pass 2 options, categories collapse toonevsother. - Can I lazy-load catalogs? Yes — provide
config.localeswith modules you control (the i18n core doesn't mandate how you import them). - What if I mistype a key at runtime? An unknown key returns the key name as a string. At compile time, TypeScript catches it in code that sees the inferred types.