Skip to content

Svelte i18n Integration

Comvi’s Svelte integration uses Svelte’s native store system for reactive translations. It targets Svelte 5 (runes) and works with SvelteKit for SSR.

Terminal window
pnpm add @comvi/svelte @comvi/core @comvi/plugin-fetch-loader
  1. Create the i18n instance

    src/lib/i18n.ts
    import { createI18n } from '@comvi/core';
    import { FetchLoader } from '@comvi/plugin-fetch-loader';
    export const i18n = createI18n({
    locale: 'en',
    fallbackLocale: 'en',
    })
    .use(FetchLoader({
    cdnUrl: 'https://cdn.comvi.io/your-distribution-id',
    }));
  2. Set up the context in your root layout

    src/routes/+layout.svelte
    <script lang="ts">
    import { setI18nContext } from '@comvi/svelte';
    import { i18n } from '$lib/i18n';
    let { children } = $props();
    setI18nContext(i18n);
    </script>
    {@render children()}
  3. Use in components

    src/routes/+page.svelte
    <script lang="ts">
    import { useI18n } from '@comvi/svelte';
    const { t, locale } = useI18n();
    </script>
    <h1>{$t('hello.world')}</h1>
    <p>{$t('welcome.message', { name: 'Alice' })}</p>
    <p>Current locale: {$locale}</p>

Comvi uses Svelte’s context API to make the i18n instance available to all child components. Call setI18nContext once in your root layout. All descendant components can then access translations with useI18n() or getI18nContext().

src/routes/+layout.svelte
<script lang="ts">
import { setI18nContext } from '@comvi/svelte';
import { i18n } from '$lib/i18n';
let { children } = $props();
setI18nContext(i18n);
</script>
{@render children()}

If you need the raw i18n instance (for example, in a utility function), use getI18nContext():

<script lang="ts">
import { getI18nContext } from '@comvi/svelte';
const i18n = getI18nContext();
</script>

The primary function for accessing translations and locale state. It returns Svelte stores that you subscribe to with the $ prefix.

<script lang="ts">
import { useI18n } from '@comvi/svelte';
const { t, locale, isLoading, setLocale } = useI18n();
</script>
{#if $isLoading}
<p>Loading translations...</p>
{:else}
<h1>{$t('page.title')}</h1>
<p>Current locale: {$locale}</p>
{/if}

Returned values:

PropertyTypeDescription
tReadable<(key, params?) => string>Reactive translation function (use as $t(...))
tRawReadable<(key, params?) => TranslationResult>Structured output for advanced renderers (use as $tRaw(...))
localeReadable<string>Current language (read-only store, use setLocale() to change)
isLoadingReadable<boolean>Whether translations are being loaded
setLocale(lang: string) => Promise<void>Switch to a different language

Use setLocale() to switch languages. The locale store is read-only:

<script lang="ts">
import { useI18n } from '@comvi/svelte';
const { locale, setLocale } = useI18n();
</script>
<select value={$locale} onchange={(e) => setLocale(e.currentTarget.value)}>
<option value="en">English</option>
<option value="de">Deutsch</option>
<option value="fr">Francais</option>
</select>

For translations containing HTML, use the <T> component for safe interpolation:

<script lang="ts">
import { T } from '@comvi/svelte';
</script>
<!-- Translation: "Read our <link>terms of service</link>" -->
<T
i18nKey="legal.tos"
components={{
link: { tag: 'a', props: { href: '/terms' } },
}}
/>

Unlike the React and SolidJS bindings, Svelte’s <T> builds an HTML string and injects it with {@html}. Consequences:

  • components mappings can only be HTML tag names or { tag, props } objects — you can’t pass a Svelte component (Svelte components aren’t plain functions you can call to get markup).

  • Output is sanitized: text is HTML-escaped, attribute names like on*/srcdoc/formaction are stripped, and only tags in a safe allow-list are emitted (others fall back to <span>). The default allow-list covers common inline/block formatting tags (a, strong, em, br, ul/li, span, …). Override it with the allowedTags prop:

    <script lang="ts">
    import { T } from '@comvi/svelte';
    const allowedTags = new Set(['strong', 'em', 'a', 'mark']);
    </script>
    <T i18nKey="rich.message" {allowedTags} components={{ hl: 'mark' }} />

Load translations from a specific namespace:

<script lang="ts">
import { useI18n } from '@comvi/svelte';
const { t } = useI18n('dashboard');
</script>
<h1>{$t('page.title')}</h1>

useI18n() exposes onMissingKey and onLoadError (both bound directly to the core instance). Register them once — for example in your root layout — and tear them down via the returned cleanup. The onMissingKey callback may return a replacement (TranslationResult | void):

src/routes/+layout.svelte
<script lang="ts">
import { onDestroy } from 'svelte';
import { setI18nContext, useI18n } from '@comvi/svelte';
import { i18n } from '$lib/i18n';
let { children } = $props();
setI18nContext(i18n);
const { onMissingKey, onLoadError } = useI18n();
const offMissing = onMissingKey((key, locale, namespace) => {
console.warn(`Missing: ${namespace}:${key} (${locale})`);
return `[${key}]`; // optional fallback string
});
const offError = onLoadError((locale, namespace, error) => {
console.error(`Failed to load ${namespace} for ${locale}:`, error);
});
onDestroy(() => {
offMissing();
offError();
});
</script>
{@render children()}

You can also handle missing keys per-render with the <T> component’s fallback prop or by providing slotted fallback content (rendered when the key is missing):

<T i18nKey="optional.banner" fallback="Welcome!" />
<T i18nKey="optional.note">
<p>Default note shown when the key is missing.</p>
</T>

Comvi works with Svelte 5 runes. The stores returned by useI18n() are compatible with the $ rune syntax:

src/routes/+page.svelte
<script lang="ts">
import { useI18n } from '@comvi/svelte';
const { t, locale, setLocale } = useI18n();
</script>
<h1>{$t('page.title')}</h1>
<select value={$locale} onchange={(e) => setLocale(e.target.value)}>
<option value="en">English</option>
<option value="de">Deutsch</option>
</select>

In Svelte 5, use $derived for computed translations:

<script lang="ts">
import { useI18n } from '@comvi/svelte';
const { t } = useI18n();
let count = $state(0);
let cartMessage = $derived($t('cart.items', { count }));
</script>
<p>{cartMessage}</p>
<button onclick={() => count++}>Add item</button>

@comvi/svelte does not ship a SvelteKit adapter. The pattern is: create a fresh i18n instance per request in +layout.server.ts, return its loaded translations, and build the client context from that payload.

src/routes/+layout.server.ts
import { createI18n } from '@comvi/svelte';
import { FetchLoader } from '@comvi/plugin-fetch-loader';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ url, request }) => {
// Detect from URL path first, then Accept-Language. Never from navigator (no DOM here).
const fromPath = url.pathname.split('/')[1];
const fromHeader = request.headers.get('accept-language')?.split(',')[0]?.split('-')[0];
const locale = ['en', 'de'].includes(fromPath) ? fromPath : fromHeader ?? 'en';
// Fresh instance for this request only.
const i18n = createI18n({ locale, fallbackLocale: 'en' }).use(
FetchLoader({ cdnUrl: 'https://cdn.comvi.io/your-distribution-id' }),
);
await i18n.init();
const namespace = i18n.getDefaultNamespace();
return {
locale,
// Keyed by "locale:namespace" so multi-namespace apps round-trip cleanly.
messages: {
[`${locale}:${namespace}`]: i18n.getTranslations(locale, namespace) ?? {},
},
};
};

Create the client i18n instance from the serialized messages so hydration starts from the same cache — no duplicate fetch:

src/routes/+layout.svelte
<script lang="ts">
import { createI18n, setI18nContext } from '@comvi/svelte';
import type { LayoutProps } from './$types';
let { data, children }: LayoutProps = $props();
const i18n = createI18n({
locale: data.locale,
fallbackLocale: 'en',
translation: data.messages,
});
setI18nContext(i18n, { autoInit: false });
</script>
<html lang={data.locale}>
<body>
{@render children()}
</body>
</html>

If you lazy-load extra namespaces, include them in the returned messages before rendering any component that needs them.

Add language metadata for search engines:

src/routes/+layout.svelte
<script lang="ts">
import { useI18n } from '@comvi/svelte';
const { locale } = useI18n();
</script>
<svelte:head>
<html lang={$locale} />
<link rel="alternate" hreflang="en" href="https://yoursite.com/en" />
<link rel="alternate" hreflang="de" href="https://yoursite.com/de" />
</svelte:head>

Enable type-safe translations with auto-generated types. The Comvi CLI emits a .d.ts that augments the TranslationKeys interface in @comvi/core for APIs that expose the typed core translation function.

src/lib/i18n.ts
import { createI18n } from '@comvi/core';
// Side-effect import — augments @comvi/core's TranslationKeys interface
import './types/i18n';
export const i18n = createI18n({
locale: 'en',
fallbackLocale: 'en',
});

The current Svelte $t store is typed as (key: string, params?: TranslationParams) => string, so it remains permissive even when generated types are imported. Use the generated types with direct core APIs or other bindings that expose typed t() overloads. See Type-Safe Translations for the full setup.