Internationalization (i18n) system for Composable Svelte. Use when implementing multi-language support, translations, date/number formatting, locale detection, or localizing applications...
Complete i18n solution integrated with the Composable Architecture. Handles translations, formatting (dates, numbers, currency), locale detection, and works seamlessly with SSR/SSG.
Principle: i18n state is part of your application state, integrated via the i18n reducer.
import { createInitialI18nState, i18nReducer } from '@composable-svelte/core/i18n';
interface AppState {
todos: Todo[];
i18n: I18nState; // ✅ i18n integrated with app state
}
type AppAction =
| { type: 'addTodo'; text: string }
| I18nAction; // ✅ i18n actions part of app actions
const appReducer: Reducer<AppState, AppAction> = (state, action, deps) => {
// Handle i18n actions
if (typeof action.type === 'string' && action.type.startsWith('i18n/')) {
const [newI18nState, i18nEffect] = i18nReducer(state.i18n, action as any, deps);
return [
{ ...state, i18n: newI18nState },
i18nEffect as Effect<AppAction>
];
}
// Handle other actions...
return [state, Effect.none()];
};
// ❌ Don't create a separate store for i18n
const i18nStore = createStore({
initialState: i18nState,
reducer: i18nReducer
});
const appStore = createStore({
initialState: appState,
reducer: appReducer
});
WHY: Single source of truth. i18n changes should flow through the same reducer pipeline as other state changes.
Principle: The framework provides locale-aware formatters. Never manually format dates, numbers, or currency.
<script lang="ts">
import { createTranslator, createFormatters } from '@composable-svelte/core/i18n';
const t = $derived(createTranslator($store.i18n, 'common'));
const formatters = $derived(createFormatters($store.i18n));
</script>
<div>
<p>{t('welcome', { name: 'Alice' })}</p>
<p>{formatters.date(post.date)}</p>
<p>{formatters.number(1234.56)}</p>
<p>{formatters.currency(99.99, 'USD')}</p>
</div>
<!-- ❌ Don't manually format dates -->
<p>{new Date(post.date).toLocaleDateString()}</p>
<!-- ❌ Don't manually format numbers -->
<p>{price.toFixed(2)}</p>
<!-- ❌ Don't manually construct currency -->
<p>${price}</p>
WHY: Manual formatting doesn't respect locale conventions. Formatters automatically handle regional differences (e.g., "January 5, 2025" vs "5 janvier 2025", "1,234.56" vs "1 234,56").
Use createTranslator() for accessing translations. It handles interpolation, ICU MessageFormat, and fallback chains automatically.
<script lang="ts">
import { createTranslator } from '@composable-svelte/core/i18n';
const t = $derived(createTranslator($store.i18n, 'common'));
</script>
<div>
<!-- Simple translation -->
<h1>{t('app.title')}</h1>
<!-- With interpolation -->
<p>{t('welcome', { name: 'Alice' })}</p>
<!-- ICU MessageFormat (pluralization) -->
<p>{t('items', { count: 5 })}</p>
<!-- Translation: "{count, plural, one {# item} other {# items}}" -->
<!-- Output: "5 items" -->
</div>
Translation File Example (locales/en/common.json):
{
"app.title": "My Application",
"welcome": "Welcome, {name}!",
"items": "{count, plural, one {# item} other {# items}}",
"greeting": "{gender, select, male {Hello Mr. {name}} female {Hello Ms. {name}} other {Hello {name}}}"
}
Use createFormatters() to get locale-bound formatters. All formatters automatically use the current locale.
<script lang="ts">
import { createFormatters, DateFormats, NumberFormats } from '@composable-svelte/core/i18n';
const formatters = $derived(createFormatters($store.i18n));
</script>
<div>
<!-- Date formatting -->
<p>{formatters.date(post.date)}</p>
<!-- en: "January 5, 2025" -->
<!-- fr: "5 janvier 2025" -->
<!-- es: "5 de enero de 2025" -->
<!-- Date with custom options -->
<p>{formatters.date(post.date, DateFormats.short)}</p>
<!-- en: "1/5/25" -->
<!-- fr: "05/01/2025" -->
<!-- Number formatting -->
<p>{formatters.number(1234.56)}</p>
<!-- en: "1,234.56" -->
<!-- fr: "1 234,56" -->
<!-- de: "1.234,56" -->
<!-- Currency formatting -->
<p>{formatters.currency(99.99, 'USD')}</p>
<!-- en-US: "$99.99" -->
<!-- fr: "99,99 $US" -->
<!-- de: "99,99 $" -->
<!-- Relative time -->
<p>{formatters.relativeTime(yesterday)}</p>
<!-- en: "yesterday" -->
<!-- fr: "hier" -->
<!-- es: "ayer" -->
</div>
Available DateFormats:
DateFormats.short - "1/5/25"DateFormats.medium - "Jan 5, 2025"DateFormats.long - "January 5, 2025" (default)DateFormats.full - "Monday, January 5, 2025"The framework provides three locale detectors for different environments.
import { createBrowserLocaleDetector } from '@composable-svelte/core/i18n';
const localeDetector = createBrowserLocaleDetector({
supportedLocales: ['en', 'fr', 'es'],
defaultLocale: 'en',
cookieName: 'locale', // Optional: persist preference
storageKey: 'user_locale' // Optional: localStorage key
});
const locale = localeDetector.detect();
// Checks: 1. localStorage, 2. cookie, 3. navigator.language, 4. default
import { createSSRLocaleDetector } from '@composable-svelte/core/i18n';
// In your server handler
const localeDetector = createSSRLocaleDetector({
supportedLocales: ['en', 'fr', 'es'],
defaultLocale: 'en',
url: request.url, // For ?lang=fr query param
cookies: request.headers.get('cookie') ?? '',
acceptLanguage: request.headers.get('accept-language') ?? ''
});
const locale = localeDetector.detect();
// Checks: 1. URL (?lang=fr), 2. cookie, 3. Accept-Language, 4. default
import { createStaticLocaleDetector } from '@composable-svelte/core/i18n';
// For pre-rendering specific locale
const localeDetector = createStaticLocaleDetector('fr', ['en', 'fr', 'es']);
const locale = localeDetector.detect(); // Always returns 'fr'
Three strategies for loading translations based on your use case.
import { FetchTranslationLoader } from '@composable-svelte/core/i18n';
const translationLoader = new FetchTranslationLoader({
baseUrl: '/locales', // Fetches from /locales/{locale}/{namespace}.json
supportedLocales: ['en', 'fr', 'es']
});
File Structure:
public/
locales/
en/
common.json
products.json
fr/
common.json
products.json
import { BundledTranslationLoader } from '@composable-svelte/core/i18n';
// Import translations at build time
import enCommon from '../locales/en/common.json';
import frCommon from '../locales/fr/common.json';
const translationLoader = new BundledTranslationLoader({
bundles: {
'en': { common: enCommon },
'fr': { common: frCommon }
}
});
import { createGlobLoader } from '@composable-svelte/core/i18n';
const translationLoader = createGlobLoader(
import.meta.glob('/src/locales/*/*.json')
);
// src/store.ts
import { createStore } from '@composable-svelte/core';
import {
createInitialI18nState,
i18nReducer,
createBrowserLocaleDetector,
FetchTranslationLoader,
browserDOM
} from '@composable-svelte/core/i18n';
// Detect locale
const localeDetector = createBrowserLocaleDetector({
supportedLocales: ['en', 'fr', 'es'],
defaultLocale: 'en',
storageKey: 'app_locale'
});
const initialLocale = localeDetector.detect();
// Initialize i18n state
const i18nState = createInitialI18nState(initialLocale, ['en', 'fr', 'es'], 'en');
const initialState: AppState = {
todos: [],
i18n: i18nState
};
// Create store with i18n dependencies
const store = createStore({
initialState,
reducer: appReducer,
dependencies: {
translationLoader: new FetchTranslationLoader({
baseUrl: '/locales',
supportedLocales: ['en', 'fr', 'es']
}),
localeDetector,
storage: localStorage,
dom: browserDOM
}
});
// Load initial translations
store.dispatch({
type: 'i18n/loadNamespace',
namespace: 'common'
});
<!-- src/App.svelte -->
<script lang="ts">
import { createTranslator, createFormatters } from '@composable-svelte/core/i18n';
const t = $derived(createTranslator($store.i18n, 'common'));
const formatters = $derived(createFormatters($store.i18n));
function switchLanguage(locale: string) {
store.dispatch({
type: 'i18n/setLocale',
locale,
preloadNamespaces: ['common']
});
}
</script>
<div>
<h1>{t('app.title')}</h1>
<select value={$store.i18n.currentLocale} onchange={(e) => switchLanguage(e.currentTarget.value)}>
<option value="en">English</option>
<option value="fr">Français</option>
<option value="es">Español</option>
</select>
<p>{t('welcome', { name: 'User' })}</p>
<p>{formatters.date(new Date())}</p>
</div>
Server Setup:
// src/server/index.ts
import { renderToHTML } from '@composable-svelte/core/ssr';
import {
createInitialI18nState,
BundledTranslationLoader,
createSSRLocaleDetector,
serverDOM
} from '@composable-svelte/core/i18n';
// Import translations
import enCommon from '../locales/en/common.json';
import frCommon from '../locales/fr/common.json';
app.get('*', async (request, reply) => {
// Detect locale from request
const localeDetector = createSSRLocaleDetector({
supportedLocales: ['en', 'fr'],
defaultLocale: 'en',
url: request.url,
cookies: request.headers.cookie ?? '',
acceptLanguage: request.headers['accept-language'] ?? ''
});
const locale = localeDetector.detect();
// Initialize i18n for this request
const i18nState = createInitialI18nState(locale, ['en', 'fr'], 'en');
const initialState: AppState = {
todos: [],
i18n: i18nState
};
// Render with i18n dependencies
const html = await renderToHTML(App, {
initialState,
reducer: appReducer,
dependencies: {
translationLoader: new BundledTranslationLoader({
bundles: {
'en': { common: enCommon },
'fr': { common: frCommon }
}
}),
localeDetector,
storage: new Map(), // SSR-safe storage
dom: serverDOM
}
});
reply.type('text/html').send(html);
});
Client Hydration:
// src/client/index.ts
import { hydrateStore } from '@composable-svelte/core/ssr';
import {
FetchTranslationLoader,
createBrowserLocaleDetector,
browserDOM
} from '@composable-svelte/core/i18n';
const store = hydrateStore(
document.getElementById('__COMPOSABLE_SVELTE_STATE__')!.textContent,
{
reducer: appReducer,
dependencies: {
translationLoader: new FetchTranslationLoader({
baseUrl: '/locales',
supportedLocales: ['en', 'fr']
}),
localeDetector: createBrowserLocaleDetector({
supportedLocales: ['en', 'fr'],
defaultLocale: 'en'
}),
storage: localStorage,
dom: browserDOM
}
}
);
The framework automatically detects and compiles ICU MessageFormat syntax for advanced translations.
{
"items": "{count, plural, =0 {No items} one {# item} other {# items}}"
}
t('items', { count: 0 }); // "No items"
t('items', { count: 1 }); // "1 item"
t('items', { count: 5 }); // "5 items"
{
"greeting": "{gender, select, male {Hello Mr. {name}} female {Hello Ms. {name}} other {Hello {name}}}"
}
t('greeting', { gender: 'male', name: 'Smith' }); // "Hello Mr. Smith"
t('greeting', { gender: 'female', name: 'Johnson' }); // "Hello Ms. Johnson"
t('greeting', { gender: 'other', name: 'Taylor' }); // "Hello Taylor"
{
"cart": "{itemCount, plural, one {You have # item} other {You have # items}} in {cartCount, plural, one {# cart} other {# carts}}"
}
t('cart', { itemCount: 1, cartCount: 1 }); // "You have 1 item in 1 cart"
t('cart', { itemCount: 5, cartCount: 2 }); // "You have 5 items in 2 carts"
<script lang="ts">
const availableLocales = $derived($store.i18n.availableLocales);
const currentLocale = $derived($store.i18n.currentLocale);
const languageNames: Record<string, string> = {
en: 'English',
fr: 'Français',
es: 'Español'
};
function switchLanguage(locale: string) {
store.dispatch({
type: 'i18n/setLocale',
locale,
preloadNamespaces: ['common'] // Load common translations immediately
});
}
</script>
<div class="language-switcher">
{#each availableLocales as locale}
<button
class:active={locale === currentLocale}
onclick={() => switchLanguage(locale)}
>
{languageNames[locale]}
</button>
{/each}
</div>
Load translations on-demand to reduce initial bundle size.
// Load translations when user navigates to products page
function navigateToProducts() {
store.dispatch({
type: 'i18n/loadNamespace',
namespace: 'products'
});
// Navigate after translations loaded (via effect)
store.dispatch({
type: 'navigate',
destination: { type: 'products' }
});
}
In Reducer:
case 'i18n/namespaceLoaded': {
// Translation loaded, safe to navigate
if (action.namespace === 'products') {
return [state, Effect.none()];
}
return [state, Effect.none()];
}
<script lang="ts">
const authors = ['Alice', 'Bob', 'Carol'];
// The framework uses Intl.ListFormat automatically
const formattedAuthors = $derived(
new Intl.ListFormat($store.i18n.currentLocale, {
style: 'long',
type: 'conjunction'
}).format(authors)
);
</script>
<p>Authors: {formattedAuthors}</p>
<!-- en: "Authors: Alice, Bob, and Carol" -->
<!-- fr: "Authors: Alice, Bob et Carol" -->
<!-- es: "Authors: Alice, Bob y Carol" -->
locales/
en/
common.json # UI labels, buttons, navigation
products.json # Product-related translations
errors.json # Error messages
validation.json # Form validation messages
fr/
common.json
products.json
...
Benefits:
{
"nav.home": "Home",
"nav.products": "Products",
"products.addToCart": "Add to Cart",
"products.outOfStock": "Out of Stock",
"validation.required": "This field is required"
}
WHY: Namespaced keys make it clear where translations are used and prevent conflicts.
{
"cart.items": "{count, plural, =0 {Your cart is empty} one {# item in cart} other {# items in cart}}"
}
WHY: The =0 case lets you provide a more natural message than "0 items".
<script lang="ts">
// ✅ CORRECT - Reactive to locale changes
const formatters = $derived(createFormatters($store.i18n));
// ❌ WRONG - Won't update when locale changes
const formatters = createFormatters($store.i18n);
</script>
import { TestStore } from '@composable-svelte/core/test';
test('displays localized date', async () => {
const store = new TestStore({
initialState: {
post: { date: '2025-01-05' },
i18n: createInitialI18nState('fr', ['en', 'fr'])
},
reducer: appReducer,
dependencies: { /* ... */ }
});
const formatters = createFormatters(store.state.i18n);
expect(formatters.date(store.state.post.date)).toBe('5 janvier 2025');
});
// ❌ WRONG - Translations not loaded yet
store.dispatch({ type: 'i18n/setLocale', locale: 'fr' });
const t = createTranslator(store.state.i18n, 'common'); // May not have French translations!
// ✅ CORRECT - Wait for load or preload
store.dispatch({
type: 'i18n/setLocale',
locale: 'fr',
preloadNamespaces: ['common'] // Loads before completing
});
// ❌ WRONG - i18n actions not handled
const appReducer: Reducer<AppState, AppAction> = (state, action, deps) => {
switch (action.type) {
case 'addTodo':
return [/* ... */];
// Missing i18n handling!
}
};
// ✅ CORRECT - Handle i18n actions
const appReducer: Reducer<AppState, AppAction> = (state, action, deps) => {
// Handle i18n actions first
if (typeof action.type === 'string' && action.type.startsWith('i18n/')) {
const [newI18nState, i18nEffect] = i18nReducer(state.i18n, action as any, deps);
return [
{ ...state, i18n: newI18nState },
i18nEffect as Effect<AppAction>
];
}
switch (action.type) {
case 'addTodo':
return [/* ... */];
}
};
// ❌ WRONG - localStorage doesn't exist on server
const deps = {
storage: localStorage, // Crashes in SSR!
dom: serverDOM
};
// ✅ CORRECT - Use SSR-safe storage
const deps = {
storage: new Map(), // Or mock storage
dom: serverDOM
};
// ❌ WRONG - Formatters created once, won't update
const formatters = createFormatters($store.i18n);
// ✅ CORRECT - Derived, updates when locale changes
const formatters = $derived(createFormatters($store.i18n));
The framework already caches Intl.DateTimeFormat and Intl.NumberFormat instances. Creating formatters is cheap.
// Load translations only when needed
store.dispatch({
type: 'i18n/loadNamespace',
namespace: 'products'
});
Bundled translations are faster than fetch on every request:
const translationLoader = new BundledTranslationLoader({
bundles: {
'en': { common: enCommon },
'fr': { common: frCommon }
}
});
store.dispatch({
type: 'i18n/setLocale',
locale: 'fr',
preloadNamespaces: ['common', 'navigation'] // Load multiple at once
});
For complete SSR/SSG patterns, see the composable-svelte-ssr skill. Key points:
BundledTranslationLoader, createSSRLocaleDetector, serverDOMFetchTranslationLoader, createBrowserLocaleDetector, browserDOMrenderToHTMLcreateStaticLocaleDetector with fixed locale/en/, /fr/, /es/)Example: See examples/ssr-server/src/build/ssg.ts for complete SSG setup.
All types are fully typed and exported:
import type {
I18nState,
I18nAction,
I18nDependencies,
Translator,
BoundFormatters,
TranslationLoader,
LocaleDetector
} from '@composable-svelte/core/i18n';
Use TestStore to test translations and locale switching:
import { TestStore } from '@composable-svelte/core/test';
import { createTranslator } from '@composable-svelte/core/i18n';
test('switches locale', async () => {
const store = new TestStore({
initialState: {
i18n: createInitialI18nState('en', ['en', 'fr'])
},
reducer: appReducer,
dependencies: mockI18nDependencies
});
await store.send({ type: 'i18n/setLocale', locale: 'fr', preloadNamespaces: ['common'] });
await store.receive({ type: 'i18n/namespaceLoaded', namespace: 'common' }, (state) => {
expect(state.i18n.currentLocale).toBe('fr');
});
});
Core Principles:
i18nReducercreateTranslator() for translations, createFormatters() for dates/numbers/currencyMost Common Pattern:
<script lang="ts">
import { createTranslator, createFormatters } from '@composable-svelte/core/i18n';
const t = $derived(createTranslator($store.i18n, 'common'));
const formatters = $derived(createFormatters($store.i18n));
</script>
<div>
<h1>{t('app.title')}</h1>
<p>{t('welcome', { name: user.name })}</p>
<time>{formatters.date(post.date)}</time>
<span>{formatters.currency(product.price, 'USD')}</span>
</div>
The i18n system is fully integrated with the Composable Architecture, providing a complete, type-safe, and performant solution for building multi-language applications.