About this series
This series covers everything you need to add multiple languages to a blog, documentation, or any other content-driven site served from Markdown files - from the first config change through to a production caching layer that keeps translation API costs well under $30 even for a 500-article catalog, and a fraction of that every year after.
The examples throughout use Portuguese and Spanish as concrete locales, but the architecture is identical for any language: German, Japanese, Korean, Chinese, or anything else your audience speaks. The series is written in the order you would implement it, and each article produces working code you commit to your repository.
Before stepping into the implementation, it is worth spending a moment on the broader landscape of internationalisation approaches, because the architecture you choose depends almost entirely on where your content lives.
Where content lives shapes everything
There are three common content models for developer blogs and documentation sites, and each leads to a fundamentally different i18n strategy.
Content in a CMS
Tools like Contentful, Sanity, or Prismic have built-in internationalisation. You create a locale-aware content type, authors write directly in each language inside the CMS interface, and the API returns the correct locale on request. Translation workflows, approval states, and publication gates are handled by the CMS itself.
This is the right choice when non-technical authors manage content in multiple languages and editorial workflow tooling is a hard requirement. The trade-off is that the CMS becomes a dependency for every page render, content is locked inside a proprietary system, and the development experience is dictated by the CMS API rather than your own data structures.
Content in a database
Articles stored in Postgres, MySQL, or a document store like MongoDB give you full control over the schema. You add a locale column, query by it, and return translated rows.
This model scales well to large content operations with dedicated translation teams and keeps everything in one queryable place. The cost is infrastructure complexity: you need a running database, a connection pool, and a query layer, and every page load hits the database unless you add a caching layer on top.
Content in Markdown files
Articles are .md files committed to the repository alongside the application code. There is no database, no API dependency, no CMS subscription. Content is version-controlled, diff-able, and deployable with a single git push.
This is the natural model for developer-authored blogs and technical documentation where authors are comfortable in a code editor and content evolves alongside the codebase. The trade-off is that CMS-style editorial tooling does not exist by default, but for a developer blog, you do not need it.
This series focuses on Markdown files
If your content lives in a CMS, the CMS handles internationalisation and this series is not the right starting point - consult your CMS documentation for locale configuration. If your content lives in a database, the approach is similar in spirit but the implementation differs - you would add a locale column and query by it rather than calling a translation API at request time. This series is specifically designed for the third model: content in Markdown files, which is the most common for developer-authored blogs and documentation.
Why this series focuses on Markdown
All six articles in this series assume your content lives in Markdown files inside src/posts/. This is a deliberate choice that unlocks a specific architecture: prose is extracted from the canonical Markdown file, sent to the Claude API for translation, and stored in Upstash Redis.
The original .md file is never modified. It stays the single source of truth for code blocks, structure, and metadata in every language. The examples use Portuguese (pt) and Spanish (es), but adding German (de), Japanese (ja), or Korean (ko) requires changing one line in the config and one line in the route matcher.
For Markdown-first projects, on-demand translation solves a problem that neither CMS nor database approaches face: a substantial portion of every article - often 40 to 60 percent - is code that must never be translated. The prose extractor described in article 4 strips every code block, file path, and inline code reference before the Claude API ever sees the content. Only the actual human-readable prose crosses the API boundary.
The translated result is stored in Upstash Redis. Complete translations are stored permanently and only cleared when the article source changes. Incomplete translations (where the model dropped some segments) get a 2-hour TTL so they auto-retry on the next visit without manual intervention. The first visitor to a locale and article combination pays the translation cost once; every subsequent visitor gets the cached result instantly.
What this series covers
Article 1 (this article) explains the overlay model: what it is, why it is better than full file duplication or runtime translation of the full source, and how to design the architecture so adding any new language later requires changing two lines of code.
Article 2 walks through every zero-risk change: locale config with proper TypeScript types, locale detection in hooks.server.ts, the HTML lang attribute, the route matcher (src/params/locale.ts) that prevents the locale group from hijacking existing routes, and the (locale)/[lang=locale]/ route group that adds translated routes without modifying a single existing English route.
Article 3 extends the content index singleton to expose getContent(locale) - the single function every translated route calls. Also covers +page.server.ts vs +page.ts and why @types/node is required, plus the complete +page.svelte component that renders translated articles.
Article 4 covers the prose extractor, the Claude API translation module, the marked v15 and Shiki rendering pipeline, Claude API key setup, and the full +page.server.ts implementation using translateBatch for chunked, retried translation.
Article 5 adds the Upstash Redis translation cache: step-by-step Vercel Marketplace setup, the cache module with conditional TTL (permanent for complete translations, 2 hours for incomplete ones), fire-and-forget HTML writes, and the full cost breakdown by catalog size.
Article 6 implements the development tooling: the /dev/translations preview route for testing translations before real traffic arrives, the pnpm translate CLI script for pre-translating individual articles, the deploy-time cache invalidation script, and the Batch API catalog translation pipeline for translating hundreds of articles overnight at 50 percent off.
Read all six in order for the complete picture, or jump to the article that covers the part you are ready to implement.
The file-per-language trap
The most natural way to add a second language to a markdown blog is the most expensive. You duplicate every article file, add a language prefix to the path, and translate the whole thing. One hundred English articles becomes two hundred files with Portuguese, and three hundred with Spanish translations.
The repository triples in size, and every time you fix a typo or update a code example in English, you have to find and fix the same thing in every translated copy. Within six months, your Portuguese readers are following outdated code that no longer works against the current version of the framework. This is the file-per-language trap, and nearly every i18n tutorial for static site generators leads you straight into it.
The trap persists because it makes the wrong thing feel obvious. When you think “translate this article”, the instinct is to copy the whole file and hand it to a translator. That works perfectly for a marketing site where the content is pure prose. It breaks badly for a technical blog where 40 to 60 percent of each article is code that must never be translated, stays identical in every language, and becomes a maintenance burden the moment it lives in multiple files.
What a technical article actually contains
Before designing a translation architecture, it helps to categorise what is actually inside a technical article.
Never translated - identical in every language:
- All code blocks (the actual implementation)
- Inline code references like
$state(),load(),+page.server.ts - File paths like
src/routes/(locale)/[lang=locale]/+layout.ts - CLI commands like
pnpm add @upstash/redis - Technical terms used as proper nouns: SvelteKit, Runes, ISR, SSR, hydration, Vite
- Frontmatter structural fields:
track,topic,tags,difficulty,position
Always translated - the actual prose:
- Section introductions and explanations
- The reasoning behind decisions
- Error message explanations
- Common mistakes descriptions
titleanddescriptionfrontmatter values- SEO metadata values
Translated with care - technical prose:
- Sentences that mix explanation with inline code references
- Comments inside code blocks (keep in English - developers worldwide expect English code comments)
- Analogies and metaphors that may not map cleanly across languages
In a typical 3,000-word SvelteKit article, roughly 1,200 words are prose, 300 are headings and structural text, and 1,500 are code. You need to translate 1,500 words to serve a Portuguese reader well. Duplicating the entire 3,000-word file means you are storing and maintaining 1,500 words of identical code in every language you add.
How translation actually works in this architecture
The canonical .md file never changes and never moves. It remains the single source of truth for the article in every language. What changes per locale is only the prose - the sentences and paragraphs a reader actually reads.
The primary translation path is on-demand via the Claude API:
first visitor to /pt/[your-article-slug]
→ prose extractor strips all code blocks from the .md
→ prose-only content sent to Claude API
→ pre-rendered HTML stored in Upstash Redis (TTL based on completeness - see article 5)
→ article rendered: translated prose + original code blocks
every subsequent visitor
→ Redis cache hit → serve instantly, zero API cost For a concrete example: when a reader visits /pt/sveltekit/routing/dynamic-routes for the first time, that flow runs in full. Every reader after them gets the cached result instantly.
The .md file is never duplicated. The code blocks never leave the server. The English source stays untouched in every locale.
For important articles - pillar content and series entry points - there is an optional static pre-translation path that runs before the first visitor arrives. A CLI script extracts prose from the .md, calls the Claude API in batch mode at 50 percent off the standard rate, and writes the result directly to Redis:
src/posts/
sveltekit/
routing/
dynamic-routes.md ← canonical English, never changes
src/translations/ ← optional static YAML overlays
pt/
sveltekit/
routing/
dynamic-routes.yaml ← pre-translated prose only, no code When a static overlay file exists, the server uses it directly with no API call. When it does not, the on-demand path handles it automatically.
Why this is better than every alternative
vs. full file duplication: Only prose crosses the API boundary and only prose is cached. When you update a code example in the canonical English file, every language automatically gets the update because the code never lived anywhere else.
vs. runtime translation of the full file: Sending a full markdown article to a translation API is wasteful and risky. Translation models occasionally correct variable names, translate string literals, or adjust formatting inside code blocks. The overlay model extracts prose before the API call, so the API never sees a single backtick.
vs. a translation management system: Tools like Crowdin or Lokalise are designed for UI strings - short labels, button text, error messages. They work poorly with long-form prose and have no concept of “this section is code, skip it”. They also add significant infrastructure and cost for a blog that needs to translate 100 articles once, not thousands of UI strings continuously.
vs. doing nothing: Serving English to a Brazilian developer who would have read the article in Portuguese is a real cost, even if it is invisible in your analytics. The overlay model makes translation cheap enough that not translating stops being a reasonable choice.
Designing for languages you have not added yet
The most important architectural constraint is that adding a third or fourth language later should not require touching any existing code. This is achievable if you make SUPPORTED_LOCALES the single source of truth for everything language-related.
Every piece of the system that varies by locale - route validation, hreflang generation, cache key namespacing, translation scripts - reads from that one config array. Adding Korean means adding 'ko' to SUPPORTED_LOCALES in config.ts and to the route matcher in src/params/locale.ts. The routes, the content index, the Redis cache, and the hreflang tags all update automatically.
The one exception is the Vite glob pattern for optional YAML overlay loading. Vite analyses glob strings statically at build time and cannot construct them from a runtime variable. Each language needs one explicitly written glob:
// src/lib/content/index.ts - add one line per new language
const allOverlays = {
pt: import.meta.glob('/src/translations/pt/**/*.yaml', { eager: true }),
es: import.meta.glob('/src/translations/es/**/*.yaml', { eager: true })
// ko: import.meta.glob('/src/translations/ko/**/*.yaml', { eager: true }),
} Two steps to add any language: add to SUPPORTED_LOCALES in config, add to the param matcher, uncomment the glob. Every other file picks it up automatically.
A note on the integration domain
This series creates articles with integration: [i18n] in frontmatter. That domain is not in the default list in config.ts - add it to keep the content index consistent and power the /integrations/i18n hub page. Open src/lib/config.ts and extend the INTEGRATION_DOMAINS constant or wherever your site records the valid integration slugs:
// src/lib/config.ts - add i18n to the integration domains
export const INTEGRATION_DOMAINS = [
'api',
'authentication',
'databases',
'email',
'ai',
'cms',
'animation',
'deployment',
'testing',
'payments',
'i18n' // ← add this
] as const If your content index validates against this list, the articles in this series will build cleanly.
The translation pipeline options
Once the architecture is in place, there are three ways translations actually get produced:
On-demand at first request: A reader visits a translated URL for the first time. The server detects the locale, checks Redis, finds nothing, calls the Claude API with only the prose segments, stores the result, and serves the page. Every subsequent visitor gets the cached version. This handles the long tail of articles that are occasionally visited but not worth pre-translating manually.
CLI pre-translation: For pillar articles and series entry points, you run pnpm translate --slug <slug> --lang pt to translate before the first visitor arrives. The script writes directly to Redis.
Batch API catalog translation: For the initial launch of a new language, pnpm translate:catalog --lang pt submits all articles as a single batch job processed within 24 hours at 50 percent off the standard rate.
When static YAML sidecars make more sense
The on-demand Claude API approach works well for most developer blogs where articles are long, traffic per locale is unpredictable, and maintaining hundreds of translation files manually is impractical. Static YAML sidecar files committed to the repository are the better primary mechanism for:
Small, stable catalogs under roughly 30 articles: When your entire content set is small enough to pre-translate in a single batch run and changes infrequently, committing YAML sidecar files for every article eliminates the Upstash Redis dependency entirely.
Documentation sites: Technical documentation tends to be highly stable once written. The translation cost is paid once at documentation release time rather than on demand. Tools like Docusaurus and VitePress take this approach natively.
Teams with dedicated translators: If a human translator produces the translations rather than a machine API, they need a file to work in. A YAML sidecar is a natural deliverable.
The hybrid: For a growing blog, the practical choice is static YAML sidecars for the 10 to 15 highest-traffic entry points (pillar articles, series openers), and on-demand Claude API translation cached in Upstash Redis for everything else. The first visitor to any untranslated article pays a 3 to 6 second wait once; every subsequent visitor pays nothing.
What this series builds
This series implements the hybrid approach - on-demand Claude API translation cached in Upstash Redis as the primary mechanism, with optional static YAML sidecars for pillar articles that deserve zero cold-start latency.
Full file duplication fails: 100 articles in 3 languages becomes 300 files to keep in sync. A translation management system is designed for UI strings, not long-form technical prose with heavy code content. A pure static YAML approach for every article requires pre-translating all content upfront - impractical for a blog that publishes regularly.
The hybrid matches the actual shape of the problem. The long tail of articles, which may receive occasional visits from Portuguese or German readers, are handled automatically on first request and cached permanently in Upstash Redis. Adding a third locale later requires two lines of code.
Cost and scalability
The initial batch translation is the largest single cost: roughly $4 for 100 articles and $22 for 500 articles using the Batch API at 50 percent off. After that, ongoing cost is determined entirely by how many new articles you publish - five new articles a month across two locales costs about $0.44 a month regardless of catalog size. Year two and beyond cost far less than year one. In both cases the translation API is the smallest line item in your infrastructure budget.
Key takeaways
- CMS, database, and Markdown are three fundamentally different content models - each requires a different i18n strategy
- The overlay model is specifically designed for Markdown-first projects where code blocks make full file duplication impractical
- Technical articles are 40 to 60 percent code that must never be translated - the prose extractor strips all code before it reaches the Claude API
SUPPORTED_LOCALESas the single source of truth means adding a language touches two lines of code - config and the route matcher- The route matcher (
src/params/locale.ts) is critical - without it the locale group hijacks existing routes like/svelte/and/sveltekit/ - Add
i18nto your integration domains inconfig.tsso the hub page at/integrations/i18nis powered correctly
What about Paraglide JS?
Paraglide JS is worth addressing directly because it is SvelteKit’s own official recommended i18n integration. It is an excellent library - but it solves a different problem than the one this series addresses.
Paraglide is a compiler-based i18n library for UI strings: navigation labels, button text, form labels, error messages. You define keyed messages in JSON files and call them in your components - the compiler generates typesafe message functions and tree-shakes unused ones, producing significantly smaller i18n bundles.
It is the wrong tool for article content for three concrete reasons. First, message keys do not map to prose - a 2,000-word article introduction is not a short label, and encoding it as a JSON message key is impractical. Second, all translations must exist at build time, so you cannot generate translations on demand from an API. Third, Paraglide’s tree-shaking optimisation does not apply to long-form prose that should not be in the bundle at all.
The correct relationship is additive: a fully internationalised SvelteKit blog would use both. Paraglide handles the UI shell - navigation, footer, buttons, metadata labels - and the Claude API approach handles the article content. They operate on completely separate data and never conflict. This series omits Paraglide for simplicity; adding it later for the UI layer does not require touching the article translation architecture.
Further reading
- Paraglide JS documentation - the official SvelteKit i18n recommendation for UI strings
- SvelteKit routing documentation - understanding route groups and param matchers
- Anthropic Claude API documentation - the API used for on-demand translation
- Upstash Redis on the Vercel Marketplace - the caching layer covered in article 5, free on Hobby plan
- Contentful localisation - the CMS approach referenced in this article
- Sanity i18n documentation - another CMS approach for comparison