Starting without breaking anything
The most important property of a good i18n foundation is that it is completely invisible to existing users. English readers should notice nothing - no redirects, no URL changes, no behaviour differences. The foundation phase introduces locale awareness into the application without activating it for anyone.
This is achievable because the entire approach is additive. New config values, new type fields, new route groups. Nothing existing is modified until article three, when the content index learns to load translated data. By the end of this article you will have a fully wired locale detection system ready to serve translated content the moment it exists, while today it simply detects the locale, sets it on the request, and does nothing else with it.
Step 1 - Locale configuration
Everything locale-specific in the application reads from one source. src/lib/config.ts already holds your site-wide constants; add the locale layer alongside them.
// src/lib/config.ts
import { dev } from '$app/environment'
export const title = 'The Hackpile'
export const description = 'Practical guides for building with Svelte...'
export const url = dev ? 'http://localhost:5173' : 'https://hackpile.dev'
// ── Localisation ────────────────────────────────────────────────────────────
// English is the default and has no URL prefix. It is NOT in SUPPORTED_LOCALES
// because it never appears as a [lang] route parameter. Adding a language
// requires: (1) adding it here, (2) adding it to src/params/locale.ts.
export const SUPPORTED_LOCALES = ['pt', 'es'] as const
export type Locale = (typeof SUPPORTED_LOCALES)[number]
export const DEFAULT_LOCALE = 'en' as const
export const LOCALE_NAMES: Record<Locale, string> = {
pt: 'Português',
es: 'Español'
}
// Used by hreflang generation and the locale switcher component.
export const LOCALE_REGIONS: Record<Locale, string> = {
pt: 'pt-BR', // Brazilian Portuguese - the larger market
es: 'es' // Pan-Latin American Spanish
} DEFAULT_LOCALE is typed as 'en' rather than string so TypeScript can distinguish “this is English” from “this locale is unknown” in logic that checks locale === DEFAULT_LOCALE. SUPPORTED_LOCALES is a const tuple so Locale is the union 'pt' | 'es' rather than string, which lets the type system catch typos in locale handling code at compile time.
Step 2 - Extending the TypeScript types
Three fields need to be added to the Article interface in src/lib/content/types.ts. These are runtime fields injected by the content index - they never appear in frontmatter.
// src/lib/content/types.ts - add to the existing Article interface
export interface Article {
// ... all existing fields unchanged ...
// ── i18n fields (injected at load time, never in frontmatter) ─────────────
// lang: which locale this article is loaded for ('en' | 'pt' | 'es')
lang?: string
// isFallback: true when English is served because no translation exists yet
isFallback?: boolean
// translationStatus: quality indicator for translation badge UI
translationStatus?: 'complete' | 'partial' | 'machine-translated'
} Create the i18n module directory and its types file:
// src/lib/i18n/types.ts
export interface TranslationOverlay {
lang: string
translationStatus: 'complete' | 'partial' | 'machine-translated'
translatedBy?: string
// Translated frontmatter values
title?: string
description?: string
seo?: { title?: string; description?: string }
// Keyed prose sections - object keys are the segment `id` values from extract.ts
// (e.g. 'prose-0', 'prose-1'). Any id absent from this map falls back to English.
sections?: Record<string, string>
} Create the directory structure:
mkdir -p src/lib/i18n
mkdir -p src/translations/pt
mkdir -p src/translations/es
mkdir -p src/params Step 3 - Extending App.Locals
SvelteKit’s App.Locals interface attaches custom data to a server request, making it available in hooks.server.ts and all +layout.server.ts / +page.server.ts load functions. Open src/app.d.ts:
// src/app.d.ts
declare global {
namespace App {
interface Locals {
// null means English - the default locale, no URL prefix.
// A Locale value means a translated route is being served.
locale: import('$lib/config').Locale | null
}
interface PageData {}
interface PageState {}
interface Platform {}
}
}
export {} null represents English rather than an absent value. This distinction matters in load functions: locals.locale === null means “serve English”, not “locale detection failed”.
Step 4 - The HTML lang attribute placeholder
Update src/app.html to use a placeholder instead of a hardcoded lang="en". The hooks handler will replace it with the correct value on every request.
<!-- src/app.html -->
<!doctype html>
<html lang="%hackpile.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> Step 5 - Locale detection in hooks.server.ts
hooks.server.ts runs on every request before any route load function executes. It detects the locale from the URL, attaches it to event.locals, and injects the correct lang attribute into the HTML document.
// src/hooks.server.ts
import { SUPPORTED_LOCALES } from '$lib/config'
import type { Locale } from '$lib/config'
function detectLocale(pathname: string): Locale | null {
// The locale is always the first path segment when present.
// /pt/sveltekit/loading-data → 'pt'
// /sveltekit/loading-data → null (English)
const firstSegment = pathname.split('/').filter(Boolean)[0] as Locale
return SUPPORTED_LOCALES.includes(firstSegment) ? firstSegment : null
}
export const handle = async ({ event, resolve }) => {
event.locals.locale = detectLocale(event.url.pathname)
const response = await resolve(event, {
filterSerializedResponseHeaders: (name) => name === 'x-total-count',
// Inject the detected locale into the HTML lang attribute.
// %hackpile.lang% is the placeholder in src/app.html.
transformPageChunk: ({ html }) => html.replace('%hackpile.lang%', event.locals.locale ?? 'en')
})
return response
} The transformPageChunk callback fires once per rendered HTML document. Screen readers and search engines use the lang attribute to select the correct pronunciation engine and regional ranking signals.
Step 6 - The route matcher
This step is critical and must not be skipped. Without it, the (locale)/[lang] route group matches every URL that starts with a single path segment - including /svelte/, /sveltekit/, /learn/ and every other existing route prefix in your blog. The result is a cascade of 404 errors like "Language 'svelte' is not supported" and "Language 'sveltekit' is not supported" for all your English content.
The fix is a SvelteKit param matcher. Create src/params/locale.ts:
// src/params/locale.ts
import type { ParamMatcher } from '@sveltejs/kit'
// Must stay in sync with SUPPORTED_LOCALES in src/lib/config.ts.
// Cannot import from config.ts here - param matchers run before
// the SvelteKit module resolution layer and cannot use $lib aliases.
const SUPPORTED_LOCALES = ['pt', 'es'] as const
export const match: ParamMatcher = (param) => {
return (SUPPORTED_LOCALES as readonly string[]).includes(param)
} Two things matter here. First, this file cannot use $lib path aliases - param matchers are evaluated by SvelteKit’s routing layer before module resolution is available, so the import must be a plain static array literal. Second, you must keep this array in sync with SUPPORTED_LOCALES in config.ts manually. When you add 'de' to the config, also add 'de' here.
The directory name for the route group must use [lang=locale], not [lang]. The =locale suffix tells SvelteKit to run src/params/locale.ts as the validator for that segment. Only 'pt' and 'es' will ever match - /svelte/..., /sveltekit/..., and every other existing route fall through to the standard [...slug] handler as before.
Step 7 - The route architecture
SvelteKit’s route group syntax (name) creates a grouping directory that does not appear in URLs. Combined with the matcher from step 6, this structure safely co-exists with all your existing routes:
src/routes/
+layout.svelte ← root layout, all routes
+layout.ts ← root layout data loader
+page.svelte ← English homepage
+page.ts ← English homepage data
[...slug]/ ← English articles, URLs unchanged
+page.ts
+page.svelte
tracks/ ← English track/topic pages, unchanged
...
(locale)/ ← route group, zero URL impact
[lang=locale]/ ← only matches 'pt', 'es' - not 'svelte', 'learn', etc.
+layout.server.ts ← validates lang param, exposes to children (MUST be server)
[...slug]/ ← translated article pages
+page.server.ts ← server-only load - API keys stay server-side
+page.svelte Note the [lang=locale] naming: the part before = is the parameter name (params.lang in load functions), and the part after = is the matcher filename (src/params/locale.ts).
The layout file must be +layout.server.ts, not +layout.ts. This is the single most important detail in this step and the source of a subtle bug if you get it wrong. The page route uses +page.server.ts, and in SvelteKit, await parent() inside a server load function only receives data from server layout loads - those in +layout.server.ts files.
A universal +layout.ts load function is invisible to server page loads: its return value is not passed through parent(). The result is that lang comes back as undefined, which causes a 400: Unsupported locale: undefined error on every translated route.
Create the layout server load for the (locale)/[lang=locale]/ group:
// src/routes/(locale)/[lang=locale]/+layout.server.ts
import { SUPPORTED_LOCALES } from '$lib/config'
import type { Locale } from '$lib/config'
import { error } from '@sveltejs/kit'
import type { LayoutServerLoad } from './$types'
export const load: LayoutServerLoad = ({ params }) => {
// The matcher already enforces this, but belt-and-suspenders prevents
// a crash if someone constructs a request that bypasses routing.
if (!SUPPORTED_LOCALES.includes(params.lang as Locale)) {
error(404, `Language '${params.lang}' is not supported`)
}
return {
lang: params.lang as Locale
}
} This server load runs before every child route in the group. Its return value flows into data for every child +page.server.ts via await parent(), so any translated page load can destructure const { lang } = await parent() and get the correct locale string.
Step 8 - hreflang in the root layout
Every page needs to advertise its language variants to search engines. The <link rel="alternate" hreflang> tags belong in +layout.svelte because they must appear on every page.
The canonical path derivation uses page.url.pathname from $app/state, the correct Svelte 5 source for reactive URL data in layout components. Do not rely on a url field passed through layout load data, that would require every layout load function to explicitly return the URL, which is redundant when $app/state already provides it reactively.
There is also a Svelte 5 subtlety here, multi-line derived logic requires $derived.by(), not $derived(). The $derived(expr) form only accepts a single expression. When you need a block of code with intermediate variables, use $derived.by(() => { ... }).
<!-- src/routes/+layout.svelte -->
<script lang="ts">
import { page } from '$app/state'
import * as config from '$lib/config'
import { SUPPORTED_LOCALES } from '$lib/config'
let { children } = $props()
// page.url.pathname is reactive via $app/state - no subscription needed.
// $derived.by() is required when the derivation needs multiple statements.
// $derived(() => { ... }) creates a derived that IS the function, not its result.
const canonicalPath = $derived.by(() => {
const parts = page.url.pathname.split('/').filter(Boolean)
const firstIsLocale = SUPPORTED_LOCALES.includes(parts[0] as config.Locale)
return firstIsLocale ? '/' + parts.slice(1).join('/') : page.url.pathname
})
</script>
<svelte:head>
<!-- hreflang - signals language variants to search engines -->
<link rel="alternate" hreflang="en" href="{config.url}{canonicalPath}" />
{#each SUPPORTED_LOCALES as locale (locale)}
<link
rel="alternate"
hreflang={config.LOCALE_REGIONS[locale]}
href="{config.url}/{locale}{canonicalPath}"
/>
{/each}
<!-- x-default points to the English canonical - the authoritative version -->
<link rel="alternate" hreflang="x-default" href="{config.url}{canonicalPath}" />
</svelte:head>
{@render children()} Note the (locale) key expression in {#each SUPPORTED_LOCALES as locale (locale)}. Svelte 5 requires keyed blocks for {#each} over arrays that could change, even const tuples. Without the key, Svelte cannot efficiently reconcile DOM updates when the locale list changes.
As you know, Svelte 5 layouts render child content via {@render children()} where children is destructured from $props().
Step 9 - The locale switcher component
The foundation needs a UI component that lets readers switch between languages. Create src/lib/components/layout/LocaleSwitcher.svelte:
<!-- src/lib/components/layout/LocaleSwitcher.svelte -->
<script lang="ts">
import { page } from '$app/state'
import { SUPPORTED_LOCALES, LOCALE_NAMES } from '$lib/config'
import type { Locale } from '$lib/config'
import { Globe } from 'lucide-svelte'
// page from $app/state is the Svelte 5 way to access reactive page data.
// It replaces the old $page store from $app/stores. No subscription needed.
const currentLocale = $derived.by((): Locale | null => {
const first = page.url.pathname.split('/').filter(Boolean)[0] as Locale
return SUPPORTED_LOCALES.includes(first) ? first : null
})
const basePath = $derived.by(() => {
const parts = page.url.pathname.split('/').filter(Boolean)
const firstIsLocale = SUPPORTED_LOCALES.includes(parts[0] as Locale)
const stripped = firstIsLocale ? '/' + parts.slice(1).join('/') : page.url.pathname
return stripped || '/'
})
function localeHref(locale: Locale | null): string {
if (!locale) return basePath
return `/${locale}${basePath}`
}
const currentLabel = $derived(currentLocale ? currentLocale.toUpperCase() : 'EN')
let open = $state(false)
</script>
<svelte:window
onkeydown={(e) => {
if (e.key === 'Escape') open = false
}}
/>
<div class="locale-switcher">
<button
class="locale-btn"
onclick={() => (open = !open)}
aria-haspopup="listbox"
aria-expanded={open}
aria-label="Switch language - current: {currentLabel}"
>
<Globe size={13} />
<span class="locale-label">{currentLabel}</span>
</button>
{#if open}
<div class="backdrop" role="presentation" onclick={() => (open = false)}></div>
<ul class="locale-menu" role="listbox" aria-label="Language options">
<li role="option" aria-selected={currentLocale === null}>
<a
href={localeHref(null)}
class:active={currentLocale === null}
onclick={() => (open = false)}
hreflang="en"
>
<span class="locale-code">EN</span>
<span class="locale-name">English</span>
</a>
</li>
{#each SUPPORTED_LOCALES as locale (locale)}
<li role="option" aria-selected={currentLocale === locale}>
<a
href={localeHref(locale)}
class:active={currentLocale === locale}
onclick={() => (open = false)}
hreflang={locale}
>
<span class="locale-code">{locale.toUpperCase()}</span>
<span class="locale-name">{LOCALE_NAMES[locale]}</span>
</a>
</li>
{/each}
</ul>
{/if}
</div> Wire <LocaleSwitcher /> into your site’s header component, placing it next to your search button and theme toggle controls.
What you have now
After this article, your project has:
SUPPORTED_LOCALESconfig driving all locale-aware logic- Extended
Articletype withlang,isFallback, andtranslationStatus - Locale detection on every request via
hooks.server.ts - Correct
langattribute on every HTML document viatransformPageChunk src/params/locale.tsroute matcher - prevents the locale group from hijacking existing routes- A
(locale)/[lang=locale]/route group that only activates for valid locale codes +layout.server.tsexposinglangto child server loads viaawait parent()- hreflang tags on every page using
page.url.pathnamefrom$app/state- reactive, no layout data dependency LocaleSwitcher.sveltecomponent wired into the header- Empty
src/translations/pt/andsrc/translations/es/directories ready for overlays
No English reader has experienced any change. No existing route has been modified.
Common mistakes in this phase
Using +layout.ts instead of +layout.server.ts
// AVOID : +layout.ts is a universal load function
// src/routes/(locale)/[lang=locale]/+layout.ts
import type { LayoutLoad } from './$types'
export const load: LayoutLoad = ({ params }) => {
return { lang: params.lang }
} // PREFERRED : +layout.server.ts is a server load function
// src/routes/(locale)/[lang=locale]/+layout.server.ts
import type { LayoutServerLoad } from './$types'
export const load: LayoutServerLoad = ({ params }) => {
return { lang: params.lang }
} The +page.server.ts child route calls await parent() to get lang. In SvelteKit, server page loads only receive data from server layout loads. A universal +layout.ts is invisible to +page.server.ts - await parent() returns {} and lang is undefined, causing 400: Unsupported locale: undefined on every translated route.
The rule is simple: if the child uses +page.server.ts, the layout that provides data to it must be +layout.server.ts.
Forgetting the route matcher
// AVOID : [lang] matches EVERY first segment
src/routes/(locale)/[lang]/ // PREFERRED : [lang=locale] only matches 'pt', 'es', etc.
src/routes/(locale)/[lang=locale]/ Without [lang=locale], visiting /svelte/template-syntax/if-block hits the locale layout and throws "Language 'svelte' is not supported". The matcher in src/params/locale.ts is what prevents this.
Importing $lib aliases in the param matcher
// AVOID : $lib is not available in src/params/
import { SUPPORTED_LOCALES } from '$lib/config' // PREFERRED : static literal, must stay in sync manually
const SUPPORTED_LOCALES = ['pt', 'es'] as const Using $derived() for multi-statement logic
<!-- AVOID : creates a derived that IS the function, not its result -->
const canonicalPath = $derived(() => {
const parts = page.url.pathname.split('/')
return parts.join('/')
}) <!-- PREFERRED : $derived.by() evaluates the block and stores the return value -->
const canonicalPath = $derived.by(() => {
const parts = page.url.pathname.split('/')
return parts.join('/')
}) Using $app/stores instead of $app/state
<!-- AVOID : $app/stores is the Svelte 4 / legacy pattern -->
<script>
import { page } from '$app/stores'
$: pathname = $page.url.pathname
</script> <!-- PREFERRED : $app/state is the Svelte 5 runes pattern -->
<script lang="ts">
import { page } from '$app/state'
const pathname = $derived(page.url.pathname)
</script> $app/state exports page as a reactive proxy object - you access its properties directly without the $ sigil prefix. The $app/stores pattern requires the Svelte 4 store subscription syntax ($page) which is not idiomatic in Svelte 5 runes components.
Putting English in SUPPORTED_LOCALES
// AVOID : creates /en/ routes that duplicate English canonical URLs
export const SUPPORTED_LOCALES = ['en', 'pt', 'es'] as const // PREFERRED : English has no route prefix
export const SUPPORTED_LOCALES = ['pt', 'es'] as const
export const DEFAULT_LOCALE = 'en' as const Key takeaways
SUPPORTED_LOCALESas aconsttuple makesLocalea union type that catches locale typos at compile timesrc/params/locale.tsis not optional - without it the[lang]segment matches all existing routes and breaks navigation[lang=locale]in the directory name binds the route to the matcher;params.langis still the parameter name in load functions- The layout must be
+layout.server.ts- a universal+layout.tsdoes not pass data to+page.server.tsviaawait parent();langwill beundefinedand every translated route will return400: Unsupported locale: undefined - The param matcher cannot use
$libaliases - it must contain a plain literal array kept in sync with config manually $derived.by(() => { ... })is required for multi-statement derivations;$derived(() => { ... })produces a function, not its resultpagefrom$app/stateis the Svelte 5 way to access reactive URL data - not the$pagestore from$app/storesnullrepresents English inlocals.locale- a meaningful value, not an absent one
Further reading
- SvelteKit route matching documentation - param matchers, the mechanism behind
[lang=locale] - SvelteKit route groups documentation - the mechanism behind
(locale)/ - SvelteKit hooks documentation - the full hooks API including
handleandtransformPageChunk - SvelteKit load function data flow - how
await parent()works and the server/universal boundary - Google hreflang documentation - the canonical reference for hreflang implementation
- Svelte 5 $derived rune -
$derivedvs$derived.by- when each is appropriate - Svelte 5 $app/state - reactive page, navigating, and updated state in Svelte 5
- MDN: lang attribute - screen reader and browser behaviour