Best Practices
This guide covers recommended patterns, conventions, and tips for using
sveltekit-i18n in production applications.
It assumes the v3 surface: one reactive instance, no stores, no $ prefixes —
the tour is in Getting Started. Member-level detail
lives in the
core reference;
this document is about what to do with those members.
The snippets import from src/lib through #lib, the entry sv create
scaffolds in the imports field of a SvelteKit 3 app's package.json, and name
the file's extension, which TypeScript needs to resolve such an import. A
SvelteKit 2 app adds the same entry, "imports": { "#lib/*": "./src/lib/*" },
or imports from $lib instead, without the extension — as it must on Vite 5
when a .ts file is imported from a .js module or a plain <script>.
Table of Contents
- Instance Ownership
- Awaiting Loads
- SSR and CSR Considerations
- Translation File Organization
- Key Naming Conventions
- Performance Optimization
- TypeScript Patterns
- Extensions
- Component-Scoped Translations
- Library Authors: Shipping Translations
- Dynamic Routes and Locales
- Content Management
- Testing
- Production Deployment
Instance Ownership
An instance owns a locale and the tables loaded for it. Whoever holds the instance shares that state, which makes where it is created the most consequential decision in the whole setup.
The rule: one instance per request on the server, one instance per tab in
the browser. What modules export is the config — inert data — and the
functions sveltekit-i18n/kit builds from it.
The module-level singleton hazard
A module that constructs an instance is evaluated once per process on the server, not once per request:
// ❌ src/lib/i18n.js — shared by every concurrent request
import { I18n } from 'sveltekit-i18n';
export const i18n = new I18n(config);Two visitors rendered at the same time overwrite each other's locale and
translation tables, and one visitor's language ends up in the other's HTML. The
failure is load-dependent: a single-user dev session never shows it.
// ✅ src/lib/i18n.js — the config is inert data, the wiring builds instances
import { defineI18n } from 'sveltekit-i18n/kit';
export const config = {
initLocale: 'en',
loaders: [/* … */],
};
export const { handle, load, use, get } = defineI18n(config);Each request builds its own instance from it, and components reach that
instance through get() — Svelte context under the hood — rather than through
an import of the instance. See
SSR and CSR Considerations for the wiring.
When a singleton is enough
The shared-state problem exists only on the server. A module-level instance is safe when the server renders nothing visitor-specific:
- the app is client-only (
export const ssr = false), or - every request renders the same locale, no loader's
routescapture params (concurrent requests would compete for whose params are shown), and no loader throws aredirect()or anerror()that depends on the visitor — concurrent requests share a load, so every one of them would reject with it.
In the browser, a singleton's root load runs for a preload too — the page a
hovered link leads to. Load it as steps 3 and 4 of the
manual recipe
do, so a hovered link does not switch it: the first pass calls
loadTranslations(), every later one preload(), and the root layout commits
the navigation. A module-level flag the first pass sets in the browser only
(let started = false) tells that pass apart, as client does in step 3.
Everything else — including "we will add a second language later" — wants the per-request wiring from the start. Retrofitting it means touching every module that imported the instance.
Do not destructure value properties
A destructured value is a one-time snapshot; the property read is what is reactive.
<script>
// ❌ frozen at the moment of destructuring
const { locale, loading } = get();
</script><script>
import { get } from '#lib/i18n.js';
const i18n = get();
// ✅ each binding stays in sync with the instance
const { locale, loading } = $derived(i18n);
</script>
{#if loading}Loading…{:else}<p>{locale}</p>{/if}t and l are the exception: they are functions, and their tracked reads
happen at call time, so const { t } = i18n stays reactive and can be passed to
a child component.
Release instances that outlive their work
destroy()
detaches an instance from its loading lifecycle: in-flight loads settle with
their data discarded, loading drops to false, further load calls are ignored
with a warning, and reads keep working so a component still tearing down
renders instead of breaking.
<script>
import { I18n } from 'sveltekit-i18n';
import { widgetConfig } from './translations';
const i18n = new I18n(widgetConfig);
$effect(() => () => i18n.destroy());
</script>A per-request instance that has awaited its load has nothing in flight and needs
no call. A browser instance living as long as the app needs none either — the
instances /kit builds included.
Awaiting Loads
Every load-triggering method — loadTranslations, loadNamespace,
setLocale, setRoute, loadConfig — returns the promise of the matching
load. Concurrent duplicate triggers for the same locale and route join the load
already in flight instead of fetching twice, and receive its promise.
That makes await the whole coordination story:
// ✅ the panel opens with its namespace present
await i18n.loadNamespace('editor');
editorOpen = true;With /kit, the root layout's load awaits the
route's translations for you, so a page renders with them present — on a
client navigation, load preloads them and the navigation shows them as it
commits.
Never poll loading. It is a UI flag — true while any load is in
flight — not a synchronization primitive. A wall-clock wait is worse: it flakes
on slow CI and races on fast networks.
// ❌ waits for the wrong thing, or for nothing at all
while (i18n.loading) await tick();
await new Promise((resolve) => setTimeout(resolve, 100));Assigning locale is deliberately fire-and-forget. i18n.locale = 'cs' is
a shorthand for setLocale('cs') whose promise nobody holds; the property
advances once the new locale's translations resolved, so the UI never shows a
language whose text is not there. Await the method where the next step depends
on the switch having happened:
await i18n.setLocale('cs');
document.documentElement.lang = i18n.locale;Errors. A loader that throws is caught and logged individually, so one
broken loader never fails the batch. The exception is SvelteKit's redirect()
and error() below 500: thrown from a loader, they reject the load once the
others settled, the locale does not advance, and the call is undone — awaited
in a load, SvelteKit follows them; in an event handler, your code does
(isRedirect(), isHttpError()). Anything that throws after the loaders — a
custom preprocess, a malformed payload — rejects the returned promise, which
in a SvelteKit load goes to the error page. A promise you discard is safe (the
failure is reported through the configured logger and never becomes an
unhandled rejection), but it is then only in the log.
initialized is the flag for "a locale and route are set and translations
are present" — the one to gate a first render on, where anything needs gating at
all. With the SSR wiring below, the server already rendered the text.
SSR and CSR Considerations
sveltekit-i18n/kit is the wiring: the server builds
an instance per request, negotiates the locale, loads it for the route and
hands its state to the browser, which keeps one instance per tab and does not
fetch again what the server loaded.
Getting Started walks
through it with the translation files in place. Condensed:
// src/lib/i18n.js
import { defineI18n } from 'sveltekit-i18n/kit';
export const config = {/* … */};
export const { handle, load, use, get } = defineI18n(config, {
preferredLocale: (event) => event.cookies?.get('lang'),
});// src/hooks.server.js
export { handle } from '#lib/i18n.js';
// src/routes/+layout.server.js and src/routes/+layout.js
export { load } from '#lib/i18n.js';<!-- src/routes/+layout.svelte -->
<script>
import { use } from '#lib/i18n.js';
let { data, children } = $props();
use(() => data);
</script>
{@render children()}Context is what keeps a per-request instance per-request: get() reads it, and
nothing imports the instance, so nothing can share it between visitors.
An app that needs something the wiring does not do builds the same flow by
hand — Server-Side Rendering is the recipe:
snapshot({ records: true }) on the server, hydrate() on the client.
What the hand-off carries
snapshot({ records: true }) serializes what the server instance holds for the
active locale and the fallbackLocale, together with the records of the
loaders that delivered it, the locale and the route. hydrate() displays the
data and keeps those loaders from running again for the same route params:
- Other locales are left out. The client instance is built from the config,
so its own
translations— the language names a switcher renders — stay. - Seeds do not count as loaded. Data passed to
addTranslations()orconfig.translationsfills the tables but records nothing, so its namespace's loaders still run. Handing a snapshot over withaddTranslations()makes the client fetch everything again. - The data is pre-preprocess, so the receiving instance applies its own
config.preprocess. - Freshness is not transferred. A hydrated locale's
cachewindow starts when the client receives the data.
No flash of untranslated content
Translations awaited in the root layout's load are in the HTML the server
sent — view the page source to confirm. Gating the render on
i18n.initialized or i18n.loading is for the client-only case; adding it to
an SSR app hides content that was already there.
Resolve the locale where the server can see it
A cookie, a route param or the Accept-Language header is readable on the
server; localStorage is not. Put the visitor's choice in a cookie (write it
from the switcher) and read it in preferredLocale:
export const { handle, load, use, get } = defineI18n(config, {
preferredLocale: (event) => event.cookies?.get('lang'),
});The wiring tries that first, then Accept-Language, then initLocale and
fallbackLocale, and matches each against the configured locales with
matchLocale: a visitor sending
en-GB gets en, and a region-tagged configured locale is matched as it is
spelled. When none matches, the first locale the config serves is taken — set
initLocale to choose the locale such a visitor gets, rather than leave it to
the order of the loaders.
preferredLocale runs on every navigation and every preload, so it only reads
the event.
Prerendering
A prerendered page is one HTML file, so it can carry exactly one locale. Give
each locale its own URL (see Dynamic Routes and
Locales) and resolve the locale from the route
parameter in preferredLocale instead of from a cookie — a prerendered route
has no visitor to read one from.
The build's answer travels with the page: a link or Back to a prerendered page
switches the tab to the locale preferredLocale gave it at build time. With
an optional segment ([[lang]]), let the unprefixed pages name the default
locale, or they keep whatever locale the tab shows:
preferredLocale: (event) => event.params.lang ?? 'en',A default for visitors whose cookie or header names no locale belongs in
initLocale instead: one returned from preferredLocale says the URL names
that locale.
Translation File Organization
Directory Structure
Organize translations by locale and namespace:
src/lib/
├── i18n.js # the config, and what defineI18n builds from it
└── translations/
├── en/
│ ├── common.json # shared UI, navigation
│ ├── home.json # homepage
│ ├── about.json # about page
│ ├── products.json # product pages
│ └── errors.json # error messages
├── cs/
│ ├── common.json
│ ├── home.json
│ ├── about.json
│ ├── products.json
│ └── errors.json
└── de/
├── common.json
└── ...✅ Benefits:
- Easy to find translations
- Clear separation by locale
- Scalable structure
Namespace Strategy
A loader's namespace prefixes every key the loader returns, so
{ "greeting": "…" } loaded under namespace: 'common' is read as
t('common.greeting'). A namespace must not contain a . — the dot is the
separator the flattened tables are keyed by. (key is the deprecated 3.0
spelling of namespace; it still works and logs a warning once per loader.)
Common namespace
Keep frequently used translations in a common namespace loaded on every page:
// common.json
{
"app.name": "My App",
"nav.home": "Home",
"nav.about": "About",
"button.save": "Save",
"button.cancel": "Cancel",
"error.required": "This field is required"
}Keep it small – only essentials that appear across multiple pages.
Page-specific namespaces
Give each major page or section its own namespace and scope it with routes.
A descriptor may list several locales, and the loader computes its file from
its props:
const fromFile = async ({ locale, namespace }) => (await import(`./translations/${locale}/${namespace}.json`)).default;
const locale = ['en', 'cs', 'de'];
export const config = {
loaders: [
// Common (every page)
{ locale, namespace: 'common', loader: fromFile },
// Page-specific
{ locale, namespace: 'home', routes: ['/'], loader: fromFile },
{ locale, namespace: 'about', routes: ['/about'], loader: fromFile },
{ locale, namespace: 'products', routes: [/^\/products/], loader: fromFile },
],
};A namespace may be split across routes. Each loader is recorded on its own, so several loaders can fill one namespace: each part loads where its route matches and merges into what the others delivered. That keeps one prefix for what belongs together while each page fetches only its part:
{ locale, namespace: 'shop', routes: ['/shop'], loader: async ({ locale }) => (await import(`./translations/${locale}/shop-list.json`)).default },
{ locale, namespace: 'shop', routes: [/^\/shop\/[^/]+$/], loader: async ({ locale }) => (await import(`./translations/${locale}/shop-detail.json`)).default },Where both parts declare the same key, the data applied last wins. The
collision is logged (at warn) only when both parts are applied together — one
load delivering both, or the namespace rebuilt because one of its loaders
delivered again (for new params, after expiry or invalidate(), or as a
cache: false loader); parts that arrive in separate loads, as route-scoped
ones usually do, overwrite each other silently otherwise. Keep their keys disjoint.
Feature-based organization
For large apps, organize by feature instead of page:
translations/
├── en/
│ ├── common.json
│ ├── auth.json # login, register, password reset
│ ├── checkout.json # cart, payment, confirmation
│ ├── profile.json # user profile, settings
│ └── admin.json # admin panelconst loaders = [
{ locale, namespace: 'auth', routes: ['/login', '/register', '/reset-password'], loader: fromFile },
{ locale, namespace: 'checkout', routes: [/^\/cart/, /^\/checkout/], loader: fromFile },
];File Size Guidelines
Target sizes:
common.json: < 5 KB (essential shared content)- Page-specific: < 20 KB per file
- If larger, split it — into sub-namespaces, or into route-scoped loaders of one namespace
products/
├── list.json # product listing page
├── detail.json # product detail page
├── compare.json # product comparison
└── reviews.json # product reviewsKey Naming Conventions
Use dot notation
Organize keys hierarchically with dots — either in the key itself, or as nested
JSON, which the default preprocess: 'full' flattens to the same thing:
{
"user.profile.name": "Name",
"user.profile.email": "Email"
}{
"user": {
"profile": { "name": "Name", "email": "Email" }
}
}Both are read as t('<namespace>.user.profile.name'), where <namespace> is
the loader's namespace. Arrays flatten too (items.0, items.1); preprocess: 'preserveArrays' keeps them as arrays.
Descriptive names
// ❌ Bad
{ "t1": "Welcome", "btn": "Click", "txt": "Hello" }
// ✅ Good
{
"home.welcome.title": "Welcome",
"common.button.submit": "Submit",
"greeting.message": "Hello"
}Consistency patterns
Establish naming patterns and stick to them:
{
"home.title": "Home",
"about.title": "About",
"form.label.name": "Name",
"form.placeholder.search": "Search...",
"form.error.required": "Required",
"button.submit": "Submit",
"button.cancel": "Cancel",
"message.success.saved": "Successfully saved",
"message.error.failed": "Operation failed"
}Avoid deep nesting
❌ Too deep (harder to maintain):
{ "pages.user.profile.settings.privacy.options.visibility.public": "Public" }✅ Better (balanced):
{ "profile.privacy.public": "Public" }Rule of thumb: max 3–4 levels deep, the namespace included.
Context in keys
Include context when the same word has different meanings:
{
"common.button.close": "Close",
"common.adjective.close": "Near",
"store.status.open": "Open",
"action.open": "Open file"
}Avoid prototype names as key segments
A lookup is an own-property read, so toString, constructor, valueOf and
__proto__ resolve as missing translations, never as inherited members.
That is deliberate — a visitor-controlled key cannot reach the prototype chain —
but it means a message whose whole key is such a name can never be read: a
top-level toString in config.translations, say. A namespaced key is safe —
preprocessing stores common.toString as one own property of that name, so the
lookup finds it.
The same applies to locale codes: they are keys too, and they key the tables
through sanitizeLocales.
With extension-typed-access, the first-level
members of t named like what a function answers (name, length, call,
toString, …) read the real t, so avoid these as namespace names, or reach
their keys through the string form (t('name.first')). The extension's README
lists them:
reserved names.
then reads undefined at every level, so a key under it, at any depth, takes
the string form too.
Performance Optimization
Lazy loading by route
Load a namespace only where it is used:
// `locale` and `fromFile` as under Namespace Strategy
const loaders = [
// Always loaded
{ locale, namespace: 'common', loader: fromFile },
// Loaded on /admin and below
{ locale, namespace: 'admin', routes: [/^\/admin/], loader: fromFile },
];routes entries may be exact strings, regular expressions, or anything with a
test(route) method. The route reaching them is the bare path
(/products/123), without basePath.
What an interaction needs rather than a route — a modal, an editor — loads on
demand with loadNamespace(), whatever the loader's routes say:
await i18n.loadNamespace('editor');⚠️ A dev-supplied route regex is the one ReDoS surface here — the route
comes from url.pathname, which a visitor controls. Keep route patterns simple
and anchored (/^\/products/), and never build one from user input.
Dynamic imports
// ❌ static import — always in the initial bundle
import translations from './large-translations.json';
// ✅ dynamic import — its own chunk, fetched when the loader runs
loader: async () => (await import('./large-translations.json')).defaultPreloading the next page
With /kit, SvelteKit's own link preloading
(data-sveltekit-preload-data) runs the root layout's load, which preloads
the target route's translations without changing what is shown — the next
page's translations come along with its data, and the navigation shows them as
it commits, with nothing to wire up. Where preloading costs too much, turn it
off: data-sveltekit-preload-data="false".
By hand, preload() is the request of a
navigation that may never commit. It fetches what a locale and route need —
judging the cache window and running cache: false loaders as the
navigation would — without switching to them: neither the locale, the route
nor loading changes. It resolves to a token, which the call that commits the
navigation takes as { preloaded } to show what the preload fetched instead of
fetching it again:
// Hovering a link to the German about page
const preloaded = await i18n.preload('de', '/about');
// Following it
await i18n.loadTranslations('de', '/about', { preloaded });A plain loadTranslations(locale, route) activates the locale and sets the
route, so keep it for the case where you are switching.
loadTranslations(locale, route, { activate: false }) only fills the tables,
for data a page may need later: it leaves expiry to the call that activates,
and runs a cache: false loader, which the activating call then runs again.
Synchronous translations for the first paint
config.translations is available before any loader runs — the right place for
the handful of strings a language switcher needs in every language:
/** @type {import('sveltekit-i18n').Config} */
export const config = {
translations: {
en: { 'lang.en': 'English', 'lang.cs': 'Czech' },
cs: { 'lang.en': 'Angličtina', 'lang.cs': 'Čeština' },
},
loaders: [/* … */],
};fallbackLocale costs a second load
A fallback locale is loaded alongside the active one, roughly doubling the fetched payload:
// Loads 'cs' and 'en' on every page
const config = { fallbackLocale: 'en' };Use it for a gradual rollout (new features in one language, translated later) or for genuinely partial catalogues — not as a permanent default once the translations are complete.
Caching
config.cache is how long a locale's loaded translations stay fresh, in
milliseconds. The default is Number.POSITIVE_INFINITY: each loader runs once
per locale and route params, which is right when translation files ship with
the app and change only with a deploy.
// Runtime source (CMS, translation service, database): refetch on the first
// load trigger after an hour
const config = { cache: 3600000 };
// Always stale — every load trigger refetches
const config = { cache: 0 };Expiry is evaluated on the next activating load trigger
(loadTranslations, setLocale, setRoute) or preload(); nothing refetches
in the background, a warm load ({ activate: false }, loadNamespace())
leaves expiry to the next activating one, and a call handed a preload's token
leaves it to that preload. And expiry refreshes, it never removes by
itself: what is displayed stays until the refetch lands, and then the fresh data
replaces what that loader delivered before, so a message the source dropped
goes and falls back to fallbackLocale.
A source that caches on its own — a SvelteKit remote query, an SWR layer,
an HTTP cache — gets cache: false on its loader. The core then keeps no
freshness for it: it runs on every trigger that selects it, and its source
decides what is fresh. Without it, a loader that answers from its own cache
after invalidate() hands back the same stale table, and the core stamps it
fresh anyway.
⚠️ On the server, cache has little to hold on to. A per-request instance
lives for one render, so its bookkeeping dies with it — the cache that matters
there belongs to the loader's own fetch (HTTP caching, or a module-level
memo). In the browser the instance lives for the session, and that is where
cache and invalidate() do their work.
Invalidation
invalidate(locale?, namespace?) marks loaded translations stale — one
locale or all of them, one namespace or all of them. It starts no load and
removes nothing from the tables; loaders run again on the next load trigger. A
loader in flight when it is called is severed: its pre-invalidation data is
discarded, while the rest of its load lands.
// An admin action or a CMS webhook told us the English content changed
i18n.invalidate('en');
// Only the CMS-backed namespace changed, in every language
i18n.invalidate(undefined, 'content');
// Nothing has happened yet — the next trigger refetches
await i18n.loadTranslations('en', location.pathname);Invalidate the narrowest thing that changed: a namespace invalidation leaves every other namespace loaded.
Keep the infinite cache default and call invalidate() for event-driven
refreshes; the two compose, and a finite cache can still be forced early this
way.
TypeScript Patterns
The config type
import { I18n, type Config } from 'sveltekit-i18n';
const config: Config = {
loaders: [
{
locale: 'en',
namespace: 'common',
loader: async () => (await import('./en/common.json')).default,
},
],
};
export const i18n = new I18n(config);Config is the core's config without the parser slot (this package fills
it) and with parserOptions for the Curly Message Format's options.
Annotating widens. const config: Config = … makes the annotation, not the
literal, the type the constructor sees — which costs the locale completion the
literal would have given setLocale(), l(), invalidate() and locale. Pass
the literal straight to the constructor, or keep the literal type with
as const satisfies Config:
import { I18n, type Config } from 'sveltekit-i18n';
export const config = {
initLocale: 'en',
loaders: [{ locale: 'cs', namespace: 'common', loader: async () => ({}) }],
} as const satisfies Config;
const i18n = new I18n(config);
i18n.locale; // 'en' | 'cs' | (string & {}) | undefinedThe completion is a hint, never a constraint — a locale can arrive from a
URL, a cookie or an Accept-Language header, so any string still compiles. One
dynamic source (loaders built by mapping over a string[]) degrades the whole
union to string, on purpose: a half-known set would complete some locales
while hiding the rest.
Typed keys and payloads with schema
A schema maps each translation key to the payload its message expects.
Register it once for the app: a global script fills the global
SvelteKitI18n.Register interface, and every instance whose config states no
schema is typed by it — new I18n(config), and get(), use() and
data.i18n from defineI18n(config) — with nothing to wire:
// src/i18n-schema.d.ts — a global script: no top-level import or export
interface TranslationSchema {
'common.greeting': { name: string }; // payload required
'common.about': never; // message takes no parameters
'home.title': { title?: string }; // nothing required — payload optional
}
declare namespace SvelteKitI18n {
interface Register {
schema: TranslationSchema;
}
}import { I18n } from 'sveltekit-i18n';
export const i18n = new I18n(config); // typed by TranslationSchema
i18n.t('common.greeting', { name: 'Alice' }); // ok
i18n.t('common.about'); // ok — takes no payload
i18n.t('common.greting', { name: 'Alice' }); // Error: not a key of the schema
i18n.t('common.greeting', {}); // Error: `name` is requiredA config may state its own schema instead, which wins over the registry. Only
its type is read, which is why {} as … is the idiom:
new I18n({ ...config, schema: {} as TranslationSchema }).
Rules worth knowing:
never,undefined,voidornull— the payload argument must be omitted.any— the payload slot stays unchecked (not the same as "no payload").- A union of keys (
t(cond ? 'a' : 'b', …)) takes the intersection of their payloads. - A schema whose keys are not a closed set (
Record<string, …>, or no keys at all) degrades to plainstringkeys instead of rejecting every call — soschema: {}opts an instance out of the registry, where the constructor infers the config's type (a type argument decides on its own). A second instance with a catalogue of its own, a test or a story states its closed schema, or opts out. - The registry covers the whole program: only the app registers. A library
never ships a registration — its own instances state their schema, or
schema: {}. Two registrations whoseschemadiffers are a type error (TS2717) withskipLibCheck: false, and silent with SvelteKit's defaultskipLibCheck: true, where the first one wins. - The registry needs
sveltekit-i18n3.1 or newer; an older core ignores it without a diagnostic, so state the schema per instance there. - Construction time only. A later
loadConfig()cannot retype an existing instance. With/kit, the config handed todefineI18n()— or the registry, when it states no schema — typesget(),use()anddata.i18n.
Generate it. A hand-written schema drifts from the catalogue it describes.
@sveltekit-i18n/typegen is a
Vite plugin, installed on its own, that writes it from your translations on
vite build and while vite dev runs:
// vite.config.js
import { sveltekit } from '@sveltejs/kit/vite';
import { typegen } from '@sveltekit-i18n/typegen';
export default {
plugins: [
sveltekit(),
typegen({ config: 'src/lib/i18n.js', extractParams: { from: 'sveltekit-i18n' } }),
],
};The file typegen writes registers the schema, so the config needs no schema:
export const { handle, load, use, get } = defineI18n(config);A cast of the slot still types the instance, and overrides the registered schema for it:
export const { handle, load, use, get } = defineI18n({ ...config, schema: {} as TranslationSchema });sveltekit-i18n 3.0 has neither /kit nor the registry: cast the slot of the
config handed to the constructor, new I18n({ ...config, schema: {} as TranslationSchema }).
It runs the config's loaders as the app's server would, so the keys are the
ones preprocess produces; extractParams reads payloads through the
re-exported extractParamsFactory. Its
options reach the extractor as JSON, so a custom modifier (a function) never
does, and what it changes about a payload goes unreported — see
extractParams.
Keep the generated src/i18n-schema.d.ts out of Git, and let CI's
vite build regenerate it. A namespace whose loader cannot run at build time
(a remote query, routes that capture params) is typed open: any key under
it, unchecked.
The same schema types keys as members of t — t.home.title() — with
extension-typed-access, which reads the levels the
generated file registers (typegen 3.1 and newer).
One payload type for every message
State it through the type arguments. Annotating the config variable does not type the payload:
import { I18n, type Config } from 'sveltekit-i18n';
type Payload = { name: string };
const config: Config<Payload> = { loaders: [/* … */] };
export const i18n = new I18n<Config<Payload>, Payload>(config);
i18n.t('greeting', { name: 'Jarda' }); // ok
i18n.t('greeting', { nmae: 'Jarda' }); // Error: typo caughtUse schema for per-key payloads, this for a catalogue where every message
takes the same shape. It holds only while no schema is registered:
Config<Payload> leaves the schema slot any, so a registered schema types
the instance instead — the
opt-out replaces the slot in
the type argument.
Custom modifier props
A custom modifier's per-call options ride the third argument of t. Type them
once and spell them as the third type argument:
import { I18n, type Config, type Modifier } from 'sveltekit-i18n';
type Payload = { name: string };
type Props = { truncate?: { maxLength?: number } };
const truncate: Modifier.T<{ maxLength?: number }, Props> = ({ value, props }) =>
(value.length > (props.maxLength ?? 50) ? `${value.slice(0, props.maxLength ?? 50)}…` : value);
const i18n = new I18n<Config<Payload, Props>, Payload, Props>({
...config,
parserOptions: { customModifiers: { truncate } },
});
i18n.t('greeting', { name: 'Jarda' }, { truncate: { maxLength: 20 } });The exported types
From the package root: Config, Parser, Modifier, Report and Cst (this
package's parser surface), plus everything the core publishes — Extension,
Loader, Logger, Schema, Snapshot, Translations, and the core's own
Config and Parser namespaces under the names BaseConfig and BaseParser
(the plain names are taken).
From sveltekit-i18n/kit: defineI18n and the Kit types.
From sveltekit-i18n/utils: matchLocale, resolveLoaders,
sanitizeLocales, textDirection, toDotNotation and the DotNotation type.
The instance is typed by the config it was constructed from, or, when that
config states no schema, by the schema registered in SvelteKitI18n.Register,
which the typegen writes for you.
Extensions
config.extensions pipes the constructed instance through adapter functions,
left to right; new I18n(config) evaluates to the last one's output. The
pipe runs once, inside the constructor — a later loadConfig() cannot re-pipe.
The $t store surface
Teams that want the store form back add
@sveltekit-i18n/extension-stores:
import { I18n } from 'sveltekit-i18n';
import stores from '@sveltekit-i18n/extension-stores';
const { t, locale, loading, instance } = new I18n({ ...config, extensions: [stores] });⚠️ It is still one instance. Destructuring stores at module level
reintroduces exactly the sharing described in Instance
Ownership. Put the extension in the config handed to
defineI18n() instead: data.i18n, use() and get() then hand out the
store surface of each per-request and per-tab instance, while the wiring keeps
driving the instance itself. The stores example does
exactly that.
Keys as members of t
@sveltekit-i18n/extension-typed-access
reads a key as a path of members on t, typed from the schema:
t.cart.summary.itemCount({ count: 3 }) beside
t('cart.summary.itemCount', { count: 3 }).
import { defineI18n } from 'sveltekit-i18n/kit';
import typedAccess from '@sveltekit-i18n/extension-typed-access';
export const { handle, load, use, get } = defineI18n({ ...config, extensions: [typedAccess] });- Generate the schema with typegen 3.1 or newer. The file it writes also registers the keys nested by segment, so a level of the tree costs the checker its own segments. Without them the extension groups the keys itself, at a cost that grows with the square of the keys under one segment: keep namespaces to hundreds of keys there.
- Keep the string form for keys known only at runtime — CMS content,
t(item.labelKey)— and for a namespace with a reserved name (see Key Naming Conventions). - Call the leaf.
{t.home.title}without the call renders missing-key text — or throws, with an objectfallbackValue— and markup accepts a function, sosvelte-checkdoes not catch it; astringannotation does.
The typed-access example reads its keys through
the tree.
Markup in a message
@sveltekit-i18n/extension-html
adds a T component that renders the markup a message carries as elements and
Svelte components, without {@html}: tags come from an allowlist, an href
is gated by scheme, and the payload is escaped.
import { defineI18n } from 'sveltekit-i18n/kit';
import html from '@sveltekit-i18n/extension-html';
export const { handle, load, use, get } = defineI18n({
...config,
extensions: [html({ onReport: null })],
});<i18n.T key="intro" params={{ name }} /><T>where the markup renders,t()everywhere else. An attribute, a<title>or anaria-labeltakes the message with its markup as text.- Map a tag to a component of your own — a router-aware link, say:
html({ onReport: null, components: { a: Link } })renders<a>as<Link>, with the tag's attributes as props. - Block elements are off by default. A
<T>inside a<p>or a<button>cannot hold a<p>or a<div>; enableBLOCK_ELEMENTSwhere the context allows, and passcomponents={{ a: null }}to a<T>inside a link. - Send
onReportsomewhere in development. An unmapped tag, a dropped attribute or a blocked URL is reported, not thrown, on every render — so a channel that counts should deduplicate.
The html example renders an escaped payload, a tag as a
component and block elements per usage.
Pipe order
extension-stores goes last, since it returns no instance; extension-html
goes after extension-typed-access. What each order hands out is in
the API reference.
Writing your own
An extension is a function. It may augment the instance in place or return a different surface entirely:
const withGreeting = (i18n) => Object.assign(i18n, {
greet: (name) => i18n.t('common.greeting', { name }),
});
export const config = { ...base, extensions: [withGreeting] };In TypeScript, declare that the output depends on the input with an
Extension.Operator — otherwise the pipe folds the extension's fixed return
type and the schema and locale narrowing are erased:
import { I18n, type Extension } from 'sveltekit-i18n';
interface WithGreeting extends Extension.Operator {
readonly output: this['input'] & { greet: (name: string) => string };
}
const withGreeting: Extension.Generic<WithGreeting> = (i18n) => Object.assign(i18n, {
greet: (name: string) => i18n.t('common.greeting', { name }),
});
const i18n = new I18n({ ...config, extensions: [withGreeting] });
i18n.greet('World');
i18n.t('common.greeting', { name: 'World' }); // still key- and payload-checkedThe pipe folds a tuple. A config kept in a variable, as the one handed to
defineI18n(), widens its extensions to an array, and the surface is then
typed as the bare instance while the call still returns the extension's output.
Keep that config as const ({ ...base, extensions: [withGreeting] } as const),
or hand the extensions over in the call as above.
A generic function signature (<I>(i18n: I) => I & { … }) does not work:
reading one instantiates its type parameters at their constraints, so the pipe
would fold unknown.
instanceof does not hold
new I18n(config) returns the core's instance, and an extension may replace it
again, so i18n instanceof I18n is false. Test for a member you use. This is
documented, not fixed.
Component-Scoped Translations
Prefer one instance. A reusable component with text of its own does not need
an instance of its own — it needs a namespace of its own. Give it a
namespace nobody else uses, scope it with routes if it only appears on some
pages, and read the app's instance with get():
// src/lib/i18n.js — `fromFile` as under Namespace Strategy
export const config = {
loaders: [
{ locale: ['en', 'cs'], namespace: 'common', loader: fromFile },
{ locale: ['en', 'cs'], namespace: 'dataTable', loader: fromFile },
],
};<!-- src/lib/components/DataTable.svelte -->
<script>
import { get } from '#lib/i18n.js';
const i18n = get();
</script>
<table>
<thead>
<tr>
<th>{i18n.t('dataTable.column.name')}</th>
<th>{i18n.t('dataTable.column.date')}</th>
</tr>
</thead>
</table>One instance means one locale, one loading flag and one set of tables — nothing
to keep in sync. A component that appears on interaction rather than on a
route — a dialog, a panel — can give its loader routes: [], which no page
selects, and load it as it opens with await i18n.loadNamespace('dataTable'),
which selects a namespace's loaders whatever their routes say.
A second instance is for genuine isolation: an embedded widget that must render in a locale of its own, or a surface whose translations come from a different source entirely. Then it owns its whole lifecycle:
<script>
import { I18n } from 'sveltekit-i18n';
import { get } from '#lib/i18n.js';
import { widgetConfig } from './translations';
const app = get();
const widget = new I18n(widgetConfig);
// Follow the app's language; drop the `$effect` to pin it to its own.
$effect(() => {
if (app.locale) widget.setLocale(app.locale);
});
$effect(() => () => widget.destroy());
</script>
<p>{widget.t('widget.title')}</p>Library Authors: Shipping Translations
A component library that ships its own translations must not ship its own
instance. The app owns the single new I18n(…); the library contributes
loaders, translations and components that read whatever instance they are
handed.
Why: two instances mean two reactive graphs and two locales that drift apart, and a module-level instance inside a library is the singleton hazard again — one that every consuming app inherits on the server.
1. Declare sveltekit-i18n as a peer dependency
{
"name": "acme-table",
"peerDependencies": {
"sveltekit-i18n": "^3.1.0",
"svelte": ">=5"
},
"devDependencies": {
"sveltekit-i18n": "^3.1.0"
}
}A regular dependency would install a second copy of the package — and with it a
second copy of the core, whose state the app's instance knows nothing about.
^3.1.0 is the floor for loaders spelled with namespace, as below; a library
that must still serve 3.0 apps spells it key and states ^3.0.0, and 3.1
logs a deprecation warning for it.
2. Export loaders, not an instance
// acme-table/src/i18n.js
const FILES = {
en: () => import('./translations/en.json'),
cs: () => import('./translations/cs.json'),
};
/**
* @param {readonly string[]} locales
* @returns {import('sveltekit-i18n').Loader.LoaderModule[]}
*/
export const tableLoaders = (locales) => locales
.filter((locale) => Object.hasOwn(FILES, locale))
.map((locale) => ({
locale,
namespace: 'acmeTable',
loader: async () => (await FILES[locale]()).default,
}));- The
namespaceis the library's — one nobody else is likely to claim, and free of.characters. Every message is then read ast('acmeTable.…'). - The app says which locales it supports; the library contributes the ones it
has. Locales it does not translate fall back through the app's
fallbackLocale. - Keep the loaders lazy (
import()), so an app that never renders the component never fetches its strings.
The app composes them into its own config:
// src/lib/i18n.js
import { tableLoaders } from 'acme-table/i18n';
const locales = ['en', 'cs', 'de'];
/** @satisfies {import('sveltekit-i18n').Config} */
export const config = {
fallbackLocale: 'en',
loaders: [
{
locale: locales,
namespace: 'common',
loader: async ({ locale }) => (await import(`./translations/${locale}/common.json`)).default,
},
...tableLoaders(locales),
],
};Nothing else changes: the library's namespace is cached, invalidated, snapshotted and route-scoped exactly like the app's own.
3. For a handful of strings, ship an extension instead
addTranslations is the synchronous path — it takes locale-indexed data and
merges it branch by branch. It only seeds: a loader of the same namespace still
runs and merges over it. Packaged as an extension, it runs for every instance
the app builds, per request included:
// acme-table/src/i18n.js
const TRANSLATIONS = {
en: { acmeTable: { empty: 'No rows', loading: 'Loading…' } },
cs: { acmeTable: { empty: 'Žádné řádky', loading: 'Načítání…' } },
};
/** @type {import('sveltekit-i18n').Extension.T} */
export const withTable = (i18n) => {
i18n.addTranslations(TRANSLATIONS);
return i18n;
};// src/lib/i18n.js
import { withTable } from 'acme-table/i18n';
export const config = { ...appConfig, extensions: [withTable] };In TypeScript, brand it so the pipe keeps the surface it was handed:
import type { Extension } from 'sveltekit-i18n';
interface WithTable extends Extension.Operator {
readonly output: this['input'];
}
export const withTable: Extension.Generic<WithTable> = (i18n) => {
i18n.addTranslations(TRANSLATIONS);
return i18n;
};The extension runs wherever an instance is handed out — with /kit, on the
instance each universal load builds, on the server's rendering pass and in
the browser — so the strings are present on both sides without travelling in
the snapshot.
4. Let components receive the instance
A library component must not import an instance. Take it as a prop:
<!-- acme-table/src/AcmeTable.svelte -->
<script>
let { i18n, rows } = $props();
</script>
{#if !rows.length}<p>{i18n.t('acmeTable.empty')}</p>{/if}…or agree on a context key the library owns and the app sets once:
// acme-table/src/context.js
import { getContext, setContext } from 'svelte';
const KEY = Symbol.for('acme-table:i18n');
export const setTableI18n = (i18n) => setContext(KEY, i18n);
export const getTableI18n = () => getContext(KEY);<!-- src/routes/+layout.svelte — the app, once -->
<script>
import { setTableI18n } from 'acme-table';
import { use } from '#lib/i18n.js';
let { data, children } = $props();
setTableI18n(use(() => data));
</script>
{@render children()}use() returns the instance it provides, so the app hands the library the same
one its own components reach through get().
Context is per component tree, so a per-request instance stays per-request — the property that makes this the right mechanism on the server as well.
What a library must not do
- Export a constructed instance (
export const i18n = new I18n(config)) — the app then has two. - Call
loadConfig()on the app's instance — it replaces the whole config, including the app's loaders. - Call
destroy()on an instance it did not create. - Depend on
@sveltekit-i18n/baseor@sveltekit-i18n/parser-curlydirectly —sveltekit-i18nre-exports the core's whole surface and the parser's build-time half, and a direct dependency is another way to end up with two copies of the core. - Count on seeded strings to keep a loader from running. In 3.1, data from
addTranslations()orconfig.translationsrecords nothing. - Register a schema in
SvelteKitI18n.Register. The registry types every schema-less instance of the program it is part of, so only the app fills it; a library's own instances state their schema, orschema: {}.
Dynamic Routes and Locales
Locale in the URL path
For SEO-friendly locale routing:
src/routes/[lang]/
├── +layout.svelte
├── +page.svelte
└── about/
└── +page.svelteResolve the locale from the route parameter — this is also what makes prerendering possible:
// src/lib/i18n.js
export const { handle, load, use, get } = defineI18n(config, {
preferredLocale: (event) => event.params.lang,
});The root wiring from SSR and CSR
Considerations is unchanged: the locale now comes
from the path instead of a cookie, and a value no configured locale matches
falls through to Accept-Language, initLocale, fallbackLocale and the
first locale the config serves. A link to another locale's URL switches the tab
as the navigation commits. Nothing under [lang]/ has to load translations
again.
Loader routes match url.pathname, so they see the locale segment
(/cs/about). Match it in the pattern:
{ locale, namespace: 'about', routes: [/^\/[^/]+\/about$/], loader: fromFile }Locale-aware links
// src/lib/utils/i18n.js
export const localePath = (path, locale) => `/${locale}${path}`;<script>
import { get } from '#lib/i18n.js';
import { localePath } from '#lib/utils/i18n.js';
const i18n = get();
</script>
<a href={localePath('/about', i18n.locale)}>{i18n.t('common.nav.about')}</a>Language switcher
l(locale, key) reads a locale the call names, which is how each language
renders in its own name:
<script>
import { page } from '$app/state';
import { get } from '#lib/i18n.js';
const i18n = get();
const pathWithout = (path) => path.replace(/^\/[a-z]{2}(?=\/|$)/, '') || '/';
</script>
{#each i18n.locales as loc (loc)}
<a href={`/${loc}${pathWithout(page.url.pathname)}`} class:active={i18n.locale === loc}>
{i18n.l(loc, `lang.${loc}`)}
</a>
{/each}The lang.* keys come from config.translations, so they are present in every
language before anything loads.
Right-to-left locales
With /kit, <html dir="%dir%"> in app.html and use() keep the document's
direction in step with the locale. For an element of its own — a widget pinned
to another locale — take the direction from
textDirection:
<script>
import { textDirection } from 'sveltekit-i18n/utils';
</script>
<div dir={textDirection(widget.locale)}>…</div>It reads the script, not a list of languages, so ar, he, fa and ckb are
'rtl'. Spell the script where a region could change it (ku-Arab): engines
disagree on some likely scripts, and the server's %dir% and the browser's
would then too.
Content Management
Loading from a CMS or an API
A loader is just an async function:
/** @satisfies {import('sveltekit-i18n').Config} */
export const config = {
loaders: [
{
locale: ['en', 'cs'],
namespace: 'content',
loader: async ({ locale, namespace }) => {
const response = await fetch(`https://api.cms.example/translations/${locale}/${namespace}`);
return response.json();
},
},
],
cache: 300000, // the browser instance refetches after 5 minutes
};A loader receives { locale, namespace, route, params } — plain data — so one
function can serve every locale and namespace, and params carries what a
named group in its routes captured (an article id, a product slug).
⚠️ The server builds an instance per request, so a loader hitting a remote
source runs on every render. Put the caching where it survives that: HTTP cache
headers, a CDN in front of the CMS, or a module-level memo in the loader. The
cache window is what keeps the browser instance from refetching on every
navigation.
A source that caches on its own — a SvelteKit remote query (SvelteKit
2.27 or newer, with experimental.remoteFunctions on), an SWR layer — gets
cache: false, so the core leaves freshness to it and runs the loader on
every trigger that selects it:
// src/lib/i18n.remote.js
import { query } from '$app/server';
import { db } from '#lib/server/database.js';
export const messages = query('unchecked', ({ locale, namespace }) => db.messages(locale, namespace));// src/lib/i18n.js
import { messages } from './i18n.remote.js';
// in `config.loaders`:
{
locale: ['en', 'cs'],
namespace: 'editor',
cache: false,
loader: async ({ locale, namespace }) => {
const data = await messages({ locale, namespace });
// A remote query that redirected resolves `undefined`: throw, to retry.
if (data === undefined) throw new Error(`No ${namespace} messages for ${locale}`);
return data;
},
}Refreshing the source is the app's business (messages(…).refresh()); the next
trigger picks the new data up. A loader backed by a remote function has to
mind how SvelteKit treats one on the client — its redirect(), its 4xx — which
base — loader
spells out.
Keep server-only code out of the shared config
The config is imported by +layout.server.js and +layout.js, so a loader
reaching a database drags server-only code into the client bundle. Put the query
behind an endpoint and let the loader fetch it:
// src/routes/api/translations/[locale]/+server.js
import { json } from '@sveltejs/kit';
import { db } from '#lib/server/database.js';
export const GET = async ({ params }) => json(await db.translations.find({ locale: params.locale }));{
locale: ['en', 'cs'],
namespace: 'dynamic',
loader: async ({ locale }) => (await fetch(`${import.meta.env.VITE_API_ORIGIN}/api/translations/${locale}`)).json(),
}The loader runs on the server too, where fetch takes only an absolute URL
(the core hands a loader no fetch of its own), so it builds the URL from an
origin, VITE_API_ORIGIN here — or back it with a remote query instead.
Mixing static and dynamic
const loaders = [
// Static: fast, versioned with the code
{ locale: 'en', namespace: 'common', loader: async () => (await import('./en/common.json')).default },
// Dynamic: updates without a deployment
{ locale: 'en', namespace: 'content', loader: async () => (await fetch(`${import.meta.env.VITE_API_ORIGIN}/api/translations/en/content`)).json() },
];Editors publishing a change can be reflected without a finite cache: call
invalidate(undefined, 'content') on the browser instance when the app learns
of it (a websocket message, a poll, an explicit "reload content" action), then
trigger a load. Only that namespace refetches.
Testing
Use a real instance
An instance built from translations alone is cheap and synchronous — no
loader runs, so there is nothing to await. A component that reads it through
get() finds it where use() put it, which a test renders without, so hand
the component a real instance through your module's get:
import { render } from '@testing-library/svelte';
import { expect, test, vi } from 'vitest';
import Greeting from '#lib/components/Greeting.svelte';
vi.mock('#lib/i18n.js', async () => {
const { I18n } = await import('sveltekit-i18n');
const i18n = new I18n({
initLocale: 'en',
translations: { en: { 'common.greeting': 'Hello, {{name}}!' } },
});
return { get: () => i18n };
});
// Greeting calls get() and renders i18n.t('common.greeting', { name })
test('greets the user', () => {
const { getByText } = render(Greeting, { props: { name: 'Alice' } });
expect(getByText('Hello, Alice!')).toBeInTheDocument();
});A component that takes the instance as a prop needs no mock at all.
Per-test instances mean there is no state to reset between cases — the same property that makes per-request instances right on the server.
Stub where a stub is enough
t is a plain function on a plain object; nothing subscribes to anything:
const i18n = { t: (key) => key, l: (locale, key) => key, locale: 'en', locales: ['en'] };Assert on keys instead of copy — a test that asserts on translated strings fails every time a wording changes.
Testing the loaders themselves
Await the promise the trigger returns; never sleep:
const i18n = new I18n({ ...config, log: { level: 'error' } });
await i18n.loadTranslations('en', '/products');
expect(i18n.translations.en['products.title']).toBe('Products');log: { level: 'error' } keeps the debug chatter out of the test output.
Vitest setup
The core ships its rune modules uncompiled, for the consuming bundler to compile. A SvelteKit app's Vite config already has the Svelte plugin; what a node-environment test run still needs is for the core not to be externalized:
// vite.config.js
export default defineConfig({
plugins: [sveltekit()],
test: {
server: {
deps: {
// Externalized dependencies never reach the Svelte plugin, and the
// rune modules then hit Node uncompiled ("$state is not defined").
inline: ['@sveltekit-i18n/base'],
},
},
},
});Production Deployment
Environment-specific config
/** @type {import('sveltekit-i18n').Config} */
export const config = {
cache: import.meta.env.DEV ? 0 : Number.POSITIVE_INFINITY,
log: { level: import.meta.env.DEV ? 'debug' : 'warn' },
loaders: [/* … */],
};Logging
config.log configures a module-level logger shared by every instance in
the process — set it from the one config every instance is built from, rather
than per instance.
import * as Sentry from '@sentry/sveltekit';
export const config = {
log: {
prefix: '[acme i18n]: ',
logger: {
error: (message, error) => Sentry.captureException(error ?? new Error(message)),
warn: (message) => Sentry.captureMessage(message, 'warning'),
debug: () => {},
},
},
};Every level takes the prefixed message, and a thrown value follows as a second
argument where there is one. A loader's redirect() that rejects a load arrives
at error too, as SvelteKit threw it — navigation, not a fault — so a logger
feeding an error tracker skips it with isRedirect(error) from
@sveltejs/kit. The runtime skips a method a logger does not
define, but the Logger.T type names all three levels — spell the ones you do
not want as no-ops when you type the object.
Parser reports
Reports (unknown-modifier, failed-modifier, missing-options, …) are
silent by default: nothing is written anywhere unless you pass a channel.
They never raise — the placeholder takes its fallback and the rest of the
message renders — which makes them exactly the kind of defect that survives to
production unnoticed.
export const config = {
parserOptions: {
onReport: (report) => {
// `origin` says who fixes it: `message`, `payload` or a parser `limit`
if (report.origin !== 'limit') Sentry.captureMessage(report.message, 'warning');
},
},
};A report's message is a self-contained English sentence carrying nothing from
the payload, and text is an escaped, truncated excerpt — both safe to ship to a
log aggregator.
Loader failures
A loader that throws is caught and logged on its own, so the rest of the batch still lands. Where a namespace is critical, degrade explicitly instead:
{
locale: 'en',
namespace: 'common',
loader: async () => {
try {
return (await fetch('https://cdn.example.com/translations/en/common.json')).json();
} catch {
return (await import('./en/common.json')).default; // bundled copy
}
},
}CDN-hosted translations
{
locale: 'en',
namespace: 'common',
loader: async ({ locale }) => (await fetch(`https://cdn.example.com/translations/${locale}/common.json`)).json(),
}Version the URL (or rely on immutable cache headers) so a deploy cannot serve a stale catalogue, and keep a bundled fallback for the fetch above.
Monitoring
Measure inside the loader — that is the boundary the network crosses:
{
locale: 'en',
namespace: 'common',
loader: async ({ locale, route }) => {
const start = performance.now();
const translations = await (await fetch(`${import.meta.env.VITE_API_ORIGIN}/api/translations/${locale}`)).json();
analytics.track('translations_loaded', { locale, route, duration: performance.now() - start });
return translations;
},
}Summary
Key takeaways:
- Ownership – export the config and let
sveltekit-i18n/kitbuild the instances: one per request on the server, one per tab in the browser, reached throughget(). - Awaiting – await the promise the trigger returned;
loadingis for UI, never for coordination. - Organization – namespaces scoped with
routes, split across routes where that fits, loaded through dynamic imports;loadNamespace()for what an interaction needs. - Caching – keep the infinite default for files that ship with the app;
invalidate(locale?, namespace?)for event-driven refreshes;cache: falsefor a source that caches itself; cache the fetch, not the instance, on the server. - TypeScript – a config literal for locale completion, a schema for typed
keys and payloads, generated and registered by
@sveltekit-i18n/typegen, andExtension.Operatorso the pipe keeps both. - Libraries – ship loaders and translations, never an instance or a
schema registration; peer-depend on
sveltekit-i18n; take the instance as a prop or through context. - Testing – a real instance with inline
translationsis synchronous, so there is nothing to await and nothing to reset.
See Also
- Getting Started – step-by-step setup tutorial
- API Documentation – this package's reference
- Core API reference – every shared member, in full
- Curly Message Format parser – message syntax and
parserOptions - Architecture Overview – how it all works
- Troubleshooting – common issues and solutions
- typegen – generates the
schematype - Extensions – the official extensions: stores, typed access and markup
- Examples – working code examples