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", "fr"],
"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
// 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', 'fr']);
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
fr.json -- French
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."
}
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. Use browser's Intl API with full locale (e.g., de-CH).
Formatting Helper
// src/lib/i18n/formatting.ts
import { getLocale } from '$lib/paraglide/runtime';
/**
* Get formatting locale with regional preference.
* Translation: "de" → Formatting: "de-CH" (user's browser preference)
*/
export function getFormattingLocale(): string {
const lang = getLocale(); // "de"
// Check browser's preferred regional variant
if (typeof navigator !== 'undefined') {
const preferred = navigator.languages.find(l => l.startsWith(lang));
if (preferred) return preferred; // "de-CH"
}
// Fallback to default regional variant
const defaults: Record<string, string> = {
en: 'en-US',
de: 'de-DE',
fr: 'fr-FR',
};
return defaults[lang] ?? lang;
}
/**
* Format date according to user's regional preferences.
*/
export function formatDate(
date: Date,
options: Intl.DateTimeFormatOptions = { dateStyle: 'medium' }
): string {
return new Intl.DateTimeFormat(getFormattingLocale(), options).format(date);
}
/**
* Format number.
*/
export function formatNumber(
value: number,
options: Intl.NumberFormatOptions = {}
): string {
return new Intl.NumberFormat(getFormattingLocale(), options).format(value);
}
/**
* Format currency.
*/
export function formatCurrency(value: number, currency = 'USD'): string {
return new Intl.NumberFormat(getFormattingLocale(), {
style: 'currency',
currency,
}).format(value);
}
/**
* Format percentage.
*/
export function formatPercent(value: number): string {
return new Intl.NumberFormat(getFormattingLocale(), {
style: 'percent',
minimumFractionDigits: 0,
maximumFractionDigits: 1,
}).format(value);
}
/**
* Format relative time (e.g., "2 days ago").
*/
export function formatRelative(date: Date): string {
const rtf = new Intl.RelativeTimeFormat(getFormattingLocale(), { numeric: 'auto' });
const diffMs = date.getTime() - Date.now();
const diffMins = Math.round(diffMs / (1000 * 60));
const diffHours = Math.round(diffMs / (1000 * 60 * 60));
const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24));
if (Math.abs(diffMins) < 60) {
return rtf.format(diffMins, 'minute');
}
if (Math.abs(diffHours) < 24) {
return rtf.format(diffHours, 'hour');
}
return rtf.format(diffDays, 'day');
}
Usage
<script lang="ts">
import { formatDate, formatCurrency } from '$lib/i18n/formatting';
let { product } = $props();
</script>
<article>
<h2>{product.name}</h2>
<p class="price">{formatCurrency(product.price, 'EUR')}</p>
<p class="date">Added: {formatDate(product.createdAt)}</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 |
| fr | fr-FR | 1 234,50 € | 15 janv. 2025 |
| fr | fr-CA | 1 234,50 € | 15 janv. 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',
fr: 'Français',
};
// 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
Hreflang Tags
<!-- src/routes/+layout.svelte -->
<script lang="ts">
import { page } from '$app/state';
import { locales, localizeHref } from '$lib/paraglide/runtime';
const baseUrl = 'https://example.com';
let { children } = $props();
</script>
<svelte:head>
{#each locales as lang}
<link
rel="alternate"
hreflang={lang}
href="{baseUrl}{localizeHref(page.url.pathname, { locale: lang })}"
/>
{/each}
<link
rel="alternate"
hreflang="x-default"
href="{baseUrl}{page.url.pathname}"
/>
</svelte:head>
{@render children()}
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 user-generated or CMS content stored in the database.
Schema
// src/lib/server/db/schema/content.ts
import { pgTable, text, jsonb, timestamp } from 'drizzle-orm/pg-core';
export const posts = pgTable('posts', {
id: text('id').primaryKey(),
slug: text('slug').notNull(),
// Translated fields stored as JSON
title: jsonb('title').$type<Record<string, string>>().notNull(),
content: jsonb('content').$type<Record<string, string>>().notNull(),
// Non-translated fields
authorId: text('author_id').notNull(),
publishedAt: timestamp('published_at'),
});
// Example data:
// title: { "en": "Hello World", "de": "Hallo Welt" }
Content Helper
// src/lib/i18n/content.ts
import { getLocale } from '$lib/paraglide/runtime';
type TranslatedField = Record<string, string> | null | undefined;
/**
* Translate content from database JSON field.
*/
export function tc(
translations: TranslatedField,
fallback = 'en'
): string {
if (!translations) return '';
const currentLocale = getLocale();
return (
translations[currentLocale] ??
translations[fallback] ??
Object.values(translations)[0] ??
''
);
}
/**
* Check if translation exists for a locale.
*/
export function hasTranslation(
translations: TranslatedField,
targetLocale: string
): boolean {
return Boolean(translations?.[targetLocale]);
}
/**
* Get all available locales for a translated field.
*/
export function getAvailableLocales(translations: TranslatedField): string[] {
if (!translations) return [];
return Object.keys(translations).filter((key) => translations[key]);
}
Usage
<script lang="ts">
import { tc } from '$lib/i18n/content';
let { post } = $props();
</script>
<article>
<h1>{tc(post.title)}</h1>
<div>{tc(post.content)}</div>
</article>
File Structure
project.inlang/
settings.json -- Paraglide config
messages/
en.json -- English messages
de.json -- German messages
fr.json -- French messages
src/lib/
paraglide/ -- Generated (compiler output)
messages.js -- Typed message functions
runtime.js -- Locale runtime
server.js -- Server middleware
i18n/
formatting.ts -- Intl formatters (decoupled)
content.ts -- DB content helper (tc)
href.ts -- Re-exports localizeHref, deLocalizeHref
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 (decoupled via getFormattingLocale()) |
| DB content | JSON columns with tc() helper |
| 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