Skip to main content
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", "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();

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

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

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

Sources

← Back to Blueprint