Tutorials

SvelteKit i18n: Building Type-Safe Multilingual Apps with Svelte 5

Eray Gündoğmuş
Eray Gündoğmuş
·10 min read
Share
SvelteKit i18n: Building Type-Safe Multilingual Apps with Svelte 5

SvelteKit i18n: Building Type-Safe Multilingual Apps with Svelte 5

SvelteKit's architecture lends itself surprisingly well to internationalization. Its server-side load functions run before rendering, making locale detection and message loading a natural fit. URL-based routing with layout hierarchies means you can prefix routes with /en/ or /de/ without fighting the framework. And Svelte 5's runes — $state, $derived, $effect — give you reactive locale switching without the boilerplate stores required in Svelte 4.

This guide walks through building a production-ready multilingual SvelteKit app with Svelte 5. We'll cover locale detection, type-safe translation functions, pluralization using the Intl API, and SEO considerations. We'll also look honestly at the trade-offs between bundling translation files versus serving them from a CDN.

Project Setup

Start with a fresh SvelteKit project (TypeScript template):

npx sv create my-i18n-app
cd my-i18n-app
npm install

For this guide we'll keep dependencies minimal — the Intl APIs built into modern browsers handle formatting, and we'll write a thin translation layer ourselves. If you need ICU message format support, libraries like @messageformat/core can slot in.

Folder structure we're targeting:

src/
  lib/
    i18n/
      index.ts          # Core translation utilities
      types.ts          # Generated/shared types
      locales/
        en.json
        de.json
        fr.json
  hooks.server.ts       # Locale detection
  routes/
    [locale]/
      +layout.server.ts # Load translations
      +layout.svelte    # Provide translations to tree
      +page.svelte

Define supported locales in a shared config:

// src/lib/i18n/config.ts
export const SUPPORTED_LOCALES = ['en', 'de', 'fr'] as const;
export type Locale = typeof SUPPORTED_LOCALES[number];
export const DEFAULT_LOCALE: Locale = 'en';

export function isSupportedLocale(locale: string): locale is Locale {
  return SUPPORTED_LOCALES.includes(locale as Locale);
}

Locale Detection and Routing

SvelteKit's hooks.server.ts is the right place to detect a user's locale before any route runs. We'll read from the URL first, fall back to the Accept-Language header, and then fall back to the default.

// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
import { DEFAULT_LOCALE, isSupportedLocale } from '$lib/i18n/config';

export const handle: Handle = async ({ event, resolve }) => {
  // Extract locale from URL path segment
  const pathLocale = event.url.pathname.split('/')[1];

  const locale = isSupportedLocale(pathLocale)
    ? pathLocale
    : detectFromHeader(event.request.headers.get('accept-language'));

  event.locals.locale = locale;

  return resolve(event, {
    transformPageChunk: ({ html }) => html.replace('%lang%', locale),
  });
};

function detectFromHeader(header: string | null): import('$lib/i18n/config').Locale {
  if (!header) return DEFAULT_LOCALE;

  const accepted = header
    .split(',')
    .map((part) => {
      const [lang, q = 'q=1'] = part.trim().split(';');
      return { lang: lang.split('-')[0].trim(), q: parseFloat(q.split('=')[1]) };
    })
    .sort((a, b) => b.q - a.q);

  for (const { lang } of accepted) {
    if (isSupportedLocale(lang)) return lang;
  }

  return DEFAULT_LOCALE;
}

Add the locale field to your app's locals type:

// src/app.d.ts
import type { Locale } from '$lib/i18n/config';

declare global {
  namespace App {
    interface Locals {
      locale: Locale;
    }
  }
}

For URL-based routing, create a [locale] segment at the top level and add a params matcher to validate it:

// src/params/locale.ts
import { isSupportedLocale } from '$lib/i18n/config';

export function match(param: string): boolean {
  return isSupportedLocale(param);
}

Reference this matcher in your route directory: src/routes/[locale=locale]/. SvelteKit will only match paths where the segment is a valid locale string.

Loading Translations

In +layout.server.ts beneath the [locale] segment, load the translations for the current locale. This runs server-side before the page renders, so there's no flash of untranslated content.

// src/routes/[locale=locale]/+layout.server.ts
import type { LayoutServerLoad } from './$types';

export const load: LayoutServerLoad = async ({ params, fetch }) => {
  const locale = params.locale;

  // Option A: Bundled JSON (imported directly)
  const messages = await import(`$lib/i18n/locales/${locale}.json`);

  // Option B: CDN endpoint (no redeploy needed for translation updates)
  // const res = await fetch(`https://cdn.example.com/translations/${locale}.json`);
  // const messages = await res.json();

  return { locale, messages: messages.default };
};

In the layout component, make translations available to child components:

<!-- src/routes/[locale=locale]/+layout.svelte -->
<script lang="ts">
  import { setContext } from 'svelte';
  import { createTranslator } from '$lib/i18n';
  import type { LayoutData } from './$types';

  const { data }: { data: LayoutData } = $props();

  const t = createTranslator(data.messages);
  setContext('t', t);
  setContext('locale', data.locale);
</script>

<slot />

Using Translations in Components with Svelte 5 Runes

With Svelte 5, you can use $state and $derived to make translations reactive when the locale changes. Here's a translation store pattern that works cleanly with runes:

// src/lib/i18n/index.ts
import type { Messages } from './types';

export function createTranslator(messages: Messages) {
  return function t(key: keyof Messages, params?: Record<string, string | number>): string {
    const template = messages[key] ?? key;

    if (!params) return template;

    return Object.entries(params).reduce<string>(
      (result, [k, v]) => result.replaceAll(`{${k}}`, String(v)),
      template
    );
  };
}

In a component, retrieve the translator from context:

<!-- src/routes/[locale=locale]/+page.svelte -->
<script lang="ts">
  import { getContext } from 'svelte';
  import type { Translator } from '$lib/i18n/types';

  const t = getContext<Translator>('t');
  const locale = getContext<string>('locale');

  // Svelte 5: derived state from context
  let greeting = $derived(t('home.greeting', { name: 'World' }));
</script>

<h1>{greeting}</h1>
<p>{t('home.description')}</p>

For locale switching without a full page reload, update the translator reactively using $state:

<script lang="ts">
  import { createTranslator } from '$lib/i18n';
  import type { Messages } from '$lib/i18n/types';

  let messages = $state<Messages>({} as Messages);
  let t = $derived(createTranslator(messages));

  async function switchLocale(newLocale: string) {
    const res = await fetch(`/api/translations/${newLocale}`);
    messages = await res.json();
    // Update URL without reload
    history.pushState({}, '', `/${newLocale}${window.location.pathname.replace(/^\/[^/]+/, '')}`);
  }
</script>

Type Safety

A type-safe translation function catches missing keys at compile time and enables IDE autocompletion. Generate types from your base locale file:

// src/lib/i18n/types.ts
// Auto-generated from en.json — do not edit manually
import type en from './locales/en.json';

export type Messages = typeof en;
export type MessageKey = keyof Messages;
export type Translator = (key: MessageKey, params?: Record<string, string | number>) => string;

You can automate this step. A simple script to regenerate types:

// scripts/generate-i18n-types.ts
import { writeFileSync } from 'fs';

const output = `// Auto-generated from en.json — do not edit manually
import type en from './locales/en.json';

export type Messages = typeof en;
export type MessageKey = keyof Messages;
export type Translator = (key: MessageKey, params?: Record<string, string | number>) => string;
`;

writeFileSync('src/lib/i18n/types.ts', output);
console.log('Types generated.');

Wire it into your build process in package.json:

{
  "scripts": {
    "generate:i18n": "tsx scripts/generate-i18n-types.ts",
    "prebuild": "npm run generate:i18n",
    "predev": "npm run generate:i18n"
  }
}

Now t('home.greetng') produces a TypeScript error because 'home.greetng' does not exist in Messages. Typos caught at compile time rather than discovered by users in production.

This is one area where platforms like Better i18n add real value: they generate SDK types from your translation schema automatically, so every time a translator adds or renames a key, your TypeScript types update without manual script runs.

If you're building React or Next.js apps alongside Svelte, you'll find the same type-safe patterns apply — see React i18n: The Complete Guide to React Internationalization and Next.js App Router i18n: Server Components and RSC Patterns for framework-specific implementations.

Pluralization and Formatting

JavaScript's built-in Intl API handles pluralization correctly across locales without external dependencies:

// src/lib/i18n/format.ts

export function pluralize(
  locale: string,
  count: number,
  forms: Record<Intl.LDMLPluralRule, string>
): string {
  const rule = new Intl.PluralRules(locale).select(count);
  const template = forms[rule] ?? forms.other;
  return template.replace('{count}', String(count));
}

export function formatNumber(locale: string, value: number, options?: Intl.NumberFormatOptions): string {
  return new Intl.NumberFormat(locale, options).format(value);
}

export function formatDate(locale: string, date: Date, options?: Intl.DateTimeFormatOptions): string {
  return new Intl.DateTimeFormat(locale, options).format(date);
}

Usage in a component:

<script lang="ts">
  import { pluralize, formatNumber, formatDate } from '$lib/i18n/format';
  import { getContext } from 'svelte';

  const locale = getContext<string>('locale');

  const itemCount = 3;
  const price = 1499.99;
  const publishedAt = new Date('2024-06-15');

  let itemLabel = $derived(
    pluralize(locale, itemCount, {
      one: '{count} item',
      other: '{count} items',
      zero: 'no items',
      two: '{count} items',
      few: '{count} items',
      many: '{count} items',
    })
  );

  let formattedPrice = $derived(formatNumber(locale, price, { style: 'currency', currency: 'USD' }));
  let formattedDate = $derived(formatDate(locale, publishedAt, { dateStyle: 'long' }));
</script>

<p>{itemLabel}</p>
<p>{formattedPrice}</p>
<p>{formattedDate}</p>

For German locale, 1499.99 renders as 1.499,99 $ and the date as 15. Juni 2024 — with no extra libraries.

SEO for Multilingual SvelteKit

Search engines need explicit signals about alternate language versions of your pages. Add hreflang tags and localized metadata in your layout's <svelte:head>. Getting this right is essential for multilingual rankings — our complete guide to i18n SEO, hreflang tags, and locale URLs covers every implementation detail and common pitfall.

<!-- src/routes/[locale=locale]/+layout.svelte -->
<script lang="ts">
  import { page } from '$app/stores';
  import { SUPPORTED_LOCALES } from '$lib/i18n/config';

  const { data }: { data: LayoutData } = $props();

  // Build alternate URLs for all supported locales
  let alternates = $derived(
    SUPPORTED_LOCALES.map((loc) => ({
      locale: loc,
      url: $page.url.href.replace(`/${data.locale}/`, `/${loc}/`),
    }))
  );
</script>

<svelte:head>
  {#each alternates as { locale, url }}
    <link rel="alternate" hreflang={locale} href={url} />
  {/each}
  <link rel="alternate" hreflang="x-default" href={alternates.find(a => a.locale === 'en')?.url} />
</svelte:head>

<slot />

For prerendering, configure SvelteKit to generate all locale variants:

// svelte.config.js
const config = {
  kit: {
    prerender: {
      entries: [
        '/en',
        '/de',
        '/fr',
        '/en/about',
        '/de/about',
        '/fr/about',
        // ... or generate dynamically
      ],
    },
  },
};

For dynamic prerender entries, use a +page.server.ts with entries export:

// src/routes/[locale=locale]/+page.server.ts
import { SUPPORTED_LOCALES } from '$lib/i18n/config';

export function entries() {
  return SUPPORTED_LOCALES.map((locale) => ({ locale }));
}

CDN-First vs Bundled Translations

Both approaches work — the right choice depends on your workflow.

Bundled translations (import from JSON):

  • Zero additional HTTP requests per page load
  • Translations version-locked to the deployment
  • Works offline and without external dependencies
  • Updating a typo in a translation requires a redeploy

CDN delivery:

  • Translation updates go live without redeploying the app
  • Translators can push changes independently of engineers
  • Adds a network request (mitigated with aggressive caching)
  • Requires cache invalidation strategy

For most teams early in their i18n journey, bundled JSON is simpler. As your translation team grows and the pace of content updates increases, the deployment coupling becomes painful. Shipping a code release just to fix a German button label doesn't scale. Automating i18n in your CI/CD pipeline is the natural next step once you outgrow the bundled approach.

This is the core problem that platforms like Better i18n are built around: your translation messages live on their CDN, served via versioned URLs with long cache TTLs. Engineers update code, translators update translations — independently, without blocking each other. The features page covers how this works in detail, including how cache invalidation is handled on translation publish.

There's a middle ground too: fetch translations at build time from a CDN and bundle the result into the deployment artifact. You get fast local loads without bundling locale files into your repo, and you still need a redeploy for updates.

Summary

SvelteKit and Svelte 5 form a solid foundation for multilingual apps:

  • Hooks handle locale detection server-side before any rendering occurs
  • Dynamic route segments with params matchers give you clean URL-based locale routing
  • Load functions fetch translations once per layout and pass them down the component tree
  • Svelte 5 runes ($state, $derived) make locale switching reactive without manual store subscriptions
  • TypeScript types generated from your base locale catch missing or misspelled keys at compile time
  • Intl APIs handle pluralization and number/date formatting correctly for every locale
  • svelte:head hreflang tags and prerendered locale routes keep search engines informed

The architecture described here handles most production requirements. The main gap it leaves is operational: as your app grows, managing translation files inside the repo creates friction between engineers and translators. CDN delivery and automated type generation are the two areas where purpose-built tooling pays for itself fastest.

If you're building a SvelteKit app with a real translation team, take a look at Better i18n's Svelte integration — it provides a type-safe SDK, CDN delivery out of the box, and a Git-based review workflow so translation changes go through the same review process as code.

Better i18n is a developer-first localization platform built for modern frontend teams. Type-safe SDKs, Git-based workflows, CDN delivery, and AI translation with glossary enforcement — without locale files in your repo.


Take your app global with better-i18n

better-i18n combines AI-powered translations, git-native workflows, and global CDN delivery into one developer-first platform. Stop managing spreadsheets and start shipping in every language.

Get started free → · Explore features · Read the docs