URL-based locale routing with compile-time translations using Paraglide JS v2.
Strategy
Paraglide JS v2 for type-safe, minimal-bundle internationalization with official SvelteKit integration.
| Aspect | Approach |
|---|---|
| Library | Paraglide JS v2 |
| Routing | URL prefix via reroute hook (no route params) |
| Messages | JSON files per language, compiled to typed functions |
| Format | ICU MessageFormat |
| Date/Number | Native Intl API (decoupled from translation locale) |
| Detection | URL → Cookie → baseLocale |
Why Paraglide
| Feature | Paraglide v2 | svelte-i18n |
|---|---|---|
| Bundle size | ~1-2KB (compiled) | 14KB (FormatJS runtime) |
| Type safety | Full (typed functions) | None (string keys) |
| Tree-shaking | Per-page | No (all together) |
| Hydration | No mismatch | Risk of mismatch |
| Runtime switching | Reload required | Yes |
| Message format | ICU | ICU |
| Maintenance | Active (official) | "Due for reworking" |
For 2-5 languages, Paraglide's type safety and minimal bundle win. For 20+ languages, reconsider if bundle size becomes an issue.
Why not svelte-i18n? 14KB runtime, no type safety, hydration mismatch risk, maintainer uncertainty.
Why not typesafe-i18n? Creator passed away in 2023, library effectively abandoned.
Setup
Dependencies
Added via npx sv add paraglide:
{
"devDependencies": {
"@inlang/paraglide-js": "latest"
}
}
See development-environment.md for container workflow.
Project Configuration
// project.inlang/settings.json
{
"$schema": "https://inlang.com/schema/project-settings",
"sourceLanguageTag": "en",
"languageTags": ["en", "de", "ru"],
"modules": [
"https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-empty-pattern@latest/dist/index.js",
"https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-missing-translation@latest/dist/index.js"
],
"plugin.inlang.messageFormat": {
"pathPattern": "./messages/{languageTag}.json"
}
}
Vite Configuration
Trimmed for clarity — see
vite.config.tsin the repo for the production config includingserver.watch.ignoredfor the paraglide outdir.
// vite.config.ts
import { paraglideVitePlugin } from '@inlang/paraglide-js';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import UnoCSS from 'unocss/vite';
export default defineConfig({
plugins: [
UnoCSS(),
paraglideVitePlugin({
project: './project.inlang',
outdir: './src/lib/paraglide',
strategy: ['url', 'cookie', 'baseLocale']
}),
sveltekit()
]
});
Integration Points
Reroute Hook (Shared)
Strips locale prefix from URL before routing:
// src/hooks.ts
import type { Reroute } from '@sveltejs/kit';
import { deLocalizeUrl } from '$lib/paraglide/runtime';
export const reroute: Reroute = ({ url }) => {
return deLocalizeUrl(url).pathname;
};
Server Hook
Injects locale into HTML lang attribute:
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
import { paraglideMiddleware } from '$lib/paraglide/server';
const ALLOWED_LOCALES = new Set(['en', 'de', 'ru']);
export const handle: Handle = ({ event, resolve }) =>
paraglideMiddleware(event.request, ({ request, locale }) => {
const safeLocale = ALLOWED_LOCALES.has(locale) ? locale : 'en';
event.request = request;
return resolve(event, {
transformPageChunk: ({ html }) => html.replace('%lang%', safeLocale)
});
});
HTML Template
<!-- src/app.html -->
<!doctype html>
<html lang="%lang%">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
Message Files
Structure
messages/
en.json -- English (base)
de.json -- German
ru.json -- Russian
Format (ICU MessageFormat)
// messages/en.json
{
"greeting": "Hello, {name}!",
"items": "{count, plural, one {# item} other {# items}}",
"pronoun": "{gender, select, male {He} female {She} other {They}} liked your post."
}
// messages/de.json
{
"greeting": "Hallo, {name}!",
"items": "{count, plural, one {# Element} other {# Elemente}}",
"pronoun": "{gender, select, male {Er} female {Sie} other {Sie}} mochte deinen Beitrag."
}
// messages/ru.json
{
"greeting": "Привет, {name}!",
"items": "{count, plural, one {# элемент} few {# элемента} many {# элементов} other {# элемента}}",
"pronoun": "{gender, select, male {Ему} female {Ей} other {Им}} понравился ваш пост."
}
Russian plural categories (CLDR): one (1, 21, 31…), few (2-4, 22-24…), many (0, 5-19, 100…), other (decimals). Always include all four for Russian — omitting many is a correctness bug.
ICU MessageFormat features:
- Interpolation:
{name},{count} - Pluralization:
{count, plural, one {...} other {...}} - Selection:
{gender, select, male {...} female {...} other {...}} - Numbers:
{value, number},{value, number, percent}
Usage
In Components
<script lang="ts">
import * as m from '$lib/paraglide/messages';
</script>
<h1>{m.greeting({ name: 'World' })}</h1>
<!-- Pluralization -->
<p>{m.items({ count: 5 })}</p>
<!-- Selection -->
<p>{m.pronoun({ gender: 'female' })}</p>
Type Safety
// ✅ Type-safe - autocomplete and error checking
m.greeting({ name: 'Alice' });
// ❌ Compile error - missing required parameter
m.greeting();
// ❌ Compile error - unknown message key
m.unknownKey();
Link Localization
All internal links must use localizeHref() to preserve the locale prefix:
<script lang="ts">
import { localizeHref } from '$lib/i18n';
</script>
<a href={localizeHref('/about')}>About</a>
<a href={localizeHref('/settings')}>Settings</a>
For goto() calls:
import { goto } from '$app/navigation';
import { localizeHref } from '$lib/i18n';
goto(localizeHref('/dashboard'));
For active state detection, use deLocalizeHref() to strip the locale prefix before comparing:
import { deLocalizeHref } from '$lib/i18n';
import { page } from '$app/state';
const path = deLocalizeHref(page.url.pathname);
const isActive = path === '/about';
Date & Number Formatting
Formatting decoupled from translation locale. Locale is passed explicitly to every formatter — no implicit getLocale() reads — so SSR and the first client paint render byte-identical strings (no hydration mismatch from navigator.languages divergence).
Call sites pass page.data.locale (set by the root +layout.server.ts). After hydration, getFormattingLocale() may upgrade the translation locale (de) to a regional one (de-CH) using navigator.languages on subsequent reactive renders.
Formatting Helper
// src/lib/i18n/formatting.ts
const LOCALE_DEFAULTS: Record<string, string> = {
en: 'en-US',
de: 'de-DE',
ru: 'ru-RU',
};
/**
* Resolve a regional formatting locale from a translation locale.
* SSR: returns the LOCALE_DEFAULTS entry (deterministic).
* CSR: prefers a `navigator.languages` entry that starts with the translation locale.
*/
export function getFormattingLocale(locale: string): string {
if (typeof navigator !== 'undefined' && navigator.languages) {
const preferred = navigator.languages.find((l) => l.startsWith(locale));
if (preferred) return preferred;
}
return LOCALE_DEFAULTS[locale] ?? locale;
}
export function formatDate(
date: Date,
locale: string,
options: Intl.DateTimeFormatOptions = { dateStyle: 'medium' },
): string {
return new Intl.DateTimeFormat(getFormattingLocale(locale), options).format(date);
}
export function formatNumber(
value: number,
locale: string,
options: Intl.NumberFormatOptions = {},
): string {
return new Intl.NumberFormat(getFormattingLocale(locale), options).format(value);
}
export function formatCurrency(value: number, locale: string, currency = 'USD'): string {
return new Intl.NumberFormat(getFormattingLocale(locale), {
style: 'currency',
currency,
}).format(value);
}
export function formatPercent(value: number, locale: string): string {
return new Intl.NumberFormat(getFormattingLocale(locale), {
style: 'percent',
minimumFractionDigits: 0,
maximumFractionDigits: 1,
}).format(value);
}
export function formatRelative(date: Date, locale: string): string {
const rtf = new Intl.RelativeTimeFormat(getFormattingLocale(locale), { numeric: 'auto' });
// ...diff math...
}
Usage
<script lang="ts">
import { formatDate, formatCurrency } from '$lib/i18n';
import { page } from '$app/state';
let { product } = $props();
</script>
<article>
<h2>{product.name}</h2>
<p class="price">{formatCurrency(product.price, page.data.locale, 'EUR')}</p>
<p class="date">Added: {formatDate(product.createdAt, page.data.locale)}</p>
</article>
Output by locale:
| Translation Locale | Browser Preference | Price | Date |
|---|---|---|---|
| en | en-US | €1,234.50 | Jan 15, 2025 |
| en | en-GB | €1,234.50 | 15 Jan 2025 |
| de | de-DE | 1.234,50 € | 15. Jan. 2025 |
| de | de-CH | CHF 1'234.50 | 15.01.2025 |
| ru | ru-RU | 1 234,50 ₽ | 15 янв. 2025 г. |
Language Switcher
Language switching uses client-side navigation with goto(). Paraglide's getLocale() reads from window.location.href, so after navigation all m.xxx() calls pick up the new locale. A {#key} block forces re-render since Paraglide messages aren't Svelte-reactive.
Client-side Switcher
<script lang="ts">
import { locales, localizeHref, cookieName, cookieMaxAge, extractLocaleFromUrl, baseLocale } from '$lib/paraglide/runtime';
import { page } from '$app/state';
import { goto } from '$app/navigation';
const LOCALE_NAMES: Record<string, string> = {
en: 'English',
de: 'Deutsch',
ru: 'Русский',
};
// Derive from reactive page.url so {#key} triggers re-render
const currentLocale = $derived(extractLocaleFromUrl(page.url.href) ?? baseLocale);
let switching = $state(false);
async function switchLocale(event: Event, lang: string) {
event.preventDefault();
if (switching || lang === currentLocale) return;
switching = true;
// Update Paraglide cookie so server middleware resolves correctly
document.cookie = `${cookieName}=${lang}; path=/; max-age=${cookieMaxAge}`;
await goto(localizeHref(page.url.pathname, { locale: lang }), { invalidateAll: true });
switching = false;
}
</script>
{#key currentLocale}
<nav aria-label="Language">
{#each locales as lang}
<a
href={localizeHref(page.url.pathname, { locale: lang })}
hreflang={lang}
aria-current={lang === currentLocale ? 'page' : undefined}
onclick={(e) => switchLocale(e, lang)}
>
{LOCALE_NAMES[lang]}
</a>
{/each}
</nav>
{/key}
Key points:
goto()withinvalidateAll: truere-runs all load functions with the new locale{#key currentLocale}forces re-render — Paraglide messages aren't Svelte-reactiveextractLocaleFromUrl(page.url.href)derives locale from reactivepage.url- Cookie is set before navigation so server middleware resolves correctly
- No full page reload — avoids Vite HMR reconnection storms in dev mode
SEO
Canonical
Implemented in src/routes/[[locale=locale]]/+layout.svelte:
<link rel="canonical" href={page.url.origin + page.url.pathname} />
Each locale page canonicals to its own URL: /de/blog → /de/blog, not /blog. Never de-localize the canonical for translated pages. Pointing /de/blog at /blog tells Google the translation is a duplicate and suppresses it from the index. The hreflang cluster + x-default express the locale relationship; canonical stays self-referential.
Hreflang Tags
Implemented in src/routes/[[locale=locale]]/+layout.svelte:
<script lang="ts">
import { page } from '$app/state';
import { locales, localizeHref, baseLocale } from '$lib/paraglide/runtime';
let { children } = $props();
</script>
<svelte:head>
{#each locales as lang}
<link
rel="alternate"
hreflang={lang}
href="{page.url.origin}{localizeHref(page.url.pathname, { locale: lang })}"
/>
{/each}
<link
rel="alternate"
hreflang="x-default"
href="{page.url.origin}{localizeHref(page.url.pathname, { locale: baseLocale })}"
/>
</svelte:head>
{@render children()}
x-default must resolve to the base-locale (en) URL, not page.url.pathname. On a /de/... page, page.url.pathname is the German path — x-default pointing there is wrong. localizeHref(pathname, { locale: baseLocale }) strips the locale prefix and returns the unprefixed en URL.
The sitemap (/sitemap.xml) emits reciprocal xhtml:link rel="alternate" hreflang tags per URL block, keeping it consistent with these head tags.
Translated Meta Tags
<!-- src/routes/+page.svelte -->
<script lang="ts">
import * as m from '$lib/paraglide/messages';
</script>
<svelte:head>
<title>{m.home_meta_title()}</title>
<meta name="description" content={m.home_meta_description()} />
</svelte:head>
<main>
<h1>{m.home_heading()}</h1>
</main>
Content Translation (Database)
For short user-generated or CMS fields (titles, names, summaries) stored in the database.
Convention: source column + i18n JSONB sidecar
Each translatable field is two columns:
<name>—text NOT NULL, holds the EN canonical value (always present, queryable, indexable).<name>_i18n—jsonb NOT NULL DEFAULT '{}'::jsonb, partial map keyed by non-base locales only (de,ru, …).
EN never lives in the JSONB map — the source column is canonical. This keeps EN fast (plain column reads, full-text indexable) and stores translations as a small additive sidecar.
Schema
// src/lib/server/db/schema/blog/post.ts
import { pgTable, text, jsonb } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import type { TranslationMap } from '$lib/i18n';
export const post = pgTable('post', {
id: text('id').primaryKey(),
slug: text('slug').notNull(),
name: text('name').notNull(), // EN canonical
nameI18n: jsonb('name_i18n').$type<TranslationMap>().notNull().default(sql`'{}'::jsonb`),
description: text('description').notNull(), // EN canonical
descriptionI18n: jsonb('description_i18n').$type<TranslationMap>().notNull().default(sql`'{}'::jsonb`),
authorId: text('author_id').notNull(),
});
// Example row:
// name: "Hello World", nameI18n: { de: "Hallo Welt", ru: "Привет мир" }
TranslationMap = Partial<Record<Exclude<Locale, 'en'>, string>> — the type system enforces "no en key in the JSONB map."
Resolver: tc() and tcStrict()
// src/lib/i18n/translate.ts
import type { Locale } from './runtime';
export type TranslationMap = Partial<Record<Exclude<Locale, 'en'>, string>>;
/**
* Resolve order: requested locale → EN source → first non-empty translation → ''.
* Locale is passed EXPLICITLY (no getLocale() coupling) so the resolver is pure
* and SSR-safe. Server: pass `event.locals.locale`. Client: pass `getLocale()`.
*/
export function tc(source: string, i18n: TranslationMap | null | undefined, requested: Locale): string;
/**
* Strict variant: returns `null` when no translation exists for the requested
* locale. Use in admin views that need to render "missing" honestly instead of
* silently falling back to EN.
*/
export function tcStrict(source: string, i18n: TranslationMap | null | undefined, requested: Locale): string | null;
Usage
<!-- public route -->
<script lang="ts">
import { tc } from '$lib/i18n';
let { post, locale } = $props();
</script>
<article>
<h1>{tc(post.name, post.nameI18n, locale)}</h1>
<p>{tc(post.description, post.descriptionI18n, locale)}</p>
</article>
// server load — resolve at the load edge so the data graph stays locale-agnostic
import { tc } from '$lib/i18n';
export async function load({ locals }) {
const tags = await getTags();
return {
tags: tags.map((t) => ({ ...t, name: tc(t.name, t.nameI18n, locals.locale) })),
};
}
File Structure
project.inlang/
settings.json -- Paraglide config
messages/
en.json -- English messages
de.json -- German messages
ru.json -- Russian messages
src/lib/
paraglide/ -- Generated (compiler output, gitignored)
messages.js -- Typed message functions
runtime.js -- Locale runtime
server.js -- Server middleware
i18n/
index.ts -- Barrel: re-exports formatting, runtime, translate
runtime.ts -- Re-exports paraglide runtime + Locale type
messages.ts -- Re-exports paraglide messages (and `* as m`)
formatting.ts -- Intl formatters (locale passed explicitly)
translate.ts -- DB content resolver: tc() / tcStrict() + TranslationMap
href.ts -- Re-exports localizeHref, deLocalizeHref
plurals.test.ts -- CLDR plural-completeness contract test
src/
hooks.ts -- reroute
hooks.server.ts -- paraglideMiddleware + %lang%
app.html -- lang="%lang%"
Summary
| What | How |
|---|---|
| Library | Paraglide JS v2 |
| Messages | messages/{locale}.json |
| Format | ICU MessageFormat |
| Usage | import * as m from '$lib/paraglide/messages'; m.greeting({ name }) |
| Routing | reroute hook (no route params) |
| Dates/Numbers | Native Intl API; locale passed explicitly per call (SSR/CSR parity) |
| DB content | <name> text column + <name>_i18n JSONB sidecar; resolved via tc(source, i18n, locale) |
| Plural correctness | plurals.test.ts enforces CLDR categories per locale (catches one/other-only Russian) |
| Locale parity | bun run i18n:check-missing lists keys present in en.json but missing in de/ru (and orphans). Wired into validate |
| Workflow | EN authored canonically; non-EN translations authored by the AI in the same conversation as the EN edit (mirrors the name + name_i18n DB convention) |
| HTML lang | hooks.server.ts + transformPageChunk |
| Language switch | Client-side goto() + {#key} for reactive re-render |
Related
- pages.md -
/showcase/i18nroute demonstrating these patterns - db/relational.md - Translated content schema with JSON columns