Dynamic Translations
Introduction
Translations live in the translations database table - the source of truth (see Translations). Translating by hand through the /system/translations admin UI is the most reliable path: you see exactly what each language looks like. For projects with many languages or frequent content updates, the AI translation pass fills the gaps automatically, leaving you to review the result rather than type every string.
The automated path is sync:languages - it scans the database for keys present in one language but missing (or still untranslated) in another, translates them via an LLM, and writes the results back to the database. It is non-destructive: already-translated values are left untouched.
Synchronising Translation Keys
When a key exists for one language but not another - you added an English string, or just added a new language - sync:languages closes the gap. It scans the translations table across every namespace, finds keys missing from a language, AI-translates them, and writes them straight to the DB:
bun run sync:languages
This is bun generator/sync_translations.ts --translate under the hood. Because the database is authoritative, there are no JSON files to edit and no placeholder markers to find and replace - the missing rows are simply created with translated values. Run it whenever you've added strings or a language and want the other languages filled in.
To find or scaffold keys that templates reference but the DB doesn't have yet, use the Sync missing translations reeman tool, and to remove DB keys no longer referenced by any template, use Prune unused translations - both emit reviewable .sql files. See Translations → Maintenance.
AI Translation Pass
sync:languages is the standalone translation pass. The same --translate flag is also accepted by the resource generators, so a freshly generated module can have its labels translated in the same step:
bun generator/resource crud users --translate
The pass works across all languages the project supports (from config/supported_languages.ts). It reads the English rows from the database, sends them to the LLM, and writes the returned translations back into the translations table for each language. Only keys that are missing or still hold the English placeholder are sent - already-translated rows are left untouched.
Setup
Set a provider key in your .env. The shipped default is Google Gemini:
GEMINI_API_KEY=xxxxxxxxxxxxxx
GEMINI_MODEL=gemini-2.5-flash
gemini-2.5-flash is fast and cost-effective for bulk translation. Change GEMINI_MODEL to pick a different Gemini model, or switch to another backend entirely - see Choosing a Provider below.
Choosing a Provider
Gemini is one of four backends generator/ai-provider.ts can use. It resolves the provider in priority order:
- Ollama - if
OLLAMA_URLis set, a local model is used (setOLLAMA_MODELtoo). Takes priority over everything else, so a developer with Ollama running translates offline and for free. - Gemini - if
GEMINI_API_KEYis set. This is the shipped default; setGEMINI_MODELto pick the model. - Hugging Face Inference - if
HF_TOKENis set andOPENROUTER_KEYis not. Defaults toHelsinki-NLP/opus-mt; override withHF_URL/HF_MODEL. The language pair (e.g.en-sl) is derived automatically. - OpenRouter - the cloud fallback when no higher-priority provider is active. Set
OPENROUTER_MODELto pick the model (e.g.deepseek/deepseek-chat).
# Local-first (highest priority)
# OLLAMA_URL=http://localhost:11434
# OLLAMA_MODEL=gemma4:e4b
# Hugging Face, used only when OPENROUTER_KEY is unset
# HF_TOKEN=...
# HF_MODEL=Helsinki-NLP/opus-mt
# OpenRouter
# OPENROUTER_KEY=...
# OPENROUTER_MODEL=deepseek/deepseek-chat
# Hugging Face fallback
# HF_TOKEN=hf_xxxxxxxx
All three providers feed the same translation pipeline, so switching backends is purely an environment change - the commands don't differ.
What Gets Translated
The AI translates every key in the namespaces it processes, including:
- UI labels, headings, and button text
- Validation error messages
- Field labels and placeholders
- Action names and selectors
The one exception is route_name - it is never auto-translated. Localised URL segments are too sensitive to get wrong; they should always be reviewed by a human before deploying.
Typical Workflow
The flow for adding a batch of new strings across all languages:
# 1. Add the new keys to the DB (e.g. via the Sync missing translations reeman,
# which scans templates for refs and emits INSERT SQL), then apply the SQL.
# 2. Auto-translate everything still untranslated across all languages.
bun run sync:languages
sync:languages sends only the keys that are missing or still hold their English placeholder - anything already translated is untouched. After the LLM returns, review the result in the /system/translations admin UI.
Manual Review After Translation
AI-translated strings are usually correct for the common cases - button labels, field names, short messages. For domain-specific terms, brand names, or context-dependent phrasing, a human review pass is essential.
The /system/translations admin UI is the place to do it: browse a namespace, compare the languages side by side, and correct anything the model got wrong. Because edits write directly to the translations table, corrections take effect on the next request (the admin module reloads translations after each save) - no redeploy.
For terms the AI consistently gets wrong, the simplest fix is to translate those keys by hand once and leave them - sync:languages won't overwrite a key that already has a non-placeholder value.
When Not to Automate
AI translation is appropriate for:
- Bulk-translating a newly added language
- Filling in 50+ strings after adding a feature
- Keeping translations in sync during early development
AI translation is not appropriate for:
- Marketing copy, landing pages, or any text where tone and voice matter
- Legal disclaimers, terms of service, or anything with legal implications
route_namevalues (URL segments are never auto-translated)- Any string that, if translated wrong, would confuse or mislead users
For those cases, translate by hand through the admin UI - the AI pass skips keys that already have a translated value.