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 [[locale=locale]] optional catch-all param + locale matcher |
| 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": "^2.18.0"
}
}
See development-environment.md for container workflow.
Project Configuration
// project.inlang/settings.json
{
"$schema": "https://inlang.com/schema/project-settings",
"baseLocale": "en",
"locales": ["en", "de", "ru"],
"modules": [
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js",
"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js"
],
"plugin.inlang.messageFormat": {
"pathPattern": "./messages/{locale}.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
Locale Resolution (no reroute hook)
There is no reroute hook. The locale lives directly in the route tree as
the optional [[locale=locale]] catch-all segment, so SvelteKit matches
localized and canonical URLs with the same route files:
- The default locale (
en) is served unprefixed:/about. - Non-default locales carry a validated prefix:
/de/about,/ru/about.
The param matcher rejects any non-locale first segment, so /admin, /api,
/blog, etc. are never mistaken for a locale:
// src/params/locale.ts
import type { ParamMatcher } from '@sveltejs/kit';
export const match: ParamMatcher = (param) => param === 'en' || param === 'de' || param === 'ru';
/en/* URLs have no canonical home (the base locale is unprefixed), so a
dedicated stripBaseLocalePrefix handle 308-redirects /en/foo → /foo before
routing, keeping search engines off duplicate URLs.
Server Hook
The i18n handle wraps Paraglide's paraglideMiddleware. It resolves the
locale from the URL, stamps event.locals.locale (the canonical hand-off so
domain code never imports from $lib/paraglide), persists the
PARAGLIDE_LOCALE cookie when it diverges, and injects the locale into the
<html lang> attribute via the %lang% placeholder.
// src/hooks.server.ts (simplified — see repo for cookie + style injection)
import type { Handle } from '@sveltejs/kit';
import { paraglideMiddleware } from '$lib/paraglide/server';
import { locales } from '$lib/i18n';
const ALLOWED_LOCALES = new Set<string>(locales);
const i18n: Handle = ({ event, resolve }) =>
paraglideMiddleware(event.request, ({ request, locale }) => {
const safeLocale = (ALLOWED_LOCALES.has(locale) ? locale : 'en') as App.Locals['locale'];
event.locals.locale = safeLocale;
event.request = request;
return resolve(event, {
transformPageChunk: ({ html }) => html.replace('%lang%', safeLocale),
});
});
It runs inside the composed handle chain (after stripBaseLocalePrefix,
before auth):
export const handle = sequence(
securityHeaders,
stripBaseLocalePrefix, // 308 /en/* → /*
loadStyle,
i18n, // sets event.locals.locale + %lang%
authCaptchaGate,
authHandler,
sessionPopulate,
csrfProtection,
consentLoader,
debugOwnerLoader,
devRouteGuard,
analyticsCollector,
);
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
The shipping switcher lives in src/lib/components/shell/UserMenu.svelte. Each locale is a plain <a data-sveltekit-reload> link that triggers a full page reload. The reload lets the server hook re-resolve the locale and re-render every message from scratch — Paraglide messages aren't Svelte-reactive, so a full document load is the simplest correct refresh.
Shipping Switcher (full reload)
<script lang="ts">
import { locales, localizeHref } from '$lib/paraglide/runtime';
import * as m from '$lib/paraglide/messages';
import { page } from '$app/state';
const currentLocale = $derived(page.data.locale ?? 'en');
const currentHref = $derived(page.url.pathname + page.url.search + page.url.hash);
function localeLabel(locale: string): string {
switch (locale) {
case 'en': return m.shell_language_en();
case 'de': return m.shell_language_de();
case 'ru': return m.shell_language_ru();
default: return locale;
}
}
</script>
<nav aria-label="Language">
{#each locales as locale}
<a
href={localizeHref(currentHref, { locale })}
lang={locale}
aria-current={locale === currentLocale ? 'true' : undefined}
data-sveltekit-reload
>
{localeLabel(locale)}
</a>
{/each}
</nav>
Key points:
data-sveltekit-reloadforces a full document load — the server hook re-resolves the locale and persists the cookie, so the switcher sets no cookie itselfcurrentLocalereadspage.data.locale(set by the root layout), notextractLocaleFromUrl- Labels come from Paraglide messages (
m.shell_language_*), so the switcher itself is translated localizeHref(currentHref, { locale })preserves the current path, query, and hash under the new locale prefix
The /showcases/i18n demo uses a goto() + invalidateAll client-side variant (no reload, with a {#key} re-render block) to illustrate the alternative. The shipped UserMenu uses the full-reload link.
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={localizeHref(page.url.pathname, { locale: lang })}
/>
{/each}
<link
rel="alternate"
hreflang="x-default"
href={localizeHref(page.url.pathname, { locale: baseLocale })}
/>
</svelte:head>
{@render children()}
The hreflang and x-default hrefs are root-relative (no origin prefix); only the canonical link is absolute.
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/tag.ts
import { pgSchema, text, jsonb } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import type { TranslationMap } from '$lib/i18n/translate';
const blogSchema = pgSchema('blog');
export const tag = blogSchema.table('tag', {
id: text('id').primaryKey(),
slug: text('slug').notNull(),
name: text('name').notNull(), // EN canonical
nameI18n: jsonb('name_i18n').$type<TranslationMap>().notNull().default(sql`'{}'::jsonb`),
});
// Example row:
// name: "Hello World", nameI18n: { de: "Hallo Welt", ru: "Привет мир" }
The domain and tag tables carry the JSONB sidecar. The post table itself has no translatable columns — post content is translated through per-locale rows in the revision table (each revision row has a locale column with its own title/summary/markdown).
TranslationMap = Partial<Record<Exclude<Locale, 'en'>, string>> — the type system enforces "no en key in the JSONB map."
Resolver: tc()
// 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;
Usage
<!-- public route -->
<script lang="ts">
import { tc } from '$lib/i18n';
let { tag, locale } = $props();
</script>
<span class="tag">{tc(tag.name, tag.nameI18n, locale)}</span>
// 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 only
runtime.ts -- Re-exports paraglide runtime (incl. localizeHref, deLocalizeHref) + Locale type
messages.ts -- Standalone re-export of paraglide messages (NOT in the barrel)
formatting.ts -- Intl formatters (locale passed explicitly)
translate.ts -- DB content resolver: tc() + TranslationMap
plurals.test.ts -- CLDR plural-completeness contract test
src/
params/
locale.ts -- [[locale=locale]] param matcher (en/de/ru)
hooks.server.ts -- stripBaseLocalePrefix + paraglideMiddleware (i18n) + %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 | [[locale=locale]] optional catch-all (no reroute hook); /en/* 308 → /* |
| 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 -
/showcases/i18nroute demonstrating these patterns - db/relational.md - Translated content schema with JSON columns