| name | composable-svelte-i18n |
| description | Internationalization (i18n) system for Composable Svelte. Use when implementing multi-language support, translations, date/number formatting, locale detection, or localizing applications. Covers translation loading, ICU MessageFormat, formatters (dates/numbers/currency), locale detection, SSR integration, and the i18n reducer. |
Composable Svelte Internationalization (i18n)
Complete i18n solution integrated with the Composable Architecture. Handles translations, formatting (dates, numbers, currency), locale detection, and works seamlessly with SSR/SSG.
CRITICAL RULES
Rule 1: i18n State Lives in Store
Principle: i18n state is part of your application state, integrated via the i18n reducer.
✅ CORRECT - i18n in Store State
import { createInitialI18nState, i18nReducer } from '@composable-svelte/core/i18n';
interface AppState {
todos: Todo[];
i18n: I18nState;
}
type AppAction =
| { type: 'addTodo'; text: string }
| I18nAction;
const appReducer: Reducer<AppState, AppAction> = (state, action, deps) => {
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>
];
}
return [state, Effect.none()];
};
❌ WRONG - Separate i18n Store
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.
Rule 2: Use Framework Formatters, Not Manual Formatting
Principle: The framework provides locale-aware formatters. Never manually format dates, numbers, or currency.
✅ CORRECT - Framework Formatters
<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>
❌ WRONG - Manual Formatting
<!-- ❌ 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").
CORE CONCEPTS
1. Translation System
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}}}"
}
2. Formatters (Dates, Numbers, Currency)
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"
3. Locale Detection
The framework provides three locale detectors for different environments.
Browser (Client-Side)
import { createBrowserLocaleDetector } from '@composable-svelte/core/i18n';
const localeDetector = createBrowserLocaleDetector({
supportedLocales: ['en', 'fr', 'es'],
defaultLocale: 'en',
cookieName: 'locale',
storageKey: 'user_locale'
});
const locale = localeDetector.detect();
SSR (Server-Side Rendering)
import { createSSRLocaleDetector } from '@composable-svelte/core/i18n';
const localeDetector = createSSRLocaleDetector({
supportedLocales: ['en', 'fr', 'es'],
defaultLocale: 'en',
url: request.url,
cookies: request.headers.get('cookie') ?? '',
acceptLanguage: request.headers.get('accept-language') ?? ''
});
const locale = localeDetector.detect();
SSG (Static Site Generation)
import { createStaticLocaleDetector } from '@composable-svelte/core/i18n';
const localeDetector = createStaticLocaleDetector('fr', ['en', 'fr', 'es']);
const locale = localeDetector.detect();
4. Translation Loading
Three strategies for loading translations based on your use case.
Fetch Loader (Client-Side, Lazy Loading)
import { FetchTranslationLoader } from '@composable-svelte/core/i18n';
const translationLoader = new FetchTranslationLoader({
baseUrl: '/locales',
supportedLocales: ['en', 'fr', 'es']
});
File Structure:
public/
locales/
en/
common.json
products.json
fr/
common.json
products.json
Bundled Loader (SSR, Build-Time)
import { BundledTranslationLoader } from '@composable-svelte/core/i18n';
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 }
}
});
Glob Loader (Vite-Specific, Auto-Discovery)
import { createGlobLoader } from '@composable-svelte/core/i18n';
const translationLoader = createGlobLoader(
import.meta.glob('/src/locales/*/*.json')
);
COMPLETE SETUP EXAMPLES
Client-Only Application
import { createStore } from '@composable-svelte/core';
import {
createInitialI18nState,
i18nReducer,
createBrowserLocaleDetector,
FetchTranslationLoader,
browserDOM
} from '@composable-svelte/core/i18n';
const localeDetector = createBrowserLocaleDetector({
supportedLocales: ['en', 'fr', 'es'],
defaultLocale: 'en',
storageKey: 'app_locale'
});
const initialLocale = localeDetector.detect();
const i18nState = createInitialI18nState(initialLocale, ['en', 'fr', 'es'], 'en');
const initialState: AppState = {
todos: [],
i18n: i18nState
};
const store = createStore({
initialState,
reducer: appReducer,
dependencies: {
translationLoader: new FetchTranslationLoader({
baseUrl: '/locales',
supportedLocales: ['en', 'fr', 'es']
}),
localeDetector,
storage: localStorage,
dom: browserDOM
}
});
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>
SSR Application
Server Setup:
import { renderToHTML } from '@composable-svelte/core/ssr';
import {
createInitialI18nState,
BundledTranslationLoader,
createSSRLocaleDetector,
serverDOM
} from '@composable-svelte/core/i18n';
import enCommon from '../locales/en/common.json';
import frCommon from '../locales/fr/common.json';
app.get('*', async (request, reply) => {
const localeDetector = createSSRLocaleDetector({
supportedLocales: ['en', 'fr'],
defaultLocale: 'en',
url: request.url,
cookies: request.headers.cookie ?? '',
acceptLanguage: request.headers['accept-language'] ?? ''
});
const locale = localeDetector.detect();
const i18nState = createInitialI18nState(locale, ['en', 'fr'], 'en');
const initialState: AppState = {
todos: [],
i18n: i18nState
};
const html = await renderToHTML(App, {
initialState,
reducer: appReducer,
dependencies: {
translationLoader: new BundledTranslationLoader({
bundles: {
'en': { common: enCommon },
'fr': { common: frCommon }
}
}),
localeDetector,
storage: new Map(),
dom: serverDOM
}
});
reply.type('text/html').send(html);
});
Client Hydration:
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
}
}
);
ICU MessageFormat
The framework automatically detects and compiles ICU MessageFormat syntax for advanced translations.
Pluralization
{
"items": "{count, plural, =0 {No items} one {# item} other {# items}}"
}
t('items', { count: 0 });
t('items', { count: 1 });
t('items', { count: 5 });
Selection (Gender, etc.)
{
"greeting": "{gender, select, male {Hello Mr. {name}} female {Hello Ms. {name}} other {Hello {name}}}"
}
t('greeting', { gender: 'male', name: 'Smith' });
t('greeting', { gender: 'female', name: 'Johnson' });
t('greeting', { gender: 'other', name: 'Taylor' });
Nested Plurals
{
"cart": "{itemCount, plural, one {You have # item} other {You have # items}} in {cartCount, plural, one {# cart} other {# carts}}"
}
t('cart', { itemCount: 1, cartCount: 1 });
t('cart', { itemCount: 5, cartCount: 2 });
COMMON PATTERNS
Pattern 1: Language Switcher
<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>
Pattern 2: Lazy Loading Namespaces
Load translations on-demand to reduce initial bundle size.
function navigateToProducts() {
store.dispatch({
type: 'i18n/loadNamespace',
namespace: 'products'
});
store.dispatch({
type: 'navigate',
destination: { type: 'products' }
});
}
In Reducer:
case 'i18n/namespaceLoaded': {
if (action.namespace === 'products') {
return [state, Effect.none()];
}
return [state, Effect.none()];
}
Pattern 3: Formatted Lists
<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" -->
BEST PRACTICES
1. Organize Translation Files by Namespace
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:
- Lazy load translations per feature
- Easier maintenance
- Smaller initial bundle
2. Use Descriptive Keys
{
"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.
3. Provide Context in Plurals
{
"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".
4. Keep Formatters as Derived Values
<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>
5. Test with Multiple Locales
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');
});
COMMON GOTCHAS
Gotcha 1: Not Awaiting Translation Load
store.dispatch({ type: 'i18n/setLocale', locale: 'fr' });
const t = createTranslator(store.state.i18n, 'common');
store.dispatch({
type: 'i18n/setLocale',
locale: 'fr',
preloadNamespaces: ['common']
});
Gotcha 2: Forgetting to Handle i18n Actions in Reducer
const appReducer: Reducer<AppState, AppAction> = (state, action, deps) => {
switch (action.type) {
case 'addTodo':
return [];
}
};
const appReducer: Reducer<AppState, AppAction> = (state, action, deps) => {
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 [];
}
};
Gotcha 3: Using Wrong Storage on Server
const deps = {
storage: localStorage,
dom: serverDOM
};
const deps = {
storage: new Map(),
dom: serverDOM
};
Gotcha 4: Not Binding Formatters to Locale
const formatters = createFormatters($store.i18n);
const formatters = $derived(createFormatters($store.i18n));
PERFORMANCE TIPS
1. Cache Formatters Per Locale
The framework already caches Intl.DateTimeFormat and Intl.NumberFormat instances. Creating formatters is cheap.
2. Lazy Load Namespaces
store.dispatch({
type: 'i18n/loadNamespace',
namespace: 'products'
});
3. Use Bundled Loader for SSR
Bundled translations are faster than fetch on every request:
const translationLoader = new BundledTranslationLoader({
bundles: {
'en': { common: enCommon },
'fr': { common: frCommon }
}
});
4. Preload Critical Namespaces
store.dispatch({
type: 'i18n/setLocale',
locale: 'fr',
preloadNamespaces: ['common', 'navigation']
});
SSR/SSG INTEGRATION
For complete SSR/SSG patterns, see the composable-svelte-ssr skill. Key points:
SSR
- Server: Use
BundledTranslationLoader, createSSRLocaleDetector, serverDOM
- Client: Use
FetchTranslationLoader, createBrowserLocaleDetector, browserDOM
- State serializes automatically via
renderToHTML
SSG (Static Sites)
- Use
createStaticLocaleDetector with fixed locale
- Generate separate HTML files per locale (
/en/, /fr/, /es/)
- Language switcher uses links to pre-generated pages
Example: See examples/ssr-server/src/build/ssg.ts for complete SSG setup.
TYPESCRIPT SUPPORT
All types are fully typed and exported:
import type {
I18nState,
I18nAction,
I18nDependencies,
Translator,
BoundFormatters,
TranslationLoader,
LocaleDetector
} from '@composable-svelte/core/i18n';
TESTING
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');
});
});
SUMMARY
Core Principles:
- i18n state lives in your app store via
i18nReducer
- Use
createTranslator() for translations, createFormatters() for dates/numbers/currency
- Framework handles all locale-specific formatting automatically
- ICU MessageFormat for pluralization and selection
- Three loaders: Fetch (client), Bundled (SSR), Glob (Vite)
- Three detectors: Browser, SSR, Static (SSG)
Most 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.