Перейти к основному содержимому
On this page

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.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

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();

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() with invalidateAll: true re-runs all load functions with the new locale
  • {#key currentLocale} forces re-render — Paraglide messages aren't Svelte-reactive
  • extractLocaleFromUrl(page.url.href) derives locale from reactive page.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>_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/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

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

Sources

← Back to Blueprint