Ask your question and get a summary of the document by referencing this page and the AI provider of your choice
If you have an idea for improving this documentation, please feel free to contribute by submitting a pull request on GitHub.
GitHub link to the documentationCopy doc Markdown to clipboard
Svelte i18n: stores, runes, and what actually changed in Svelte 5
Almost every Svelte i18n tutorial you will find was written for Svelte 4, where a writable store was the only way to hold reactive locale state. Svelte 5 runes did not break those libraries, but they did change what idiomatic code looks like, and they made one long-standing pattern visibly wrong. This post covers the store model, what runes change, message compilation and bundle cost, typed keys, and how the current libraries compare.
Table of Contents
What "Svelte i18n" means here
This post is about Svelte the component framework, running on Vite, with no server. If you are on SvelteKit, locale routing and SSR request scoping are a separate set of problems, covered in SvelteKit i18n: routing, SSR, and shared state.
Svelte ships nothing for i18n. No $t, no locale primitive, no message format. Every option below is a third-party choice, and the choice is mostly about where messages live and when they get compiled. If the vocabulary is new, start with what internationalization actually covers.
The store model, and why every tutorial uses it
svelte-i18n is the ecosystem default. You register locale loaders, initialise, then read from the _ store:
Copy the code to the clipboard
Copy the code to the clipboard
Three things are worth understanding rather than copying:
$_is a derived store, not a function. The$prefix subscribes the component to it. Whenlocalechanges, every component that reads$_re-renders. That is the whole reactivity mechanism, and it works because stores are framework-level, not component-level.- Loading is async, rendering is not.
registerreturns a loader; until it resolves,$_('cart.total')returns the key. That is the source of the classic flash of raw keys on first paint.isLoadingandwaitLocale()exist to gate rendering on it, and most tutorials forget them. - The catalog is a plain object. Nothing splits it per route. Loading
frloads every page's French copy.
What runes actually change
Runes did not deprecate stores. $state and $derived are for state you own inside a component or a .svelte.ts module; stores are still fine for shared, cross-component state, and $store auto-subscription still works in Svelte 5. So svelte-i18n keeps working, unmodified, in a Svelte 5 app.
What changed is that you now have a second, better option for locale state:
Copy the code to the clipboard
Two gotchas people hit here. Runes outside a component only work in a .svelte.ts or .svelte.js file, so a plain locale.ts silently gives you a non-reactive variable. And you cannot export a $state binding directly, which is why the module above exports a getter instead of the value.
The honest summary: for a client-only app, the store version and the runes version behave identically. The runes version reads better and types better. Neither is a reason to rewrite a working app.
The module-level store trap
Both patterns above put locale in a module-level singleton. In a Vite SPA that is correct: one browser tab, one user, one locale. The module is instantiated once per page load and belongs to that visitor.
The moment SSR appears, that singleton is shared across every concurrent request on the server process. Request A sets the locale to fr, request B renders while A is still in flight, and B gets French. It is intermittent, it never reproduces locally with one browser tab open, and it looks like a caching bug.
The fix is Svelte context, which is per-render-tree rather than per-module: set the locale with setContext at the root and read it with getContext in components. This is not a Svelte-only problem, but Svelte's .svelte.ts modules make the wrong version unusually easy to write. If you are on a plain Vite setup today and might move to SvelteKit later, using context from the start costs you nothing.
Message compilation and bundle cost
The second axis is when your messages become code. There are two families:
- Runtime interpolation.
svelte-i18nships a message parser to the browser and resolvescart.itemsagainst a catalog object at render time. Flexible, and you pay for the parser plus the whole catalog. - Compile-time. Paraglide (inlang) generates one exported function per message and one file per locale, so
m.cart_total()is an ordinary import. Your bundler tree-shakes anything you did not call. On Vite plus Svelte this works as advertised, and it is the strongest argument for Paraglide.
The Svelte i18n benchmark runs a 10-page, 10-locale app and measures the library cost separately from the content cost. svelte-i18n lands around 15.9 kB after bundling and minification, roughly 7× svelte-intlayer. Paraglide's library number is close to zero by construction, because the runtime is generated into your source tree rather than imported from a package.
Paraglide's costs are real too. The generated files live in your repository, so you regenerate before pushing and you get merge conflicts on them in parallel pull requests. And it does not keep the current locale in a Svelte store: each message call reads the locale back from cookie or storage, which adds work per node instead of one subscription per component.
Typed keys
$_("cart.totl") is a string typo that fails at runtime, in the locale nobody tests, in production. Three ways out:
Open the table in a modal to view all data content clearly
| Approach | How you get types | Cost |
|---|---|---|
svelte-i18n | None built in; you hand-write a union of keys | Drifts from the JSON immediately |
typesafe-i18n | Generator watches your files and emits typed accessors ($LL.cart.total()) | A watcher process, generated files in the repo |
| Paraglide | Each message is an exported function, so it is typed by existence | Same generated-files trade-off |
| Intlayer | Types generated from the content declarations at build time | Build plugin required |
typesafe-i18n deserves a note: mechanically it is sound, and typed accessors plus generated formatters are a good model. The repository has not moved much recently, so check its activity before committing a codebase to it.
Comparison
Open the table in a modal to view all data content clearly
| Library | Messages live in | Locale state | Per-route splitting | Types on keys |
|---|---|---|---|---|
svelte-i18n | JSON catalogs per locale | Svelte store | No | Manual |
| Paraglide | inlang project, compiled to functions | Read per call from cookie/storage | Yes, via tree-shaking | Yes |
typesafe-i18n | Generated TS modules | Store adapter | Partial | Yes |
| Intlayer | .content.ts next to the component | Context plus store | Yes, per component | Yes |
Intlayer: content declared beside the component
Intlayer's one structural difference is that content is not centralized. Each component gets a .content.ts file next to it, and a Vite plugin compiles those declarations into per-component dictionaries.
Copy the code to the clipboard
Copy the code to the clipboard
useIntlayer returns a readable store derived from the current locale, so $content is the same auto-subscription you already know. Locale switching goes through useLocale(), which gives you locale, availableLocales and setLocale.
On the runes question, svelte-intlayer sits in the middle. setupIntlayer(locale) at the root of your app holds the locale in $state and publishes it through Svelte context, which is the SSR-safe shape described above; reads still come back as stores so $content works everywhere. There is a module-level store underneath as a fallback for apps that never call setupIntlayer, which is fine on Vite and is exactly the thing you should not rely on once a server is involved.
Setup is npx intlayer init, then intlayer() alongside svelte() in vite.config.ts.
What it costs you. The build plugin is mandatory: no Vite plugin, no dictionaries, so a plain svelte REPL-style setup is out. The ecosystem is much smaller than i18next's or svelte-i18n's, which means fewer Stack Overflow answers when something goes sideways. And the project is younger than the alternatives here, so you are betting on it continuing.
If you already run svelte-i18n, the @intlayer/svelte-i18n compat adapter aliases the package at the bundler level, so $_, $date, $number and your existing flat keys keep working while Intlayer serves the content. It is a way to test the change without touching components on day one.
Common mistakes
- Rendering before messages resolve. With any async-loading library, gate the first paint on
isLoadingorwaitLocale(), or accept a flash of raw keys. - Putting runes in a
.tsfile.$stateinlocale.tscompiles to a plain variable and nothing updates. It has to belocale.svelte.ts. - Assuming a store singleton is safe because it works locally. One tab never reproduces cross-request leakage. It shows up in production, under concurrency.
- Switching locale with a button. Crawlers do not click. If the app has localized URLs, the switcher should be an
<a>to the localized path. - Loading every locale up front. Ten locales in the initial bundle is nine locales nobody reads. Use dynamic imports per locale at minimum, and per route if the library supports it.
Going further
- Svelte i18n benchmark: bundle size, leakage and locale-switch timings
- Set up i18n in a Vite + Svelte app, step by step
- SvelteKit i18n: routing, SSR, and shared state
- Drop-in
svelte-i18ncompat adapter - Per-component vs centralized i18n
- Compiler-based vs declarative i18n
- How bundle optimization works at build time
Comments
No comments yet. Be the first to share your thoughts.
