- 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.
- Making the Content Index Locale-Aware Extend the SvelteKit content index with getContent(locale) - marks articles as isFallback when no translation exists, plus +page.svelte for translated routes.
- On-Demand Article Translation with the Claude API Translate SvelteKit markdown articles on first request using the Claude API: API key setup, prose extraction, and server-side rendering.
- Caching Translations with Upstash Redis Cache SvelteKit translations in Upstash Redis: pre-rendered HTML, permanent TTL for complete, 2-hour retry for incomplete, and instant streaming.
The gap between working and production-ready
After article five, the translation system works end-to-end. When a reader requests any translated URL, for example /pt/sveltekit/routing/dynamic-routes or /es/svelte/runes/state, the server checks Redis, calls the Claude API on a cache miss, stores the result, and serves the page. Every subsequent visitor gets the cached translation instantly.
Three problems remain
First, there is no way to test how a translation looks before real readers encounter it. The only way to trigger a translation is to visit a translated URL, which means the first test happens in production or at least with real API credentials. This is a painful feedback loop when you are tuning the translation prompt or testing a new locale.
Second, pre-translating pillar articles such as the series entry points and homepage-linked content that should never have a cold-start delay, requires visiting each translated URL manually and waiting for the on-demand path. There is no CLI equivalent.
Third, even though article five now stores complete translations permanently and incomplete ones for 2 hours, you still need an operational path to invalidate stale entries when article source content changes.
This article solves all three. You will build a /dev/translations preview route that triggers translation locally and shows output in a split-view interface, a pnpm translate CLI script for pre-translating individual articles directly to Redis, a pnpm translate:catalog pipeline that submits your entire catalog to the Anthropic Batch API at 50 percent off, and a scripts/invalidate-translations.ts script wired into a GitHub Actions workflow that invalidates Redis entries for any .md files changed in a deploy.
Install the dev dependencies
The CLI scripts run outside SvelteKit’s Vite environment, so they need two additional packages: tsx to execute TypeScript files directly from the command line without a build step, and dotenv to load .env.local into process.env before the Anthropic and Redis clients read it.
pnpm add -D tsx dotenv tsx handles TypeScript compilation in-process - you run pnpm tsx scripts/translate-article.ts and it works. dotenv loads the key-value pairs from .env.local into process.env at the start of each script, which is the only reason the scripts can read ANTHROPIC_API_KEY and UPSTASH_REDIS_REST_TOKEN without any SvelteKit environment machinery.
Add four script entries to package.json. The translate and translate:catalog commands are the user-facing CLIs. The translate:invalidate script is called by GitHub Actions rather than by hand. The dev:translations alias is a convenience - it is not strictly necessary since the preview route is just a browser URL, but it reminds you it exists.
{
"scripts": {
"translate": "tsx scripts/translate-article.ts",
"translate:catalog": "tsx scripts/translate-catalog.ts",
"translate:invalidate": "tsx scripts/invalidate-translations.ts",
"dev:translations": "echo 'Visit http://localhost:5173/dev/translations'"
}
} Step 1 - App routes vs CLI scripts: two different source loaders
Article four defined $lib/server/posts.ts with an import.meta.glob-backed readPost() function for reading raw Markdown inside SvelteKit app routes. That helper works correctly in development and in production builds on Vercel.
CLI scripts are a different environment entirely. They run outside SvelteKit’s Vite pipeline, so import.meta.glob is unavailable and $lib aliases do not resolve. The server-only module you defined in article four cannot be imported in scripts/. The distinction is important:
- App routes and server actions - always use
$lib/server/posts(readPost) - Standalone CLI scripts - use
fs.readFileSyncdirectly from disk
For the scripts in this article, direct fs access is acceptable. The scripts run on your local machine or in a GitHub Actions runner, not inside the Vercel serverless runtime, so there are no module-resolution constraints.
Step 2 - The /dev/translations preview route
The dev preview route serves one purpose: let you trigger a translation in your local environment, see the output immediately, and iterate on the prompt without deploying to production or visiting translated URLs in a browser.
In the final project, this concept evolved into the authenticated /admin/translations tooling. The pattern below is still useful as a dev-only route pattern, but the exact UI in this repository later moved under admin so it could reuse the same translation pipeline and cache-writing controls as the production tooling.
The route lives at src/routes/dev/translations/ and is protected by a hard check against the dev flag from $app/environment. In production, any request returns a 403 immediately. In development, it shows a form for selecting an article and locale, and a form action that runs the translation and returns the result in the page data.
Create the server load and form action:
// src/routes/dev/translations/+page.server.ts
import { dev } from '$app/environment'
import { error, fail } from '@sveltejs/kit'
import type { Actions, PageServerLoad } from './$types'
import { content } from '$lib/content'
import { SUPPORTED_LOCALES, LOCALE_NAMES } from '$lib/config'
import type { Locale } from '$lib/config'
import { readPost } from '$lib/server/posts'
import { extractSegments, proseOnly, reassemble } from '$lib/i18n/extract'
import { translateBatch } from '$lib/i18n/translate'
export const load: PageServerLoad = () => {
// Hard gate - this route must never be accessible in production.
// The `dev` flag is inlined at build time by Vite; in production builds it
// is `false` and the error() call is eliminated by dead-code elimination.
if (!dev) error(403, 'This route is only available in development')
return {
articles: content.articles.map((a) => ({ slug: a.slug, title: a.title })),
locales: SUPPORTED_LOCALES,
localeNames: LOCALE_NAMES
}
}
export const actions: Actions = {
translate: async ({ request }) => {
if (!dev) error(403, 'Dev-only')
const formData = await request.formData()
const slug = formData.get('slug') as string
const lang = formData.get('lang') as Locale
if (!slug || !lang) return fail(400, { error: 'slug and lang are required' })
if (!SUPPORTED_LOCALES.includes(lang)) return fail(400, { error: `Unknown locale: ${lang}` })
let rawMarkdown: string
try {
rawMarkdown = await readPost(slug)
} catch {
return fail(404, { error: `Article not found: ${slug}` })
}
const start = Date.now()
const segments = extractSegments(rawMarkdown)
const prose = proseOnly(segments)
// translateBatch handles prose + title + description in one call chain,
// identical to the production translation path in the load function.
const article = content.articles.find((a) => a.slug === slug)
const { proseTranslations, title, description } = await translateBatch(
prose,
article?.title ?? slug,
article?.description ?? '',
lang
)
const translatedMarkdown = reassemble(segments, proseTranslations)
const elapsed = Date.now() - start
// Estimate token cost: ~4 chars per token, $3 input / $15 output per million
const inputChars = prose.reduce((n, s) => n + s.content.length, 0)
const outputChars = Object.values(proseTranslations).reduce((n, s) => n + s.length, 0)
const costEstimate = (
(inputChars / 4 / 1_000_000) * 3 +
(outputChars / 4 / 1_000_000) * 15
).toFixed(4)
return {
slug,
lang,
title,
description,
translatedMarkdown,
proseSegmentCount: prose.length,
elapsed,
costEstimate
}
}
} The server-side form action does the full translation in one round-trip. The component receives the result in the form prop after submission and renders both the metadata and the translated prose. There is no streaming here - the form waits for the full translation to complete before the page re-renders. For a 3,000-word article this is 3 to 6 seconds, which is fine for a dev tool.
Create the page component:
<!-- src/routes/dev/translations/+page.svelte -->
<script lang="ts">
import type { PageData, ActionData } from './$types'
import { enhance } from '$app/forms'
let { data, form }: { data: PageData; form: ActionData } = $props()
let selectedSlug = $state(data.articles[0]?.slug ?? '')
let selectedLang = $state(data.locales[0])
let isTranslating = $state(false)
</script>
<svelte:head>
<title>Translation Preview - Dev Only</title>
</svelte:head>
<div class="dev-preview">
<header class="dev-header">
<h1>Translation Preview</h1>
<p class="dev-notice">⚠ Dev-only. Not accessible in production.</p>
</header>
<form
method="POST"
action="?/translate"
use:enhance={() => {
isTranslating = true
return async ({ update }) => {
await update()
isTranslating = false
}
}}
class="translation-form"
>
<label for="slug-select">Article</label>
<select id="slug-select" name="slug" bind:value={selectedSlug}>
{#each data.articles as article (article.slug)}
<option value={article.slug}>{article.title}</option>
{/each}
</select>
<label for="lang-select">Target locale</label>
<select id="lang-select" name="lang" bind:value={selectedLang}>
{#each data.locales as locale (locale)}
<option value={locale}>
{locale.toUpperCase()} - {data.localeNames[locale]}
</option>
{/each}
</select>
<button type="submit" disabled={isTranslating}>
{isTranslating ? 'Translating…' : 'Translate'}
</button>
</form>
{#if form && 'error' in form}
<div class="error" role="alert">{form.error}</div>
{/if}
{#if form && 'translatedMarkdown' in form}
<div class="result">
<div class="result-meta">
<dl>
<dt>Locale</dt>
<dd>{form.lang.toUpperCase()}</dd>
<dt>Translated title</dt>
<dd>{form.title}</dd>
<dt>Translated description</dt>
<dd>{form.description}</dd>
<dt>Prose segments</dt>
<dd>{form.proseSegmentCount}</dd>
<dt>API time</dt>
<dd>{(form.elapsed / 1000).toFixed(1)}s</dd>
<dt>Estimated cost</dt>
<dd>${form.costEstimate}</dd>
</dl>
</div>
<div class="translated-output">
<h2>Translated Markdown</h2>
<pre class="markdown-output">{form.translatedMarkdown}</pre>
</div>
</div>
{/if}
</div> The use:enhance action from $app/forms progressively enhances the form submission - it prevents the default full-page navigation, sends the request via fetch, and calls update() to merge the form action result back into the form prop reactively. The isTranslating flag is set to true before submission and cleared after, giving the button a loading state.
Note the bind:value={selectedSlug} and bind:value={selectedLang} on the select elements. In Svelte 5, bind: directives still work on form elements - this is not a store or state pattern, just standard two-way binding on an <select> which is idiomatic here.
Step 3 - The single-article CLI script
The pnpm translate script pre-translates one article and writes the result directly to Redis. Run it for pillar articles and series entry points before you go live with a new locale, so the first real visitor never waits for an on-demand translation.
Create scripts/translate-article.ts:
// scripts/translate-article.ts
// Pre-translate a single article and write the result to Redis.
// Usage: pnpm translate --slug sveltekit/loading-data/capstone-pokeapi --lang pt
// pnpm translate --slug sveltekit/loading-data/capstone-pokeapi --lang es --force
import 'dotenv/config'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { parseArgs } from 'node:util'
import Anthropic from '@anthropic-ai/sdk'
import { Redis } from '@upstash/redis'
// Import from src/ using relative paths - $lib aliases are not available outside SvelteKit.
// tsx resolves TypeScript directly, so no compilation step is needed.
import { extractSegments, proseOnly, reassemble } from '../src/lib/i18n/extract.js'
// render.ts has no SvelteKit deps - safe to import in scripts
import { renderMarkdownHtml } from '../src/lib/i18n/render.js'
// ── Argument parsing ─────────────────────────────────────────────────────────
const { values } = parseArgs({
options: {
slug: { type: 'string' },
lang: { type: 'string' },
force: { type: 'boolean', default: false }
},
strict: true
})
if (!values.slug || !values.lang) {
console.error('Usage: pnpm translate --slug <slug> --lang <lang> [--force]')
console.error('Example: pnpm translate --slug sveltekit/routing/dynamic-routes --lang pt')
process.exit(1)
}
const { slug, lang, force } = values
// ── Setup ───────────────────────────────────────────────────────────────────────
// Eager init is fine in scripts - unlike the app modules they are not
// imported by routes that never call translation.
const anthropic = new Anthropic()
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!
})
const cacheKey = `t:${lang}:${slug}`
const titleKey = `tm:${lang}:${slug}` // compact title-map key (see cache.ts)
// ── Check existing cache ─────────────────────────────────────────────────────
if (!force) {
const existing = await redis.get(cacheKey)
if (existing) {
console.log(`✓ Already cached: ${cacheKey}`)
console.log(' Use --force to overwrite.')
process.exit(0)
}
}
// ── Read source ──────────────────────────────────────────────────────────────
// Direct fs access is acceptable here because this is a standalone Node script,
// not request-time app code running inside the bundled server output.
let rawMarkdown: string
try {
rawMarkdown = readFileSync(resolve(`src/posts/${slug}.md`), 'utf-8')
} catch {
console.error(`✗ Article not found: src/posts/${slug}.md`)
process.exit(1)
}
// Read the article title and description from frontmatter for the translation.
// A simple regex extraction - no need for a full YAML parser for these two fields.
const titleMatch = rawMarkdown.match(/^title:\s*['"]?(.+?)['"]?\s*$/m)
const descMatch = rawMarkdown.match(/^description:\s*['"]?(.+?)['"]?\s*$/m)
const englishTitle = titleMatch?.[1] ?? slug
const englishDescription = descMatch?.[1] ?? ''
// ── Translate ────────────────────────────────────────────────────────────────
console.log(`Translating: ${slug} → ${lang}`)
const start = Date.now()
const segments = extractSegments(rawMarkdown)
const prose = proseOnly(segments)
console.log(` ${prose.length} prose segments to translate`)
// CLI scripts can't import translate.ts (it uses $env/dynamic/private).
// Replicate the same Anthropic call that translateBatch makes internally.
const payload = prose.map((s) => `[${s.id}]\n${s.content}`).join('\n\n---SEG---\n\n')
const res = await anthropic.messages.create({
model: 'claude-sonnet-4-6',
max_tokens: 8192,
messages: [
{
role: 'user',
content: `Translate to ${lang}. Preserve [id] markers, markdown, and code exactly.\nSEGMENTS:\n${payload}`
}
]
})
const rawResponse = res.content[0]?.type === 'text' ? res.content[0].text : ''
const proseTranslations: Record<string, string> = {}
for (const block of rawResponse.split(/\s*---SEG---\s*/)) {
const m = block.trim().match(/^\[([^\]]+)\]\s*\n([\s\S]+)/)
if (m) proseTranslations[m[1].trim()] = m[2].trim()
}
// Translate title + description
const metaRes = await anthropic.messages.create({
model: 'claude-sonnet-4-6',
max_tokens: 500,
messages: [
{
role: 'user',
content: `Translate to ${lang}. Return ONLY JSON {"title": ..., "description": ...}.\n{"title": ${JSON.stringify(englishTitle)}, "description": ${JSON.stringify(englishDescription)}}`
}
]
})
const metaRaw = metaRes.content[0]?.type === 'text' ? metaRes.content[0].text : '{}'
const { title, description } = (() => {
try {
const p = JSON.parse(metaRaw.replace(/```json|```/g, '').trim())
return {
title: typeof p.title === 'string' ? p.title : englishTitle,
description: typeof p.description === 'string' ? p.description : englishDescription
}
} catch {
return { title: englishTitle, description: englishDescription }
}
})()
// Render Shiki-highlighted HTML - this is what gets stored in Redis (see cache.ts)
const translatedMarkdown = reassemble(segments, proseTranslations)
const html = await renderMarkdownHtml(translatedMarkdown)
const missingCount = prose.filter((s) => !(s.id in proseTranslations)).length
const elapsed = ((Date.now() - start) / 1000).toFixed(1)
// ── Write to Redis ────────────────────────────────────────────────────────────────
// Complete translations (missingCount === 0) - stored permanently, no TTL.
// Incomplete translations (missingCount > 0) - 2-hour TTL so they auto-retry.
const TWO_HOURS = 60 * 60 * 2
const cacheEntry = { html, title, description, translatedAt: Date.now(), missingCount }
const titleEntry = { title, description }
if (missingCount > 0) {
await Promise.all([
redis.set(cacheKey, cacheEntry, { ex: TWO_HOURS }),
redis.set(titleKey, titleEntry, { ex: TWO_HOURS })
])
console.log(`⚠ Cached with 2h TTL (${missingCount} missing segments): ${cacheKey}`)
} else {
await Promise.all([redis.set(cacheKey, cacheEntry), redis.set(titleKey, titleEntry)])
console.log(`✓ Cached permanently: ${cacheKey}`)
}
console.log(` Title: ${title}`)
console.log(` Time: ${elapsed}s`)
// Rough cost estimate: 4 chars per token, $3 input / $15 output per M tokens
const inputTokens = prose.reduce((n, s) => n + s.content.length, 0) / 4
const outputTokens = Object.values(proseTranslations).reduce((n, s) => n + s.length, 0) / 4
const cost = ((inputTokens / 1_000_000) * 3 + (outputTokens / 1_000_000) * 15).toFixed(4)
console.log(` Estimated cost: $${cost}`) The --force flag is the key escape hatch. Without it, the script exits immediately when a valid cache entry exists - this prevents you from accidentally re-translating and paying for an API call you do not need. With --force, the cache entry is overwritten regardless, which is what you want when you have updated the article prose and need a fresh translation immediately rather than waiting for the GitHub Actions workflow to detect and invalidate the stale entry on the next deploy.
The frontmatter extraction uses a simple regex against the raw Markdown source rather than a full YAML parser. This is deliberate: the script only needs two fields, and importing a YAML parser adds a dependency for three lines of benefit. If frontmatter uses complex quoting or multiline strings, the regex may miss - in that case, fall back to passing an empty string, which the translation module handles by returning the input unchanged.
Step 4 - The Batch API catalog translation pipeline
The single-article script uses the standard Claude messages endpoint - one API call, synchronous, result immediate. For translating an entire catalog, the Anthropic Batch API is the right tool. It accepts up to 10,000 requests in a single batch, processes them asynchronously, and charges 50 percent less per token than the standard endpoint.
The workflow for a new locale launch is: run pnpm translate:catalog --lang pt, wait for the batch to complete (typically 30 to 90 minutes for a few hundred articles), watch the script import results to Redis automatically. After the import, every article in your catalog has a cached Portuguese translation before any reader arrives.
Create scripts/translate-catalog.ts:
// scripts/translate-catalog.ts
// Translate an entire article catalog using the Anthropic Batch API.
// Usage: pnpm translate:catalog --lang pt [--dry-run]
//
// Batch API: 50% cheaper than standard, async, up to 10,000 requests per batch.
// Results are typically ready within 30–90 minutes for 100–500 articles.
import 'dotenv/config'
import { readFileSync, readdirSync, statSync } from 'node:fs'
import { resolve, join, relative } from 'node:path'
import { parseArgs } from 'node:util'
import Anthropic from '@anthropic-ai/sdk'
import { Redis } from '@upstash/redis'
import { extractSegments, proseOnly, reassemble } from '../src/lib/i18n/extract.js'
import { renderMarkdownHtml } from '../src/lib/i18n/render.js'
const { values } = parseArgs({
options: {
lang: { type: 'string' },
'dry-run': { type: 'boolean', default: false }
},
strict: true
})
if (!values.lang) {
console.error('Usage: pnpm translate:catalog --lang <lang> [--dry-run]')
process.exit(1)
}
const lang = values.lang
const dryRun = values['dry-run']
const LANGUAGE_NAMES: Record<string, string> = {
pt: 'Brazilian Portuguese',
es: 'Latin American Spanish',
de: 'German',
fr: 'French',
ko: 'Korean',
ja: 'Japanese'
}
if (!LANGUAGE_NAMES[lang]) {
console.error(`Unknown language: ${lang}. Add it to LANGUAGE_NAMES in this script.`)
process.exit(1)
}
// ── Discover all .md files ───────────────────────────────────────────────────
function walkMdFiles(dir: string): string[] {
const results: string[] = []
for (const entry of readdirSync(dir)) {
const full = join(dir, entry)
if (statSync(full).isDirectory()) {
results.push(...walkMdFiles(full))
} else if (entry.endsWith('.md')) {
results.push(full)
}
}
return results
}
const postsDir = resolve('src/posts')
const allFiles = walkMdFiles(postsDir)
// Convert absolute paths to slugs: src/posts/sveltekit/routing/basics.md → sveltekit/routing/basics
const articles = allFiles.map((file) => ({
slug: relative(postsDir, file).replace(/\.md$/, ''),
raw: readFileSync(file, 'utf-8')
}))
console.log(`Found ${articles.length} articles`)
// ── Build batch requests ─────────────────────────────────────────────────────
// One request per article = one translated prose result per article.
// Frontmatter (title/description) is translated separately in the import step
// to keep each request focused and the response parsing simple.
function buildBatchPrompt(raw: string): string {
const segments = extractSegments(raw)
const prose = proseOnly(segments)
if (prose.length === 0) return ''
const payload = prose.map((s) => `[${s.id}]\n${s.content}`).join('\n\n---SEG---\n\n')
const language = LANGUAGE_NAMES[lang]
return `Translate to ${language}. Rules:
1. Never modify inline code (\`like this\`), fenced code blocks, file paths, or technical terms.
2. Never translate: SvelteKit, Svelte, Runes, TypeScript, Vite, Vercel, npm, pnpm, API, HTTP, JSON, YAML, Redis.
3. Preserve all markdown formatting: **, *, ##, ###, >, -.
4. Keep [key] markers on their own lines exactly as given.
5. Return ONLY translated segments in identical format.
${payload}`
}
const requests: Array<{
custom_id: string
params: { model: string; max_tokens: number; messages: Array<{ role: string; content: string }> }
}> = []
for (const article of articles) {
const prompt = buildBatchPrompt(article.raw)
if (!prompt) continue // skip articles with no prose segments (e.g. pure frontmatter stubs)
requests.push({
custom_id: article.slug,
params: {
model: 'claude-sonnet-4-6',
max_tokens: 8000,
messages: [{ role: 'user', content: prompt }]
}
})
}
console.log(`${requests.length} articles with translatable prose`)
if (dryRun) {
console.log('\n-- Dry run: no API calls made --')
console.log('First request custom_id:', requests[0]?.custom_id)
process.exit(0)
}
// ── Submit batch ─────────────────────────────────────────────────────────────
const anthropic = new Anthropic()
console.log('\nSubmitting batch to Anthropic…')
const batch = await anthropic.messages.batches.create({ requests })
console.log(`Batch ID: ${batch.id}`)
console.log(`Status: ${batch.processing_status}`)
// ── Poll until complete ──────────────────────────────────────────────────────
console.log('\nPolling for completion (checks every 30s)…')
let current = batch
while (current.processing_status === 'in_progress') {
await new Promise((r) => setTimeout(r, 30_000))
current = await anthropic.messages.batches.retrieve(batch.id)
const done = current.request_counts.succeeded + current.request_counts.errored
const total = done + current.request_counts.processing
process.stdout.write(`\r ${done}/${total} complete`)
}
console.log(
`\nBatch complete: ${current.request_counts.succeeded} succeeded, ${current.request_counts.errored} errored`
)
// ── Import results to Redis ──────────────────────────────────────────────────
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!
})
const TWO_HOURS = 60 * 60 * 2
function parseSegments(raw: string): Record<string, string> {
const result: Record<string, string> = {}
for (const block of raw.split(/\s*---SEG---\s*/)) {
const m = block.trim().match(/^\[([^\]]+)\]\s*\n([\s\S]+)/)
if (m) result[m[1].trim()] = m[2].trim()
}
return result
}
let written = 0
let skipped = 0
for await (const result of await anthropic.messages.batches.results(batch.id)) {
if (result.result.type !== 'succeeded') {
console.warn(`✗ Failed: ${result.custom_id}`)
skipped++
continue
}
const slug = result.custom_id
const responseText =
result.result.message.content[0].type === 'text' ? result.result.message.content[0].text : ''
const proseTranslations = parseSegments(responseText)
const article = articles.find((a) => a.slug === slug)
const titleMatch = article?.raw.match(/^title:\s*['"]?(.+?)['"]?\s*$/m)
const descMatch = article?.raw.match(/^description:\s*['"]?(.+?)['"]?\s*$/m)
const title = titleMatch?.[1] ?? ''
const description = descMatch?.[1] ?? ''
// Render Shiki-highlighted HTML - cache stores html, not prose segments
const segs = extractSegments(article?.raw ?? '')
const translatedMarkdown = reassemble(segs, proseTranslations)
const html = await renderMarkdownHtml(translatedMarkdown)
const proseSegs = proseOnly(segs)
const missingCount = proseSegs.filter((s) => !(s.id in proseTranslations)).length
const cacheEntry = { html, title, description, translatedAt: Date.now(), missingCount }
const titleEntry = { title, description }
// Conditional TTL: complete = permanent, incomplete = 2h auto-retry
if (missingCount > 0) {
await Promise.all([
redis.set(`t:${lang}:${slug}`, cacheEntry, { ex: TWO_HOURS }),
redis.set(`tm:${lang}:${slug}`, titleEntry, { ex: TWO_HOURS })
])
} else {
await Promise.all([
redis.set(`t:${lang}:${slug}`, cacheEntry),
redis.set(`tm:${lang}:${slug}`, titleEntry)
])
}
written++
process.stdout.write(`\r Written ${written} to Redis`)
}
console.log(`\n\n✓ Done. ${written} articles cached, ${skipped} skipped.`)
console.log(` Complete translations stored permanently. Incomplete ones expire in 2h.`) The Batch API polling loop uses processing_status === 'in_progress' as the continue condition. Batches transition through in_progress to ended - there is no intermediate state. Each 30-second poll is fine for a catalog job that takes 30 to 90 minutes; you do not need sub-second precision here.
The frontmatter translation is intentionally left as a two-step process in the catalog script. Adding a standard Claude API call for each article’s title and description inside the batch import loop would defeat the purpose - you would be making 500 individual synchronous API calls serially. The pragmatic approach is to import prose from the batch, then run pnpm translate --force on the handful of pillar articles that need perfect translated titles before launch.
Step 5 - Cache invalidation on deploy
The Redis cache has no automatic expiry for complete translations - they are stored permanently and only cleared when the article source changes. When you update an article’s prose, you want the translated cache entry replaced immediately.
The correct trigger for invalidation is a deploy that modifies .md files. GitHub Actions can detect which Markdown files changed between commits and call an invalidation script targeted at exactly those slugs.
Create the invalidation script first:
// scripts/invalidate-translations.ts
// Invalidate Redis translation cache entries for a list of article file paths.
// Called by GitHub Actions after deploy with changed .md file paths as arguments.
// Usage: pnpm tsx scripts/invalidate-translations.ts src/posts/foo/bar.md src/posts/baz/qux.md
import 'dotenv/config'
import { Redis } from '@upstash/redis'
// Import SUPPORTED_LOCALES using a relative path - $lib is not available here.
// The import path ends in .js because tsx resolves TypeScript modules using
// Node's ESM resolution, which requires explicit .js extensions even for .ts sources.
import { SUPPORTED_LOCALES } from '../src/lib/config.js'
const filePaths = process.argv.slice(2)
if (filePaths.length === 0) {
console.log('No files to invalidate.')
process.exit(0)
}
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!
})
for (const filePath of filePaths) {
// Normalise: src/posts/sveltekit/loading-data/capstone-pokeapi.md
// → sveltekit/loading-data/capstone-pokeapi
const slug = filePath.replace(/^src\/posts\//, '').replace(/\.md$/, '')
// Delete both the full entry (t:) and the compact title-map entry (tm:)
const keys = SUPPORTED_LOCALES.flatMap((locale) => [
`t:${locale}:${slug}`,
`tm:${locale}:${slug}`
])
await Promise.allSettled(keys.map((key) => redis.del(key)))
console.log(`Invalidated: ${slug}`)
keys.forEach((k) => console.log(` → ${k}`))
}
console.log('\nDone.') The Promise.allSettled - not Promise.all - is deliberate. If one locale’s Redis delete fails (network blip, key did not exist), the remaining locales still get invalidated. Promise.all would stop at the first rejection and leave other locales with stale entries.
Step 6 - The GitHub Actions workflow
The workflow runs on every push to main that touches any .md file under src/posts/. It detects the changed files using git diff between the current and previous commit, then calls the invalidation script with those file paths.
Create .github/workflows/invalidate-translations.yml:
# .github/workflows/invalidate-translations.yml
# Invalidates Redis translation cache entries for articles changed in a deploy.
# Only runs when .md files under src/posts/ are modified.
name: Invalidate Translation Cache
on:
push:
branches: [main]
paths:
- 'src/posts/**/*.md'
jobs:
invalidate:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# fetch-depth: 2 ensures the parent commit is available for git diff.
# Without it, git diff HEAD~1 fails on a shallow clone.
fetch-depth: 2
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Find changed articles
id: changed
run: |
# List .md files changed between the current and previous commit.
# The || true prevents the step from failing when no .md files changed
# (git diff exits non-zero when there are no matches in some versions).
CHANGED=$(git diff HEAD~1 --name-only -- 'src/posts/**/*.md' 2>/dev/null || true)
if [ -z "$CHANGED" ]; then
echo "No .md files changed."
echo "files=" >> $GITHUB_OUTPUT
else
echo "Changed files:"
echo "$CHANGED"
# Convert newlines to spaces for passing as shell arguments
FILES=$(echo "$CHANGED" | tr '\n' ' ')
echo "files=$FILES" >> $GITHUB_OUTPUT
fi
- name: Invalidate changed translations
if: steps.changed.outputs.files != ''
env:
UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL }}
UPSTASH_REDIS_REST_TOKEN: ${{ secrets.UPSTASH_REDIS_REST_TOKEN }}
run: |
pnpm tsx scripts/invalidate-translations.ts ${{ steps.changed.outputs.files }} The paths filter on the on.push trigger means this workflow only activates when .md files under src/posts/ change. A deploy that only modifies component code or config files does not trigger it. This keeps the workflow targeted and avoids unnecessary Redis operations.
The fetch-depth: 2 under the checkout action is a common footgun. GitHub Actions checks out repositories as shallow clones by default - only the single most recent commit. Without depth 2, git diff HEAD~1 fails because the parent commit does not exist in the clone. Depth 2 fetches just the two most recent commits, which is enough for the diff.
Make sure you have the following secrets configured in your GitHub repository under Settings → Secrets and variables → Actions:
| Secret name | Value |
|---|---|
UPSTASH_REDIS_REST_URL | The alias you created in Vercel Settings (article 5) |
UPSTASH_REDIS_REST_TOKEN | The alias you created in Vercel Settings (article 5) |
These are the same two canonical alias values from article five - the ones you added as alias variables in Vercel so the code never references the KV_-prefixed names Vercel generates.
When to use each mechanism
The series now provides four paths to get a translation into Redis. Choosing the right one depends on the situation.
pnpm translate --slug <slug> --lang <lang> is for individual articles where you want a translation in Redis before the first visitor arrives. Run it for pillar articles (pillar: true), series entry points (article.order === 1), and any article you link from the homepage. Also run it with --force when you have manually edited prose in an article and want the translation refreshed immediately without waiting for the GitHub Actions workflow.
pnpm translate:catalog --lang pt is for the initial launch of a new locale. It translates every article in one batch at 50 percent off, stores all results in Redis, and runs unattended. You start it, do something else for an hour, and come back to a fully translated catalog. Run it once per new language.
On-demand translation via the load function (article four) handles the long tail - articles that get occasional translated traffic but were not pre-translated. The Redis cache means the API is called once and the result stored permanently (or for 2h if incomplete). This is the zero-configuration path; you do not need to actively manage it.
GitHub Actions invalidation runs automatically on every deploy that modifies .md files. It removes the stale cache entry, so the next visitor triggers a fresh on-demand translation. This is the maintenance path - you never need to remember to invalidate manually.
The combination means the system self-heals: new articles are translated on first visit, updated articles get fresh translations after the next deploy, and the cost stays near zero once the initial catalog translation is done.
Common mistakes in this phase
Importing $lib aliases in CLI scripts
// Avoid - $lib is resolved by SvelteKit/Vite, not available in tsx scripts
import { extractSegments } from '$lib/i18n/extract' // Preferred - relative path from the script location to the source module
import { extractSegments } from '../src/lib/i18n/extract.js' The .js extension is required even when the source file is .ts. Node’s ESM module resolution requires extensions to be explicit. When you import ../src/lib/i18n/extract.js, tsx intercepts the resolution and compiles the .ts source transparently.
Forgetting dotenv in scripts
// Avoid - ANTHROPIC_API_KEY and UPSTASH_* are not in process.env
const anthropic = new Anthropic()
const redis = new Redis({ url: process.env.UPSTASH_REDIS_REST_URL! }) // Preferred - load .env.local first, before any client instantiation
import 'dotenv/config' // loads .env, not .env.local - see note below One subtlety: dotenv/config loads .env by default, not .env.local. The SvelteKit convention is to put secrets in .env.local (which is gitignored). For scripts, either configure dotenv explicitly or create a .env file with only the variables needed by scripts. The safest pattern:
import dotenv from 'dotenv'
dotenv.config({ path: '.env.local' }) Or use the Node 20 --env-file flag:
{
"scripts": {
"translate": "node --env-file=.env.local --import=tsx/esm scripts/translate-article.ts"
}
} Using Promise.all in the invalidation script
// Avoid - one deletion failure stops all remaining locales from being invalidated
await Promise.all(keys.map((key) => redis.del(key))) // Preferred - all locales are invalidated regardless of individual failures
await Promise.allSettled(keys.map((key) => redis.del(key))) Awaiting the cache write in the load function
// Avoid - blocks the page response until Redis write completes (adds 50-200ms)
await setCachedTranslation(slug, lang, translation)
return { meta, translatedHtml } // Preferred - fire-and-forget: response is sent while Redis write happens concurrently
void setCachedTranslation(slug, lang, translation)
return { meta, translatedHtml } On Vercel, serverless function execution continues after the response is sent until the event loop drains. The fire-and-forget write completes in the background without affecting response time.
The complete picture
After all six articles, your SvelteKit blog has a self-contained multilingual system:
The foundation (article two) adds locale detection, route matching, and the (locale)/[lang=locale]/ route group without touching any existing English route.
The content index (article three) exposes getContent(locale) so all series navigation, related article scoring, and track hierarchy works identically in every locale.
The on-demand translation (article four) uses the Claude API to translate prose segments at first request, with the prose extractor ensuring code blocks never cross the API boundary.
The Redis cache (article five) stores the pre-rendered Shiki HTML permanently (or 2 hours for incomplete translations) so every visit after the first is served at zero API cost.
The dev tooling (this article) closes the operational loop: the preview route lets you iterate on translation quality locally, the CLI scripts pre-populate Redis before traffic arrives, and the GitHub Actions workflow keeps the cache current as your content evolves.
Key takeaways
tsxruns TypeScript scripts directly without a build step - install it withdotenvand use relative../src/lib/import paths in scripts (never$lib)render.tshas no SvelteKit dependencies (markedandshikiare plain npm packages) - it is safe to import directly in CLI scripts via../src/lib/i18n/render.js- CLI scripts cannot import
translate.ts(it uses$env/dynamic/private) - replicate the Anthropic call directly usingprocess.env.ANTHROPIC_API_KEY - The cache stores pre-rendered HTML (
{ html, title, description, translatedAt, missingCount }) - not prose segment maps; always write this format from CLI scripts too - Conditional TTL in CLI scripts:
missingCount === 0→ no TTL (permanent),missingCount > 0→ 2-hour TTL; mirrorscache.tsbehaviour exactly - Write both
t:(full) andtm:(compact title map) keys on every cache write - thetm:key is used for efficient nav title overlays - The invalidation script deletes both
t:andtm:keys for every locale - forgetting thetm:key leaves stale translated titles in the sidebar - Use
$lib/server/postsinside the app runtime; reserve directfsreads for standalone scripts and batch tooling Segment.idis the stable segment key in extract, translate, and reassemble - uses.id, nots.keydotenvmust be configured explicitly in scripts to load.env.local-import 'dotenv/config'loads.env, not.env.localPromise.allSettledis the correct primitive for parallel Redis deletes in the invalidation script - individual failures should not stop other locales from being clearedfetch-depth: 2in the GitHub Actions checkout step is required forgit diff HEAD~1to work on shallow clones- The Batch API charges 50 percent less than the standard endpoint and handles up to 10,000 requests per batch - use it for initial catalog translation when adding a new locale
Further reading
- Anthropic Batch API documentation - the async batch endpoint, request and result format, and 50% pricing
- Anthropic SDK TypeScript - batches - the
messages.batches.create,retrieve, andresultsmethods - SvelteKit form actions - the
actionsexport anduse:enhancedirective - GitHub Actions - paths filter - restricting workflow triggers to specific file patterns
- tsx - TypeScript execute - the tool used to run TypeScript scripts without compilation
- dotenv documentation -
dotenv.config({ path })for loading.env.local - Upstash Redis - command reference -
del,set,get, andttlused throughout this series