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.readFileSync directly 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 nameValue
UPSTASH_REDIS_REST_URLThe alias you created in Vercel Settings (article 5)
UPSTASH_REDIS_REST_TOKENThe 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

  • tsx runs TypeScript scripts directly without a build step - install it with dotenv and use relative ../src/lib/ import paths in scripts (never $lib)
  • render.ts has no SvelteKit dependencies (marked and shiki are 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 using process.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; mirrors cache.ts behaviour exactly
  • Write both t: (full) and tm: (compact title map) keys on every cache write - the tm: key is used for efficient nav title overlays
  • The invalidation script deletes both t: and tm: keys for every locale - forgetting the tm: key leaves stale translated titles in the sidebar
  • Use $lib/server/posts inside the app runtime; reserve direct fs reads for standalone scripts and batch tooling
  • Segment.id is the stable segment key in extract, translate, and reassemble - use s.id, not s.key
  • dotenv must be configured explicitly in scripts to load .env.local - import 'dotenv/config' loads .env, not .env.local
  • Promise.allSettled is the correct primitive for parallel Redis deletes in the invalidation script - individual failures should not stop other locales from being cleared
  • fetch-depth: 2 in the GitHub Actions checkout step is required for git diff HEAD~1 to 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

Track complete
You've finished Multilingual SvelteKit.