@pyreon/i18n provides a reactive internationalization system built on @pyreon/reactivity signals. Translations automatically update when the locale changes -- no manual re-renders needed. Supports interpolation, CLDR pluralization, nested keys, namespaced translations, async namespace loading, fallback locale chains, and custom missing key handling.
Installation
npm install @pyreon/i18nbun add @pyreon/i18npnpm add @pyreon/i18nyarn add @pyreon/i18nBasic Usage
Create an i18n instance with createI18n and use the t() function to translate keys.
import { createI18n } from '@pyreon/i18n'
const i18n = createI18n({
locale: 'en',
fallbackLocale: 'en',
messages: {
en: {
greeting: 'Hello {{name}}!',
farewell: 'Goodbye!',
},
de: {
greeting: 'Hallo {{name}}!',
farewell: 'Auf Wiedersehen!',
},
},
})
i18n.t('greeting', { name: 'Alice' }) // "Hello Alice!"
i18n.t('farewell') // "Goodbye!"The t() function is the primary API for translations. It reads the current locale reactively, meaning that any effect, computed, or component render function that calls t() will automatically re-evaluate when the locale changes.
I18nProvider and useI18n
Use I18nProvider to supply an i18n instance via context, and useI18n to access it from any child component. This avoids prop-drilling the i18n instance through your component tree.
import { I18nProvider, useI18n, createI18n } from '@pyreon/i18n'
const i18n = createI18n({
locale: 'en',
messages: { en: { greeting: 'Hello, {{name}}!' } },
})
// Root component
function App() {
return (
<I18nProvider instance={i18n}>
<Greeting />
</I18nProvider>
)
}
// Child component — access i18n via context
function Greeting() {
const { t, locale } = useI18n()
return <h1>{t('greeting', { name: 'World' })}</h1>
}I18nProvider accepts { instance: I18nInstance, children } props. useI18n() throws if called outside an I18nProvider. The I18nContext is also exported for advanced use cases.
Trans Component
The Trans component enables rich JSX interpolation within translation strings. Use it when your translations contain inline markup like bold text, links, or other components.
import { Trans, useI18n } from '@pyreon/i18n'
// Translation: "You have <bold>{{count}}</bold> unread messages"
function Messages({ count }) {
const { t } = useI18n()
return (
<Trans
t={t}
i18nKey="messages.unread"
values={{ count }}
components={{
bold: (children) => <strong>{children}</strong>,
}}
/>
)
// Renders: You have <strong>5</strong> unread messages
}
// Translation: "Read our <terms>terms of service</terms> and <privacy>privacy policy</privacy>"
function Legal() {
const { t } = useI18n()
return (
<Trans
t={t}
i18nKey="legal"
components={{
terms: (children) => <a href="/terms">{children}</a>,
privacy: (children) => <a href="/privacy">{children}</a>,
}}
/>
)
}TransProps:
| Property | Type | Description |
|---|---|---|
i18nKey | string | Translation key (supports namespace/* zero-content: unhandled mdast node "textDirective" */ syntax) |
values | InterpolationValues | Interpolation values for placeholder syntax |
components | Record<string, (children) => VNode> | Component map for rich interpolation |
t | (key, values?) => string | The i18n t function |
parseRichText
The parseRichText function parses tagged translation strings into an array of strings and { tag, children } objects. It is used internally by Trans but is exported for custom rendering scenarios.
import { parseRichText } from '@pyreon/i18n'
parseRichText('Hello <bold>world</bold>, click <link>here</link>')
// → ["Hello ", { tag: "bold", children: "world" }, ", click ", { tag: "link", children: "here" }]Parses <tag>content</tag> patterns into an array of strings and { tag, children } objects. Tags that do not have a matching closing tag are left as plain text.
createI18n Configuration
The createI18n function accepts an I18nOptions object with the following properties:
locale (required)
The initial locale string (e.g., "en", "de", "ja"). This becomes the default locale used by t().
const i18n = createI18n({
locale: 'en',
messages: { en: { hello: 'Hello' } },
})fallbackLocale
When a translation key is missing in the active locale, the system tries this locale before giving up. This is useful for partially translated locales.
const i18n = createI18n({
locale: 'de',
fallbackLocale: 'en',
messages: {
en: {
greeting: 'Hello',
onlyInEnglish: 'This only exists in English',
},
de: {
greeting: 'Hallo',
},
},
})
i18n.t('greeting') // "Hallo" (found in 'de')
i18n.t('onlyInEnglish') // "This only exists in English" (falls back to 'en')messages
Static translation messages keyed by locale. Each locale maps to a TranslationDictionary -- a nested object where leaf values are translation strings.
const i18n = createI18n({
locale: 'en',
messages: {
en: {
nav: {
home: 'Home',
about: 'About',
contact: 'Contact',
},
auth: {
login: 'Log In',
logout: 'Log Out',
errors: {
invalid: 'Invalid credentials',
expired: 'Session expired',
},
},
},
fr: {
nav: {
home: 'Accueil',
about: 'A propos',
contact: 'Contact',
},
auth: {
login: 'Se connecter',
logout: 'Se deconnecter',
errors: {
invalid: 'Identifiants invalides',
expired: 'Session expiree',
},
},
},
},
})When messages are provided, they are stored under the default namespace (which is "common" unless overridden by defaultNamespace).
loader
An async function for loading translations on demand. Called with (locale, namespace) when loadNamespace() is invoked. The loader should return a TranslationDictionary or undefined if the namespace does not exist.
const i18n = createI18n({
locale: 'en',
loader: async (locale, namespace) => {
try {
const mod = await import(`./locales/${locale}/${namespace}.json`)
return mod.default
} catch {
return undefined
}
},
})defaultNamespace
The namespace used when t() is called without a namespace prefix. Defaults to "common".
const i18n = createI18n({
locale: 'en',
defaultNamespace: 'main',
messages: {
en: { title: 'My App' }, // stored under "main" namespace
},
})
i18n.t('title') // looks up "title" in "main" namespacepluralRules
Custom plural rules per locale. Each rule is a function that receives a count and returns a CLDR plural category string. If not provided, Intl.PluralRules is used where available.
const i18n = createI18n({
locale: 'ar',
pluralRules: {
ar: (count) => {
if (count === 0) return 'zero'
if (count === 1) return 'one'
if (count === 2) return 'two'
if (count >= 3 && count <= 10) return 'few'
if (count >= 11 && count <= 99) return 'many'
return 'other'
},
},
messages: {
ar: {
items_zero: 'No items',
items_one: 'One item',
items_two: 'Two items',
items_few: '{{count}} items (few)',
items_many: '{{count}} items (many)',
items_other: '{{count}} items',
},
},
})onMissingKey
A callback invoked when a translation key is not found in either the current locale or the fallback locale. It receives the locale, the full key, and the resolved namespace. It can return a custom fallback string, or return void to use the default behavior (returning the key itself).
const i18n = createI18n({
locale: 'en',
messages: { en: {} },
onMissingKey: (locale, key, namespace) => {
console.warn(`Missing translation: [${locale}] ${namespace}:${key}`)
return `[MISSING: ${key}]`
},
})
i18n.t('unknown.key') // logs warning, returns "[MISSING: unknown.key]"If onMissingKey returns void (or undefined), the raw key string is returned as a visual fallback:
const i18n = createI18n({
locale: 'en',
messages: { en: {} },
onMissingKey: (locale, key) => {
// Log but don't provide a custom fallback
reportMissingKey(locale, key)
},
})
i18n.t('missing') // returns "missing"Translation Function (t)
The t() function is the core of the i18n system. It resolves a translation key, applies interpolation, handles pluralization, and returns the translated string.
Key Format
Keys follow the format namespace:dotted.path or just dotted.path (which uses the default namespace):
i18n.t('greeting') // default namespace, key "greeting"
i18n.t('user.profile.title') // default namespace, key "user.profile.title"
i18n.t('auth:login') // "auth" namespace, key "login"
i18n.t('auth:errors.invalid') // "auth" namespace, key "errors.invalid"Reactivity
The t() function reads the current locale and store version reactively. This means that when called inside an effect(), computed(), or component render function, it automatically re-evaluates when:
The locale changes via
i18n.locale.set(...)New messages are added via
i18n.addMessages(...)A namespace finishes loading via
i18n.loadNamespace(...)
import { effect } from '@pyreon/reactivity'
const i18n = createI18n({
locale: 'en',
messages: {
en: { greeting: 'Hello' },
de: { greeting: 'Hallo' },
},
})
effect(() => {
console.log(i18n.t('greeting'))
})
// Logs: "Hello"
i18n.locale.set('de')
// Logs: "Hallo"Interpolation
Use {{key}} placeholders in translation strings. Values can be strings or numbers. Whitespace inside the braces is allowed.
const i18n = createI18n({
locale: 'en',
messages: {
en: {
welcome: 'Welcome, {{user}}! You have {{count}} messages.',
status: 'Logged in as {{ username }}', // spaces inside braces work
},
},
})
i18n.t('welcome', { user: 'Alice', count: 5 })
// "Welcome, Alice! You have 5 messages."
i18n.t('status', { username: 'bob' })
// "Logged in as bob"Unmatched placeholders are left as-is, which makes it easy to spot missing interpolation values during development:
i18n.t('welcome', { user: 'Alice' })
// "Welcome, Alice! You have {{count}} messages."Standalone Interpolation
The interpolate function is exported for standalone use outside the i18n system:
import { interpolate } from '@pyreon/i18n'
interpolate('Hello {{name}}!', { name: 'World' })
// "Hello World!"
interpolate('No placeholders here', { name: 'ignored' })
// "No placeholders here"
interpolate('Hello {{name}}!')
// "Hello {{name}}!" (no values = template unchanged)Nested Keys
Translation dictionaries can be deeply nested. Use dot-separated paths to access nested values:
const i18n = createI18n({
locale: 'en',
messages: {
en: {
user: {
greeting: 'Hello {{name}}!',
profile: {
title: 'Your Profile',
settings: {
theme: 'Theme',
language: 'Language',
notifications: 'Notifications',
},
},
},
},
},
})
i18n.t('user.greeting', { name: 'Alice' })
// "Hello Alice!"
i18n.t('user.profile.title')
// "Your Profile"
i18n.t('user.profile.settings.theme')
// "Theme"Nested keys work with namespaces too:
i18n.addMessages(
'en',
{
errors: {
auth: {
invalid: 'Invalid credentials',
expired: 'Session expired',
},
network: 'Network error',
},
},
'auth',
)
i18n.t('auth:errors.auth.invalid') // "Invalid credentials"
i18n.t('auth:errors.network') // "Network error"Pluralization
When interpolation values include a count key, the system automatically resolves CLDR plural categories and looks for suffixed keys. The CLDR categories are: zero, one, two, few, many, other.
Basic Pluralization
const i18n = createI18n({
locale: 'en',
messages: {
en: {
items_one: '{{count}} item',
items_other: '{{count}} items',
},
},
})
i18n.t('items', { count: 0 }) // "0 items"
i18n.t('items', { count: 1 }) // "1 item"
i18n.t('items', { count: 5 }) // "5 items"
i18n.t('items', { count: 42 }) // "42 items"The naming convention is baseKey_category. The system resolves the plural category for the current locale and count, then looks up baseKey_category. If the specific plural form is not found, it falls back to the base key itself.
Fallback to Base Key
If no plural-suffixed key matches, the base key is used:
const i18n = createI18n({
locale: 'en',
messages: {
en: {
items: 'some items', // no _one or _other suffixes
},
},
})
i18n.t('items', { count: 1 }) // "some items"
i18n.t('items', { count: 5 }) // "some items"Multi-Category Languages (Arabic Example)
Languages with complex pluralization rules can use all six CLDR categories:
const i18n = createI18n({
locale: 'ar',
pluralRules: {
ar: (count) => {
if (count === 0) return 'zero'
if (count === 1) return 'one'
if (count === 2) return 'two'
if (count >= 3 && count <= 10) return 'few'
return 'other'
},
},
messages: {
ar: {
items_zero: 'No items',
items_one: 'One item',
items_two: 'Two items',
items_few: '{{count}} items (few)',
items_other: '{{count}} items',
},
},
})
i18n.t('items', { count: 0 }) // "No items"
i18n.t('items', { count: 1 }) // "One item"
i18n.t('items', { count: 2 }) // "Two items"
i18n.t('items', { count: 5 }) // "5 items (few)"
i18n.t('items', { count: 100 }) // "100 items"Plural Fallback Locale
Pluralization also respects the fallback locale. If a plural form is missing in the current locale, the fallback locale is checked:
const i18n = createI18n({
locale: 'de',
fallbackLocale: 'en',
messages: {
en: {
items_one: '{{count}} item',
items_other: '{{count}} items',
},
de: {}, // no items translation in German
},
})
i18n.t('items', { count: 1 }) // "1 item" (falls back to English)
i18n.t('items', { count: 5 }) // "5 items" (falls back to English)Standalone Plural Resolution
The resolvePluralCategory function is exported for use outside the i18n system:
import { resolvePluralCategory } from '@pyreon/i18n'
resolvePluralCategory('en', 1) // "one"
resolvePluralCategory('en', 0) // "other"
resolvePluralCategory('en', 5) // "other"
resolvePluralCategory('ar', 2) // "two" (via Intl.PluralRules)It accepts optional custom rules as the third argument:
const customRules = {
custom: (count: number) => (count === 0 ? 'zero' : count === 1 ? 'one' : 'other'),
}
resolvePluralCategory('custom', 0, customRules) // "zero"Namespaces
Namespaces partition translations into logical groups. This is useful for code splitting, loading translations on demand, and organizing large translation files.
Namespace Syntax
Use the namespace:key format in t():
i18n.t('auth:errors.invalid')
// Looks up "errors.invalid" in the "auth" namespace
i18n.t('admin:dashboard.title')
// Looks up "dashboard.title" in the "admin" namespaceKeys without a namespace prefix use the default namespace (defaults to "common"):
i18n.t('greeting')
// Equivalent to i18n.t('common:greeting')Adding Namespaced Messages
Use the third argument of addMessages to target a specific namespace:
i18n.addMessages('en', { title: 'Dashboard' }, 'admin')
i18n.addMessages('en', { login: 'Log In' }, 'auth')
i18n.t('admin:title') // "Dashboard"
i18n.t('auth:login') // "Log In"Checking Namespaced Key Existence
The exists() method supports the namespace syntax:
i18n.exists('admin:title') // true
i18n.exists('admin:nonexistent') // false
i18n.exists('greeting') // true (checks default namespace)Async Namespace Loading
For large applications, load translations on demand with the loader option. This allows you to code-split translations and load them only when needed.
Setting Up a Loader
const i18n = createI18n({
locale: 'en',
loader: async (locale, namespace) => {
const mod = await import(`./locales/${locale}/${namespace}.json`)
return mod.default
},
})Loading a Namespace
// Load translations before using them
await i18n.loadNamespace('auth')
// Now the translations are available
i18n.t('auth:login') // "Log In"
i18n.t('auth:errors.invalid') // "Invalid credentials"Before a namespace is loaded, t() returns the raw key as a fallback:
i18n.t('auth:login') // "auth:login" (not loaded yet)
await i18n.loadNamespace('auth')
i18n.t('auth:login') // "Log In"Loading for a Specific Locale
You can preload namespaces for a locale other than the current one:
// Preload German translations while English is still active
await i18n.loadNamespace('common', 'de')Loading State
The isLoading computed signal tracks whether any namespace is currently being loaded:
// Show a loading indicator while translations are loading
effect(() => {
if (i18n.isLoading()) {
showSpinner()
} else {
hideSpinner()
}
})Loaded Namespaces
The loadedNamespaces computed returns the set of namespaces loaded for the current locale:
await i18n.loadNamespace('auth')
await i18n.loadNamespace('admin')
i18n.loadedNamespaces()
// Set { "auth", "admin" }Deduplication
Concurrent calls to loadNamespace for the same locale and namespace are automatically deduplicated. The loader is only called once:
// These two calls share the same loader invocation
await Promise.all([i18n.loadNamespace('auth'), i18n.loadNamespace('auth')])
// loader('en', 'auth') was only called onceAlready-loaded namespaces are not re-fetched:
await i18n.loadNamespace('auth')
await i18n.loadNamespace('auth') // no-op, already loadedError Handling
If the loader throws, the error propagates to the caller and the loading state is properly cleaned up:
try {
await i18n.loadNamespace('broken')
} catch (err) {
console.error('Failed to load translations:', err)
}
i18n.isLoading() // false (cleaned up)If the loader returns undefined, the namespace is silently skipped (not added to loaded namespaces):
const i18n = createI18n({
locale: 'en',
loader: async () => undefined,
})
await i18n.loadNamespace('missing')
i18n.loadedNamespaces().has('missing') // falseMixed Static and Async Messages
You can combine static messages with an async loader. Static messages are available immediately while additional namespaces load on demand:
const i18n = createI18n({
locale: 'en',
messages: {
en: { welcome: 'Welcome!' },
},
loader: async (locale, ns) => {
const mod = await import(`./locales/${locale}/${ns}.json`)
return mod.default
},
})
i18n.t('welcome') // "Welcome!" (available immediately)
i18n.t('dashboard:title') // "dashboard:title" (not loaded yet)
await i18n.loadNamespace('dashboard')
i18n.t('dashboard:title') // "Dashboard" (now loaded)Reactive Locale Switching
The locale property is a reactive signal. Changing it causes all reactive contexts that read translations to re-evaluate.
Basic Locale Switching
import { effect } from '@pyreon/reactivity'
const i18n = createI18n({
locale: 'en',
messages: {
en: { greeting: 'Hello' },
de: { greeting: 'Hallo' },
fr: { greeting: 'Bonjour' },
},
})
effect(() => {
console.log(i18n.t('greeting'))
})
// Logs: "Hello"
i18n.locale.set('de')
// Logs: "Hallo"
i18n.locale.set('fr')
// Logs: "Bonjour"Reading the Current Locale
// Reactive read (triggers re-evaluation in effects)
const currentLocale = i18n.locale()
// Non-reactive peek (does not trigger re-evaluation)
const currentLocale = i18n.locale.peek()Component with Language Switcher
import { defineComponent } from '@pyreon/core'
import { createI18n } from '@pyreon/i18n'
const i18n = createI18n({
locale: 'en',
messages: {
en: {
greeting: 'Hello, {{name}}!',
switchLang: 'Switch Language',
},
de: {
greeting: 'Hallo, {{name}}!',
switchLang: 'Sprache wechseln',
},
ja: {
greeting: '{{name}}!',
switchLang: '',
},
},
})
const LanguageSwitcher = defineComponent(() => {
return () => (
<div>
<p>{i18n.t('greeting', { name: 'World' })}</p>
<select value={i18n.locale()} onChange={(e) => i18n.locale.set(e.target.value)}>
{i18n.availableLocales().map((loc) => (
<option value={loc}>{loc.toUpperCase()}</option>
))}
</select>
</div>
)
})Locale Switching with Namespace Reloading
When using async loading, you may need to reload namespaces after switching locales:
async function switchLocale(newLocale: string) {
i18n.locale.set(newLocale)
// Reload all currently loaded namespaces for the new locale
const namespaces = i18n.loadedNamespaces()
await Promise.all([...namespaces].map((ns) => i18n.loadNamespace(ns)))
}Adding Messages at Runtime
Use addMessages to merge translations into the store without async loading. This is useful for plugin systems, feature flags, or server-provided translations.
// Add to the default namespace
i18n.addMessages('en', {
dashboard: {
title: 'Dashboard',
welcome: 'Welcome back, {{name}}!',
},
})
// Add to a specific namespace
i18n.addMessages('en', { greeting: 'Hi!' }, 'notifications')
i18n.t('notifications:greeting') // "Hi!"Deep Merging
Messages are deep-merged with existing translations. Existing keys are preserved:
const i18n = createI18n({
locale: 'en',
messages: {
en: {
errors: {
auth: 'Auth error',
},
},
},
})
i18n.addMessages('en', {
errors: {
network: 'Network error',
},
})
i18n.t('errors.auth') // "Auth error" (preserved)
i18n.t('errors.network') // "Network error" (added)Creating New Locales
addMessages creates the locale if it does not exist:
i18n.addMessages('fr', { greeting: 'Bonjour' })
i18n.locale.set('fr')
i18n.t('greeting') // "Bonjour"
i18n.availableLocales() // includes "fr"Immutability
The store clones the provided messages, so mutating the source object after calling addMessages has no effect:
const source = { greeting: 'Hello' }
i18n.addMessages('en', source)
source.greeting = 'MUTATED'
i18n.t('greeting') // "Hello" (not affected)Reactive Updates
addMessages triggers a reactive update, so any effect or computed that reads the added keys will re-evaluate:
effect(() => {
console.log(i18n.t('newKey'))
})
// Logs: "newKey" (missing key fallback)
i18n.addMessages('en', { newKey: 'New value!' })
// Logs: "New value!" (reactive update)Checking Key Existence
The exists() method checks whether a translation key exists in the current locale or the fallback locale:
i18n.exists('greeting') // true
i18n.exists('nonexistent.key') // false
i18n.exists('auth:errors.invalid') // true (if namespace is loaded)
i18n.exists('auth:nonexistent') // falseIt also checks the fallback locale:
const i18n = createI18n({
locale: 'de',
fallbackLocale: 'en',
messages: {
en: { onlyEn: 'English only' },
de: {},
},
})
i18n.exists('onlyEn') // true (found in fallback)Note that exists() uses .peek() internally and is not reactive. Use it for imperative checks, not inside render functions or effects where you need reactivity.
Missing Key Handling
The resolution order for a translation key is:
Look up the key in the current locale and resolved namespace
Look up the key in the fallback locale (if configured) and resolved namespace
Call the
onMissingKeyhandler (if configured)Return the raw key string as a visual fallback
This means you always get a string back from t() -- it never throws or returns undefined.
Logging Missing Keys in Development
const i18n = createI18n({
locale: 'en',
messages: { en: {} },
onMissingKey: (locale, key, namespace) => {
if (import.meta.env.DEV) {
console.warn(`[i18n] Missing key "${key}" in namespace "${namespace}" for locale "${locale}"`)
}
// Return undefined to use default behavior (return the key itself)
},
})Collecting Missing Keys for Translation Tools
const missingKeys = new Set<string>()
const i18n = createI18n({
locale: 'en',
messages: { en: {} },
onMissingKey: (locale, key, namespace) => {
missingKeys.add(`${locale}:${namespace}:${key}`)
return `[${key}]` // Custom visual indicator
},
})
// Later, export missing keys for your translation management system
exportForTranslation([...missingKeys])Available Locales
The availableLocales computed returns all locales that have any registered messages:
const i18n = createI18n({
locale: 'en',
messages: {
en: { hello: 'Hello' },
de: { hello: 'Hallo' },
},
})
i18n.availableLocales() // ["en", "de"]
i18n.addMessages('fr', { hello: 'Bonjour' })
i18n.availableLocales() // ["en", "de", "fr"]This is reactive and can be used in render functions to build language selectors.
Date and Number Formatting
@pyreon/i18n focuses on string translation. For date and number formatting, use the built-in Intl APIs with the current locale:
function formatDate(date: Date): string {
return new Intl.DateTimeFormat(i18n.locale(), {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(date)
}
function formatCurrency(amount: number, currency = 'USD'): string {
return new Intl.NumberFormat(i18n.locale(), {
style: 'currency',
currency,
}).format(amount)
}
function formatNumber(value: number): string {
return new Intl.NumberFormat(i18n.locale()).format(value)
}Since these functions read i18n.locale(), they are reactive when used inside effects or render functions:
const FormattedPrice = defineComponent<{ amount: number }>((props) => {
return () => <span>{formatCurrency(props.amount)}</span>
})RTL Support Considerations
For right-to-left languages, combine @pyreon/i18n with @pyreon/head to set the dir attribute:
import { useHead } from '@pyreon/head'
const rtlLocales = new Set(['ar', 'he', 'fa', 'ur'])
function RootLayout() {
useHead(() => ({
htmlAttrs: {
lang: i18n.locale(),
dir: rtlLocales.has(i18n.locale()) ? 'rtl' : 'ltr',
},
}))
return <App />
}Integration with @pyreon/router
For locale-based routing with URL prefixes like /en/about or /de/about:
import { createRouter } from '@pyreon/router'
import { createI18n } from '@pyreon/i18n'
const i18n = createI18n({
locale: 'en',
fallbackLocale: 'en',
loader: async (locale, namespace) => {
const mod = await import(`./locales/${locale}/${namespace}.json`)
return mod.default
},
})
const router = createRouter({
routes: [
{
path: '/:locale',
beforeEnter: async (to) => {
const locale = to.params.locale as string
i18n.locale.set(locale)
await i18n.loadNamespace('common')
},
children: [
{ path: '/', component: HomePage },
{ path: '/about', component: AboutPage },
],
},
],
})SSR Considerations
On the server, effects do not run. The t() function evaluates synchronously using the current locale at render time.
For SSR, set the locale before rendering:
import { createI18n } from '@pyreon/i18n'
import { renderWithHead } from '@pyreon/head'
async function handleRequest(req: Request) {
const locale = detectLocale(req)
const i18n = createI18n({
locale,
fallbackLocale: 'en',
messages: allMessages,
})
// Load any namespaces needed for the page
await i18n.loadNamespace('common')
const { html, head } = await renderWithHead(<App i18n={i18n} />)
// ...
}Create a new createI18n instance per request to avoid shared state between requests.
Full Application Example
// i18n.ts
import { createI18n } from '@pyreon/i18n'
// Inline critical translations, lazy-load the rest
export const i18n = createI18n({
locale: navigator.language.split('-')[0] || 'en',
fallbackLocale: 'en',
messages: {
en: {
app: { title: 'My App', loading: 'Loading...' },
},
de: {
app: { title: 'Meine App', loading: 'Laden...' },
},
},
loader: async (locale, namespace) => {
try {
const mod = await import(`./locales/${locale}/${namespace}.json`)
return mod.default
} catch {
return undefined
}
},
onMissingKey: (locale, key, namespace) => {
if (import.meta.env.DEV) {
console.warn(`[i18n] Missing: ${locale}/${namespace}/${key}`)
}
},
})// App.tsx
import { defineComponent } from '@pyreon/core'
import { i18n } from './i18n'
const App = defineComponent(() => {
// Load page-specific translations
i18n.loadNamespace('home')
return () => {
if (i18n.isLoading()) {
return <p>{i18n.t('app.loading')}</p>
}
return (
<main>
<h1>{i18n.t('app.title')}</h1>
<p>{i18n.t('home:welcome', { name: 'World' })}</p>
<footer>
<select value={i18n.locale()} onChange={(e) => i18n.locale.set(e.target.value)}>
{i18n.availableLocales().map((loc) => (
<option value={loc}>{loc}</option>
))}
</select>
</footer>
</main>
)
}
})API Reference
createI18n(options)
Create a reactive i18n instance.
Options (I18nOptions):
| Option | Type | Default | Description |
|---|---|---|---|
locale | string | (required) | Initial locale |
fallbackLocale | string | undefined | Fallback when a key is missing in the active locale |
messages | Record<string, TranslationDictionary> | undefined | Static messages keyed by locale |
loader | NamespaceLoader | undefined | Async namespace loader function |
defaultNamespace | string | "common" | Default namespace for keys without a prefix |
pluralRules | PluralRules | undefined | Custom plural rules per locale |
onMissingKey | (locale, key, namespace?) => string | void | undefined | Missing key handler |
Returns I18nInstance:
| Property / Method | Type | Description |
|---|---|---|
t(key, values?) | (key: string, values?: InterpolationValues) => string | Translate a key (reactive) |
locale | Signal<string> | Current locale signal (read/write) |
loadNamespace(ns, locale?) | (ns: string, locale?: string) => Promise<void> | Load a namespace asynchronously |
isLoading | Computed<boolean> | Whether any namespace is currently loading |
loadedNamespaces | Computed<Set<string>> | Set of loaded namespaces for the current locale |
exists(key) | (key: string) => boolean | Check if a key exists (non-reactive) |
addMessages(locale, messages, ns?) | (locale, messages, ns?) => void | Add/merge translations at runtime |
availableLocales | Computed<string[]> | All locales with registered messages |
I18nProvider
Provides an i18n instance to the component tree via context.
function I18nProvider(props: { instance: I18nInstance; children: VNode }): VNodeuseI18n()
Retrieves the i18n instance from the nearest I18nProvider. Throws if no provider is found.
function useI18n(): I18nInstanceI18nContext
The raw context object used by I18nProvider and useI18n. Exported for advanced use cases where you need direct context access.
Trans
Rich JSX interpolation component for translations containing inline markup.
function Trans(props: TransProps): VNodeTransProps:
| Property | Type | Description |
|---|---|---|
i18nKey | string | Translation key (supports namespace/* zero-content: unhandled mdast node "textDirective" */ syntax) |
values | InterpolationValues | Interpolation values for placeholder syntax |
components | Record<string, (children) => VNode> | Component map for rich interpolation |
t | (key, values?) => string | The i18n t function |
parseRichText(text)
Parse a tagged translation string into an array of strings and { tag, children } objects.
function parseRichText(text: string): Array<string | { tag: string; children: string }>interpolate(template, values?)
Replace {{key}} placeholders in a string with values from the given record.
function interpolate(template: string, values?: InterpolationValues): stringresolvePluralCategory(locale, count, customRules?)
Resolve the CLDR plural category for a given count and locale.
function resolvePluralCategory(locale: string, count: number, customRules?: PluralRules): stringType Exports
| Type | Description |
|---|---|
I18nInstance | The public i18n instance returned by createI18n |
I18nOptions | Options for createI18n |
TranslationDictionary | Nested Record<string, string | TranslationDictionary> |
TranslationMessages | Record<string, TranslationDictionary> |
NamespaceLoader | (locale: string, namespace: string) => Promise<TranslationDictionary | undefined> |
InterpolationValues | Record<string, string | number> |
PluralRules | Record<string, (count: number) => string> |
TransProps | Props for the Trans component |