Localize a Svelte / SvelteKit app: complete guide with Localingos
SvelteKit's load functions and per-request server state pair cleanly with i18n once you know the patterns. This guide takes a fresh SvelteKit app to fully localized using svelte-i18n for the runtime and Localingos for the translation pipeline. About 20 minutes end to end, including SSR-safe locale handling.
Step 1 — Install
npm install svelte-i18n
npm install -g localingos
The snippets below assume Svelte 5 with runes ({@render children()}, $props(), onchange). On Svelte 4 use <slot />, export let and on:change instead.
Step 2 — Wire svelte-i18n
src/lib/i18n/index.ts:
import { init, register, locale } from 'svelte-i18n';
import { browser } from '$app/environment';
// Full BCP 47 codes, matching what the CLI writes into ./locales/.
export const SUPPORTED = ['en-US', 'es-ES', 'de-DE', 'fr-FR', 'ja-JP', 'pt-BR'] as const;
register('en-US', () => import('./locales/en-US.json'));
register('es-ES', () => import('./locales/es-ES.json'));
register('de-DE', () => import('./locales/de-DE.json'));
register('fr-FR', () => import('./locales/fr-FR.json'));
register('ja-JP', () => import('./locales/ja-JP.json'));
register('pt-BR', () => import('./locales/pt-BR.json'));
// Match the full tag first, then fall back to the language subtag, so a browser
// reporting en-GB resolves to en-US instead of a locale you don't ship.
export function resolveLocale(candidate: string | undefined): string {
if (!candidate) return 'en-US';
const exact = SUPPORTED.find(l => l.toLowerCase() === candidate.toLowerCase());
if (exact) return exact;
const lang = candidate.split('-')[0].toLowerCase();
return SUPPORTED.find(l => l.split('-')[0].toLowerCase() === lang) ?? 'en-US';
}
// Initialise from the browser language only. Reading a stored preference here
// would render one locale on the server and another on the client; apply the
// stored value after hydration instead (see Step 6).
init({
fallbackLocale: 'en-US',
initialLocale: browser ? resolveLocale(window.navigator.language) : 'en-US',
});
src/routes/+layout.svelte:
<script lang="ts">
import '$lib/i18n';
import { isLoading } from 'svelte-i18n';
</script>
{#if !$isLoading}
{@render children()}
{:else}
<div>Loading…</div>
{/if}
register() with a lazy import means each locale becomes its own chunk — only the active locale ships in the initial bundle.
Step 3 — Source of truth
src/lib/i18n/locales/en-US.json:
{
"welcome": "Welcome, {name}",
"cart": "{count, plural, one {# item in your cart} other {# items in your cart}}"
}
svelte-i18n uses ICU MessageFormat syntax for plurals. The syntax and its placeholders survive translation intact, but the set of plural categories does not grow on its own.
Plurals need a deliberate decision. Localingos translates the forms you send it. It does not add plural categories your source language doesn't have — a string with two forms comes back with two forms, even in a language that needs four (Polish) or six (Welsh). Author every form your target languages require in your source file and let your i18n library select among them at runtime, or keep count-bearing copy out of translation and format numbers separately.
Step 4 — Configure Localingos
Run localingos init and answer its prompts. It writes two files.
localingos.config.json — commit this. Project settings shared with your team and CI:
{
"projectId": "your-project-id",
"sourceLocale": "en-US",
"format": "json-nested",
"sourceFile": "src/lib/i18n/locales/en-US.json",
"outputDir": "src/lib/i18n/locales",
"outputPattern": "{locale}.json"
}
.localingos.json — add this to .gitignore. Your API key for local development:
{ "apiKey": "your-api-key" }
In CI, set LOCALINGOS_API_KEY instead; it takes precedence over both files. init is interactive, so for containers, provisioning scripts or AI agents, write these two files yourself — the CLI reads nothing else.
Three things worth knowing:
formatdecides your key shape.json-nestedmapshome.titleto{ "home": { "title": … } };json-flatkeeps"home.title"as one top-level key. Those are the two supported values.- Target locales are not configured here. They belong to the project itself — set them in the dashboard under Projects → Edit → Locales → Update Project. The CLI writes one file per target locale the project has, so you add a language without touching your repo. Codes are full BCP 47, e.g.
es-ES,pt-BR,zh-TW. - Placeholder preservation is automatic. Localingos extracts placeholders from the source string —
{{name}},{name},${count},%s,%d, ICU fragments — and validates each one survives translation, retrying with a corrective prompt when it doesn't. There is nothing to configure.
Then push your source strings and pull back translations:
localingos sync
Translation is asynchronous
The first sync of a new key pushes it and usually has nothing to pull back yet:
Push: 10 created, 0 updated, 0 deleted, 0 unchanged
✅ 0 translations received
⏳ 10 keys pending translation: home.title, home.subtitle, …
No new translations. Run "localingos sync" again later.
That's expected. Run localingos sync (or localingos pull) again shortly to collect results. Unchanged strings are never re-translated or re-billed.
Verify completeness before committing. pull writes whatever is ready and exits 0, so a file pulled mid-translation can be missing keys with no warning and no error — at runtime that surfaces as a silent fallback to your source language. Check key counts per locale before you commit, and gate on it in CI.
svelte-i18n is ICU-based. ICU fragments in your source are preserved as text and their placeholders are validated, but translation does not add plural categories your source doesn't already contain — see the plural note below.
Step 5 — Use in components
<script lang="ts">
import { _ } from 'svelte-i18n';
// Runes mode: props come from $props(), not `export let`.
let { userName, itemCount }: { userName: string; itemCount: number } = $props();
</script>
<header>
<h1>{$_('welcome', { values: { name: userName } })}</h1>
<p>{$_('cart', { values: { count: itemCount } })}</p>
</header>
$_ is the reactive store version — components automatically re-render when the locale changes.
Step 6 — Language switcher
<script lang="ts">
import { locale, waitLocale } from 'svelte-i18n';
const LOCALES: Record<string, string> = {
'en-US': 'English',
'es-ES': 'Español',
'de-DE': 'Deutsch',
'fr-FR': 'Français',
'ja-JP': '日本語',
'pt-BR': 'Português (Brasil)',
};
async function switchLocale(newLocale: string) {
// Await the dictionary chunk before flipping, or the page renders the
// fallback language for a frame while the lazy import lands.
await waitLocale(newLocale);
locale.set(newLocale);
localStorage.setItem('locale', newLocale);
}
</script>
<select value={$locale} onchange={e => switchLocale((e.target as HTMLSelectElement).value)}>
{#each Object.entries(LOCALES) as [code, label]}
<option value={code}>{label}</option>
{/each}
</select>
Step 7 — SSR considerations
SvelteKit SSR runs in Node, so localStorage is unavailable. Detect the user's locale from the Accept-Language header in a +layout.server.ts:
// src/routes/+layout.server.ts
import type { LayoutServerLoad } from './$types';
import { resolveLocale } from '$lib/i18n';
export const load: LayoutServerLoad = ({ request }) => {
const header = request.headers.get('accept-language') || '';
const first = header.split(',')[0]?.split(';')[0]?.trim();
// resolveLocale() maps this onto a locale we actually ship; returning the bare
// subtag would hand the client a code with no message file behind it.
return { initialLocale: resolveLocale(first) };
};
Pass it through +layout.svelte to init({ initialLocale: data.initialLocale }). This avoids hydration mismatches between SSR-rendered HTML and client-side hydration.
Step 8 — Automate sync in CI
# .github/workflows/i18n.yml
name: i18n-sync
on:
push: { branches: [main], paths: ['src/lib/i18n/locales/en-US.json'] }
jobs:
sync:
runs-on: ubuntu-latest
permissions: { contents: write, pull-requests: write }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm install -g localingos
- run: localingos sync
env: { LOCALINGOS_API_KEY: '${{ secrets.LOCALINGOS_API_KEY }}' }
- uses: peter-evans/create-pull-request@v6
with:
branch: i18n/auto-sync
title: 'chore(i18n): sync translations'
commit-message: 'chore(i18n): sync translations'
Production checklist
- Locale-prefixed routes for SEO. Use SvelteKit's
[lang]dynamic segment:src/routes/[lang]/pricing/+page.svelte. Emit hreflang annotations in+layout.svelte. - Adapter compatibility. Works with adapter-static, adapter-node, adapter-vercel, adapter-cloudflare. No special config needed.
- RTL support. Set
<html dir>based on the active locale in+layout.svelte. - ICU support is built in. svelte-i18n parses MessageFormat natively — no extra runtime.
Wrap up
Your SvelteKit app handles 56 locales with ICU pluralization, server-side locale detection, lazy-loaded chunks, and CI-driven translation sync. Adding a language is one change on the project in the dashboard (Projects → Edit → Locales) and one register() call.
Free tier: 5,000 words, counted once per target locale — so a small SvelteKit app in three or four languages, not a whole corpus in all 56. Which plan do I need? works it out for your own string count.