- Why Standard i18n Approaches Fail for Technical Blogs Most i18n strategies duplicate or mistranslate. Learn the overlay model: prose-only translation for Markdown blogs at any scale.
- The i18n Foundation: Config, Types, and Route Architecture Build a multilingual SvelteKit blog foundation: locale config, TypeScript types, locale detection, route matcher, and the (locale)/[lang=locale] route group.
The content index as the translation boundary
The content index singleton is the cleanest place to implement translation awareness because it is already the single authority over every article in the site. Every route reads from it. The search index is built from it. Related articles, prerequisites, and series navigation all run through it.
Making the content index locale-aware means that every route that currently reads English content gets translated content simply by calling getContent(locale) instead of reading the global content singleton. The translated route code is nearly identical to the English route - the translation logic stays inside the index, invisible above and below it.
Two implementation approaches
There are two levels at which getContent(locale) can operate:
The metadata approach merges YAML overlay files for pre-translated articles (title, description, SEO values) and marks everything else as isFallback: true. This is the full overlay architecture described in article one.
The fallback approach marks all articles as isFallback: true for the locale and delegates all translation to the on-demand Claude API layer (more in article four), which translates prose at request time and caches the result in Upstash Redis.
The second approach is what you implement in this article. It is the correct starting point because it lets you deploy translated routes immediately (before any translations exist) and the on-demand layer in article four provides the actual translated prose. The YAML overlay system for pre-translated static content is an additive extension covered at the end of this article.
Step 1 - Adding getContent to the index
Open src/lib/content/index.ts and add getContent after the existing singleton code. The English content singleton remains completely unchanged - English routes continue to import and use it directly.
// src/lib/content/index.ts
// ... all existing code unchanged above this point ...
// ── Locale-aware index ───────────────────────────────────────────────────────
// getContent(locale) returns a ContentIndex shaped identically to the English
// singleton so all helpers - getPosition, relatedArticles, buildTracks - work
// unchanged on locale-specific article lists.
//
// In this implementation, all articles are marked isFallback: true for any
// non-English locale. The on-demand translation layer in article 4 provides
// the actual translated prose; this index supplies the correct structure for
// series navigation and related article resolution.
export function getContent(locale: string): ContentIndex {
const localeArticles = articles.map((a) => ({
...a,
lang: locale,
isFallback: true
}))
return Object.freeze({
articles: localeArticles,
tracks: buildTracks(localeArticles),
tags: buildTags(localeArticles),
integrations: buildIntegrations(localeArticles)
})
} The function is intentionally simple. It does not cache its result, the translated route calls it once per page load and the cost is negligible since it is just array mapping over the already-loaded article metadata. If profiling shows it to be a bottleneck at scale, a Map<string, ContentIndex> cache keyed by locale can be added without changing the public API.
Step 2 - Why +page.server.ts, not +page.ts
Article four adds the Claude API translation call to the translated article route. That call must happen server-side for two reasons:
API keys must not reach the browser. +page.ts is a universal load function - it runs on the server for the initial page render, but then runs again in the browser on client-side navigation. Any ANTHROPIC_API_KEY you access in +page.ts will be included in the client bundle, visible to anyone who opens DevTools.
Raw source resolution belongs on the server. The final production implementation does not call readFileSync directly from the route. Instead it uses a server-only readPost helper backed by import.meta.glob, so source lookup works the same in development and production builds. Either way, the browser cannot own this logic.
The translated article route is therefore +page.server.ts, not +page.ts. A server load function never runs in the browser - SvelteKit serialises its return value and sends it as JSON to the client. The component receives the serialised data but the load function itself never executes client-side.
Step 3 - The translated article route (skeleton)
Create the skeleton +page.server.ts load function for src/routes/(locale)/[lang=locale]/[...slug]/. Article four replaces the translatedMarkdown: null stub with the actual translation logic.
// src/routes/(locale)/[lang=locale]/[...slug]/+page.server.ts
import type { Config } from '@sveltejs/adapter-vercel'
import { error } from '@sveltejs/kit'
import type { PageServerLoad } from './$types'
import { getContent, content } from '$lib/content'
import { getPosition, relatedArticles } from '$lib/content/helpers'
import type { Article } from '$lib/content/types'
import { SUPPORTED_LOCALES } from '$lib/config'
import type { Locale } from '$lib/config'
// ISR with on-demand expiration - CDN lifetime is set dynamically in
// article 5 via setHeaders() based on translation completeness.
export const config: Config = {
isr: { expiration: false }
}
export const load: PageServerLoad = async ({ params, parent }) => {
const { lang } = await parent()
const slugPath = params.slug
if (!SUPPORTED_LOCALES.includes(lang as Locale)) {
error(400, `Unsupported locale: ${lang}`)
}
const englishArticle = content.articles.find((a) => a.slug === slugPath)
if (!englishArticle) error(404, `Could not find ${slugPath}`)
const localeContent = getContent(lang)
const currentPost: Article = { ...englishArticle, lang, isFallback: true }
const translatedMarkdown: string | null = null
const { prev, next } = getPosition({ ...currentPost, slug: slugPath }, localeContent.articles)
const prerequisites: Article[] = (currentPost.prerequisites ?? [])
.map((id) => content.articles.find((a) => a.id === id))
.filter((a): a is Article => a !== undefined)
let relatedPosts: Article[] = []
let isSeries = false
if (currentPost.track?.id && currentPost.topic?.id) {
isSeries = true
const trackData = localeContent.tracks[currentPost.track.id]
relatedPosts = trackData
? trackData.topics
.slice()
.sort((a, b) => a.order - b.order)
.flatMap((t) => t.articles)
: []
} else {
const curated = (currentPost.related ?? [])
.map((id) => content.articles.find((a) => a.id === id))
.filter((a): a is Article => a !== undefined)
relatedPosts = curated.length > 0 ? curated : relatedArticles(currentPost, content.articles)
}
return {
meta: currentPost,
translatedMarkdown,
slugPath,
prev,
next,
prerequisites,
relatedPosts,
isSeries,
currentTopicId: currentPost.topic?.id ?? null,
lang,
isFallback: true
}
} Step 4 - The translated article page component
This is the phase-one +page.svelte for the translated route. It renders the article structure, the fallback notice, and series navigation. Since translatedMarkdown is always null at this stage, it links readers to the English version rather than rendering translated prose.
Article four replaces the entire load function and this entire component - the load function will return translatedHtml (a server-rendered HTML string from marked and Shiki), and the component will inject it via {@html data.translatedHtml} with no client-side rendering step.
The most important design decision here concerns the prev/next navigation links. The existing SeriesNav component constructs URLs as /{article.slug} - valid for English routes. Translated routes need /{lang}/{article.slug}. Rather than modifying SeriesNav to accept a locale prefix, the translated page component constructs those hrefs inline. This keeps the existing English components unchanged and makes the URL construction explicit and auditable in one place.
<!-- src/routes/(locale)/[lang=locale]/[...slug]/+page.svelte -->
<script lang="ts">
import type { PageData } from './$types'
import ArticleMeta from '$lib/components/article/ArticleMeta.svelte'
import ProgressBar from '$lib/components/article/ProgressBar.svelte'
let { data }: { data: PageData } = $props()
</script>
<svelte:head>
<title>{data.meta.seo?.title ?? data.meta.title}</title>
<meta name="description" content={data.meta.seo?.description ?? data.meta.description} />
<meta property="og:title" content={data.meta.seo?.title ?? data.meta.title} />
<meta property="og:description" content={data.meta.seo?.description ?? data.meta.description} />
<meta property="og:type" content="article" />
</svelte:head>
<article class="article-page">
{#if data.isFallback}
<div class="fallback-notice" role="status" aria-live="polite">
<span aria-hidden="true">🌐</span>
<p>
This article is not yet translated into
<strong>{data.lang.toUpperCase()}</strong>. You are reading the English original.
</p>
</div>
{/if}
<ArticleMeta post={data.meta} />
{#if data.meta.position && (data.meta.position.total ?? 1) > 1}
<ProgressBar
index={data.meta.position.index ?? data.meta.article?.order ?? 1}
total={data.meta.position.total ?? 1}
trackType={data.meta.track?.type ?? 'reference'}
/>
{/if}
{#if data.prerequisites.length > 0}
<aside class="prerequisites" aria-label="Prerequisites">
<h2>Before you read this</h2>
<ul>
{#each data.prerequisites as prereq (prereq.id ?? prereq.slug)}
<li>
<a href="/{data.lang}/{prereq.slug}">{prereq.title}</a>
</li>
{/each}
</ul>
</aside>
{/if}
{#if data.translatedMarkdown}
<!--
This block will never render at this stage - translatedMarkdown is always null.
Article 4 replaces this entire load function and component. The new load
function calls the Claude API server-side, renders HTML with marked + Shiki,
and returns translatedHtml (a string). The updated component then uses
{#await data.translatedHtml} - no client-side render function, no $derived.
-->
<div class="prose">Translated content will render here after article 4.</div>
{:else}
<div class="fallback-content">
<p>
Translation not yet available.
<a href="/{data.slugPath}">Read the English version →</a>
</p>
</div>
{/if}
<!-- Series navigation - locale-prefixed hrefs constructed inline.
The existing SeriesNav component builds /slug hrefs for English.
Translated routes need /{lang}/slug - constructed here explicitly. -->
{#if data.prev || data.next}
<nav class="series-nav" aria-label="Series navigation">
{#if data.prev}
<a href="/{data.lang}/{data.prev.slug}" class="series-nav-link series-nav-prev" rel="prev">
<span class="nav-label">← Previous</span>
<span class="nav-title">{data.prev.title}</span>
</a>
{/if}
{#if data.next}
<a href="/{data.lang}/{data.next.slug}" class="series-nav-link series-nav-next" rel="next">
<span class="nav-label">Next →</span>
<span class="nav-title">{data.next.title}</span>
</a>
{/if}
</nav>
{/if}
{#if data.relatedPosts.length > 0}
<aside class="related-posts" aria-label="Related articles">
<h2>Related articles</h2>
<ul>
{#each data.relatedPosts.slice(0, 4) as post (post.id ?? post.slug)}
<li>
<a href="/{data.lang}/{post.slug}">{post.title}</a>
</li>
{/each}
</ul>
</aside>
{/if}
</article> What the translated route resolves at this stage
At the end of this article, when a reader visits any translated URL - for example /pt/sveltekit/routing/dynamic-routes or /es/svelte/runes/state - they receive:
- A valid 200 response (not a 404 or 500)
- The article header with English title and description via
ArticleMeta - A progress bar showing the article’s position in the series
- A fallback notice explaining the article is not yet translated
- A link to the English version
- Correct series navigation (
prev/next) within that locale, linking to locale-prefixed URLs eg./pt/ - Correct
langattribute on the HTML document matching the requested locale eg.lang="pt"
The pattern /[locale]/[article-slug] is what activates this route group for any article in your catalog. This is a meaningful milestone: the route infrastructure is complete and working end-to-end before any real translations exist. Article four replaces the English fallback with actual translated prose.
Optional: YAML overlay system for pre-translated articles
The on-demand Claude API translation in article four works for the entire catalog automatically. For pillar articles and high-traffic entry points, you may want to provide a static pre-translation that eliminates cold-start latency entirely. Static YAML overlay files serve this purpose - they are committed to the repository and loaded at build time through Vite’s import.meta.glob.
Installing vite-plugin-yaml
YAML files are not handled by Vite natively. The vite-plugin-yaml package adds YAML import support:
pnpm add -D vite-plugin-yaml Register it in vite.config.ts:
// vite.config.ts
import { sveltekit } from '@sveltejs/kit/vite'
import yaml from 'vite-plugin-yaml'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [sveltekit(), yaml()]
}) The overlay file format
A YAML overlay file lives alongside the English article in a mirror directory structure under src/translations/:
# src/translations/pt/sveltekit/routing/dynamic-routes.yaml
lang: pt
translationStatus: complete
translatedBy: claude-batch-api
title: 'Rotas Dinâmicas no SvelteKit'
description: 'Aprenda como as rotas dinâmicas funcionam no SvelteKit com parâmetros de URL, matchers e layouts aninhados.'
sections:
prose-0: |
Após o artigo três, as rotas traduzidas existem e o índice de conteúdo...
prose-1: |
A função de carregamento universal... The sections keys match the id values from the prose extractor (extract.ts from article four). Any section without a matching id in the overlay falls back to the English prose automatically - partial translations are supported by design.
Loading overlays in the content index
Add the glob imports to src/lib/content/index.ts. Each language requires its own explicit glob - Vite analyses these statically at build time and cannot construct them programmatically:
// src/lib/content/index.ts
// Add after the existing article glob
// Also add this import at the top of the file:
// import type { TranslationOverlay } from '$lib/i18n/types'
// YAML overlays for pre-translated content.
// One explicit glob per language - Vite cannot build these from a variable.
// To add a language: add a glob here and add the locale to SUPPORTED_LOCALES.
const yamlOverlays: Record<string, Record<string, unknown>> = {
pt: import.meta.glob('/src/translations/pt/**/*.yaml', { eager: true }),
es: import.meta.glob('/src/translations/es/**/*.yaml', { eager: true })
// de: import.meta.glob('/src/translations/de/**/*.yaml', { eager: true }),
}
function getOverlay(locale: string, slug: string): TranslationOverlay | null {
const localeOverlays = yamlOverlays[locale]
if (!localeOverlays) return null
// Convert slug path to overlay file path
const overlayPath = `/src/translations/${locale}/${slug}.yaml`
const raw = localeOverlays[overlayPath]
if (!raw) return null
return raw as TranslationOverlay
} Update getContent to apply overlays when they exist:
// src/lib/content/index.ts
export function getContent(locale: string): ContentIndex {
const localeArticles = articles.map((a) => {
const overlay = getOverlay(locale, a.slug)
if (!overlay) {
// No overlay - mark as fallback, on-demand translation handles it
return { ...a, lang: locale, isFallback: true }
}
// Overlay exists - use translated frontmatter values
return {
...a,
lang: locale,
isFallback: false,
title: overlay.title ?? a.title,
description: overlay.description ?? a.description,
seo: overlay.seo ? { ...a.seo, ...overlay.seo } : a.seo,
translationStatus: overlay.translationStatus ?? 'complete'
}
})
return Object.freeze({
articles: localeArticles,
tracks: buildTracks(localeArticles),
tags: buildTags(localeArticles),
integrations: buildIntegrations(localeArticles)
})
} The load function in article four uses overlay sections for articles that have them and falls through to the Claude API for those that do not. The reader experience is identical regardless of which path serves the translation.
Key takeaways
getContent(locale)returns aContentIndexidentical in shape to the English singleton - all helpers work unchanged+page.server.tsis required for any route that calls translation APIs or resolves raw article source on the server - universal load functions run in the browser and have neither- All articles start as
isFallback: truefor any locale - the route works end-to-end before any translations exist - Translated prev/next links must be constructed as
/{lang}/{article.slug}- the existingSeriesNavcomponent builds English-only hrefs vite-plugin-yamlenables YAML overlay loading; each language requires its own explicitimport.meta.globcall - Vite cannot build these from a runtime variable- YAML overlays are optional and additive - they provide zero-latency translations for pillar articles while the on-demand layer handles the rest
Further reading
- Vite glob import documentation - the static analysis constraints and eager loading behaviour
- SvelteKit server-only load functions - why
+page.server.tsis required for API keys and Node.js APIs - vite-plugin-yaml - the package that enables YAML imports in Vite