Skip to content
Comvi

Svelte

View .md

With a Svelte 5 app, plain Vite or SvelteKit, @comvi/svelte puts the i18n instance on Svelte’s context, hands components $t and $locale as stores, and renders markup inside a translation through <T>.

To watch $t re-render on a switch, open the Svelte example in StackBlitz: plurals, <T> with a link and lazy catalogs, in the browser.

Terminal window
npm install @comvi/svelte

@comvi/svelte peer-depends on svelte (^5.3) and brings @comvi/core.

src/routes/+layout.svelte
<script lang="ts">
import { createI18n, setI18nContext } from '@comvi/svelte';
import type { Snippet } from 'svelte';
const { children }: { children: Snippet } = $props();
const i18n = createI18n({
locale: 'en',
fallbackLocale: 'en',
messages: {
en: { greeting: 'Hello, {name}!' },
uk: { greeting: 'Привіт, {name}!' },
},
});
setI18nContext(i18n);
</script>
{@render children()}

createI18n() returns the instance and setI18nContext() stores it in Svelte context, where useI18n() in any descendant finds it. It also schedules init() in a microtask, so there is no init() to write, and an explicit await i18n.init() in the same script still wins without a double init. Pass { autoInit: false } to run it yourself, and { onError } to see a failed auto-init, which otherwise reaches only the instance’s own onError. In a plain Vite app the same code goes in src/App.svelte. The catalog options are on the Quick start.

src/routes/+page.svelte
<script lang="ts">
import { useI18n } from '@comvi/svelte';
const { t, locale, setLocale } = useI18n();
</script>
<h1>{$t('greeting', { name: 'Alice' })}</h1>
<select value={$locale} onchange={(e) => setLocale(e.currentTarget.value)}>
<option value="en">English</option>
<option value="uk">Українська</option>
</select>

useI18n() returns stores, so $-prefix them in markup: $t renders a string, $tRaw the parts array. Both derive from the locale store and a cache-revision store, so $t in markup or inside a $derived re-runs when the locale or the catalog changes and at no other time. setLocale() is a plain function that resolves once the new locale’s catalog has loaded.

src/locales/en.json
{
"help": "For support, <link>visit our docs</link>, <bold>email us</bold> or <btn>open a ticket</btn>.",
"legal": "See our <policy>privacy policy</policy>."
}
src/Help.svelte
<script lang="ts">
import { T } from '@comvi/svelte';
import FancyButton from './FancyButton.svelte';
</script>
<T i18nKey="help" components={{ link: 'a', bold: 'strong', btn: FancyButton }} />
<T i18nKey="legal" components={{ policy: { tag: 'a', props: { href: '/privacy' } } }} />

Each tag maps to an element name, a { tag, props } config, or a Svelte component that receives the tag’s content as its children snippet. <T> builds real DOM nodes, never an HTML string; a tag with no mapping renders its inner text. Plain $t() does not parse tags.

src/routes/+layout.svelte
<script lang="ts">
import { createI18n, setI18nContext } from '@comvi/svelte';
import { cdnLoader } from '@comvi/plugin-fetch-loader';
import type { Snippet } from 'svelte';
const { children }: { children: Snippet } = $props();
const i18n = createI18n({
locale: 'en',
fallbackLocale: 'en',
}).use(cdnLoader({ cdnUrl: 'https://cdn.comvi.io/<project-token>' }));
setI18nContext(i18n);
</script>
{@render children()}

Install @comvi/plugin-fetch-loader and compose .use(cdnLoader()) inside the createI18n expression, before setI18nContext() schedules init(): init() runs the queued plugins once, and the CDN plugin registers its loader there. The rest of the walkthrough is on Connect to Comvi. A server render does not wait for that fetch, so a SvelteKit app that renders on the server loads the catalog in a load function instead, as on SvelteKit SSR.

The CDN endpoint URL is under Settings → Content Deploy in your Comvi project, once Content Deploy is enabled and saved; pass it as cdnUrl. The URL format is on CDN Deployment.

If your keys use ICU ({count, plural, …}), add compiler: icuCompiler as on Pluralize with ICU. The platform and the compiler are independent: either works without the other.

src/routes/+page.svelte
<script lang="ts">
import { useI18n } from '@comvi/svelte';
const { isInitializing, t } = useI18n();
</script>
{#if $isInitializing}
<div class="spinner"></div>
{:else}
<h1>{$t('greeting', { name: 'Alice' })}</h1>
{/if}

With a catalog in flight during the first paint, $isInitializing is the gate: there is no provider component and no fallback prop. The store is true from setI18nContext() on, so the first render is already inside the gate, stays true while init() runs, and goes false once the default namespace is in. $isLoading goes true again for every later load, which is why the gate is isInitializing and not isLoading.

src/routes/+layout.svelte
<script lang="ts">
import { useI18n } from '@comvi/svelte';
const { locale, dir } = useI18n();
$effect(() => {
document.documentElement.lang = $locale;
document.documentElement.dir = $dir;
});
</script>

@comvi/svelte ships no SvelteKit binding of its own and needs none. Build a fresh instance per request and never mutate a module-level one on the server, or one user’s locale bleeds into another’s response. A load function returns plain data, { locale, messages }, never the instance; construct the instance in +layout.svelte with setI18nContext(i18n, { autoInit: false }) seeded from that payload, so the server and the client render the same text and there is no hydration mismatch. The complete example is on SvelteKit SSR.

<svelte:head> inserts into <head>, so lang and dir go on document.documentElement from an $effect, or through <svelte:html lang={$locale} dir={$dir} /> on Svelte 5.16 or newer, which also applies during SSR.

setI18nContext() is Svelte’s setContext() under one fixed key, so the instance is visible to every component below the one that called it and to nothing else. useI18n() is the matching getContext() and therefore runs during component initialisation only; called with no ancestor that set the context, it throws the named error in the table at the end rather than returning an empty bag.

The stores are created once per instance and cached, so every useI18n() call and every <T> in the tree shares the same store objects. The locale store listens to localeChanged; the loading and initializing stores to loadingStateChanged; the initialized store to initialized and destroyed; and a cache-revision store counts every other revision event (namespace loaded, translations cleared, config changed). Each store subscribes to the instance only while a component reads it and unsubscribes when the last reader leaves, so an unmounted page costs nothing.

$t is a derived of the locale store and the cache-revision store, one per instance and namespace, whose value is a translation function bound to that namespace. That is why $t('key') in markup updates on a locale switch or a namespace load: the store emits a new function and Svelte re-runs the expression. A translation pinned to a locale with $tRaw('key', { locale }) still re-derives with the store, since the store is shared, but resolves against the pinned locale.

setLocale() is the instance’s setLocale(). The catalog for the new locale is loaded before the locale is assigned, when a loader is composed, and localeChanged fires after both, so the locale store moves once and no frame shows half-translated text. When several switches overlap, the latest wins and the superseded ones apply nothing.

init() runs from the microtask setI18nContext() scheduled: it raises isInitializing, runs the plugins, loads the default namespace in the current locale and in each fallback locale, then sets isInitialized and emits initialized. A fallback catalog that does not load is reported, and init() still resolves. With an inline catalog the load is a cache hit. A missing key returns the key itself and emits missingKey; strict: 'dev' adds a console warning in development.

On the server the microtask still runs, against whatever instance the layout built. A module-level instance would be shared by every concurrent request, and the first setLocale() from one request would move the locale under another’s render. Building the instance inside +layout.svelte from the load data gives each request its own, and autoInit: false with the catalog already in it leaves nothing to await.

what happened message
useI18n() with no ancestor that called setI18nContext() [@comvi/svelte] i18n context not found. Call setI18nContext(i18n) in your root component (e.g., App.svelte).
useI18nLoader() on an instance with no loader composed development: [@comvi/core] This i18n instance has no loader capability. Compose it: .use(attachLoader) from "@comvi/core/loader", or loader(map).; a production build throws the short form quoted on Error codes

More messages and their fixes are on Troubleshooting.