Zum Hauptinhalt springen

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.ts in the repo for the production config including server.watch.ignored for 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();

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-reload forces a full document load — the server hook re-resolves the locale and persists the cookie, so the switcher sets no cookie itself
  • currentLocale reads page.data.locale (set by the root layout), not extractLocaleFromUrl
  • 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>_i18njsonb 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

  • pages.md - /showcases/i18n route demonstrating these patterns
  • db/relational.md - Translated content schema with JSON columns

Sources

← Back to Blueprint

Geht dieses Pattern noch besser? Sag uns, wie.

Feedback geben