Translate Locally
Translate a React, Next.js or Django repo from your terminal, signed in to Babelize, with your own LLM key, or with a Babelize API key. No GitHub App or dashboard: the result is a git diff.
babelize translate runs the Babelize pipeline on your machine. It extracts hard-coded strings, generates keys, translates them, and rewrites your components to use them. The changes are written to your working tree, where you review them with git diff.
New here? Run npx babelize in your project folder instead: it asks for everything this page passes as flags (languages, sign-in) and remembers your answers.
Signed in to Babelize (npx babelize auth login), it translates with your plan's credits, no API key needed:
npx babelize translate --to fr,de,ja
git diffOr translate with your own LLM key (no Babelize account needed):
export ANTHROPIC_API_KEY=sk-ant-... # or OPENAI_API_KEY, GEMINI_API_KEY, GROQ_API_KEY, OPENROUTER_API_KEY
npx babelize translate --to fr,de,ja
git diffOr with Babelize-hosted translation: create an API key at app.babelize.co/api-keys (free tier included) and set BABELIZE_API_KEY:
export BABELIZE_API_KEY=bblz_sk_...
npx babelize translate --to fr,de,ja
git diffWhat's supported
| Framework | Status |
|---|---|
| React (Vite, Create React App and similar client-rendered apps) | Supported, with react-i18next |
| Next.js (App Router) | Supported. Set up with next-intl. |
| Django | Supported, with gettext .po catalogues. See Django. |
| Vue | Beta: review the diff carefully |
| React Native (Expo Router, classic Expo, bare) | Beta: translations bundled with the app, device language. See React Native & Expo. |
| Static HTML | Supported |
| Markdown / MDX docs (Fumadocs) | Supported, alongside any of the above or on their own with --docs-only. See Markdown & MDX Docs. |
The Next.js Pages Router gets translated strings and injected useTranslations() calls, but _app isn't wired up for you yet. The command prints what to add. Any other project type exits with an explanation and changes nothing.
Requirements
- A git repository. The command writes into your working tree so you can review and undo it with git.
- A clean working tree. Commit or stash your work first, so the translation shows up as its own diff.
--forceskips this check. - A way to translate: signed in to Babelize (
babelize auth login, uses your credits), your own LLM key, or a Babelize API key (see Babelize-hosted translation). A key in the environment wins over being signed in. Your own key can come from any of these providers:
| Env var | Provider | Default model |
|---|---|---|
ANTHROPIC_API_KEY | Anthropic | claude-sonnet-5-5 |
OPENAI_API_KEY | OpenAI | gpt-6.1-sol |
GEMINI_API_KEY | Google Gemini | gemini-3.5-flash |
GROQ_API_KEY | Groq | openai/gpt-oss-120b |
OPENROUTER_API_KEY | OpenRouter | anthropic/claude-sonnet-5.5 |
When several keys are set, the first one in this table wins, any of them wins over BABELIZE_API_KEY, and BABELIZE_API_KEY wins over being signed in. --provider picks a specific one (including babelize), and --model overrides the default model of your own provider. Your key is only sent to that provider and is never printed. The provider bills your account for the tokens.
Babelize-hosted translation
When you're signed in, or have BABELIZE_API_KEY set, and there's no LLM key of your own (or with --provider babelize), strings are translated by Babelize and billed in Babelize credits. Signed in, your account's session is used, so no API key is needed; it's refreshed automatically during long runs. For CI, create a key at app.babelize.co/api-keys; the free tier includes AI credits.
- Only the strings and their context leave your machine, never your source code. Extraction, placeholder protection, validation and code injection all run locally, exactly as with your own key.
- Babelize picks the translator: a hosted LLM when the platform has one configured, otherwise the NLLB translation model. The summary shows which, e.g.
Provider Babelize hosted · nllb, and the credits the run used (Credits 12 used).--modeldoesn't apply. - Each translated string in each language costs the same credits as in a platform run (very long strings count as several, see credits). Strings that fail aren't charged, a retry of a rejected string isn't charged twice, and strings reused from the lockfile aren't sent at all.
- Strings longer than 2,000 characters aren't sent; they're reported as failed with a hint to use your own LLM key for them.
- When you run out of credits the command stops with exit code
6and tells you how to top up; an invalid key or a session that can't be refreshed exits with3. Rate limits are retried automatically.
Options
| Flag | Description |
|---|---|
-t, --to <locales> | Target locales, comma-separated (fr,de,ja,pt-BR). If omitted, the command prompts for them when run interactively. |
-f, --from <locale> | Source locale (default en) |
--dry-run | Print a unified diff of what would change and write nothing. This still calls your LLM provider (or uses Babelize credits). |
--force | Run even if the working tree has uncommitted changes |
--replace | Treat --to as the complete list and remove locales from earlier runs that aren't in it |
--provider <name> | anthropic, openai, gemini, groq, openrouter, or babelize (hosted: BABELIZE_API_KEY or your signed-in session) |
--model <id> | Model ID for your own provider (not available with babelize) |
-y, --yes | Skip the confirmation prompt |
--report <path> | Where to write the QA report (default .babelize/local/report.json). A --dry-run writes no report unless you pass this. |
--allow-partial | Exit 0 even when some keys failed. They are still listed in the summary and the report, and retried next run. |
--formality <locale=level> | Formal or informal address per language: formal, informal or auto, e.g. de=formal,it=informal (repeatable). Merged over formality in .babelize/config.json. See Formality. |
--brand-voice <text> | Tone and style for the translator. Replaces brandVoice in .babelize/config.json for this run. |
--include <glob> | Only extract from and rewrite these paths (repeatable). Replaces include in .babelize/config.json. See Choosing which files are translated. |
--exclude <glob> | Never extract from or rewrite these paths (repeatable). Replaces exclude in .babelize/config.json. |
--docs-only | Only translate the Markdown/MDX docs configured in docs in .babelize/config.json. See Translating Markdown and MDX docs. |
--json | Print the QA report as JSON instead of the summary (for CI and scripts) |
Locales you translated earlier are kept. --to ja after --to fr,de adds Japanese and also brings French and German up to date. Their existing translations come from the lockfile, so only new strings cost tokens. To drop a locale, pass the full list you want with --replace. Removed locale files appear as deletions in the diff.
Choosing which files are translated
By default every source file in the app is scanned. To leave some code alone (a component library published from the same repo, a CLI, demo fixtures), list globs relative to the project root in .babelize/config.json:
{
"include": ["src/app/**", "src/components/**"],
"exclude": ["src/registry", "**/*.stories.tsx"]
}include: only these paths are scanned. Omit it to scan everything.exclude: never these paths, even when included. A plain path likesrc/registrycovers everything under it.- Excluded files are never read for strings and never rewritten. Pushes that only change them don't trigger a sync.
--includeand--excludereplace the matching list for one run. Commit the config so the dashboard, GitHub PRs and your teammates use the same scope.
If package.json is published to npm (it has exports, module, types, files or publishConfig, and isn't private), the i18n library goes in devDependencies. That way your package's consumers don't install it.
Translating Markdown and MDX docs
Add docs to .babelize/config.json and babelize translate also translates your documentation pages, after the app strings, with the same translator, glossary, brand voice and formality:
{
"docs": { "include": ["content/docs/**"] }
}Each .md and .mdx page gets a translated copy next to it: install.mdx → install.ja.mdx. That is the layout of Fumadocs' default dot i18n parser. For its dir parser (content/docs/ja/install.mdx) set "layout": "dir" and "root": "content/docs". Fumadocs meta.json files get their sidebar labels translated into meta.ja.json (title, description, ---Separator--- and link text; page names, icons and URLs stay).
What a reader sees is translated: frontmatter title and description, headings, paragraphs, list items, table cells, quotes and readable JSX props like title or description. Code blocks, inline code, import/export lines, {expressions}, URLs and JSX tags stay exactly as written. Links, bold and italic are kept around the translated text. A translated heading keeps the source heading's id (## Instalación [#installation]), so #installation links still work; set "anchors": false in docs to turn that off.
Translations are stored per paragraph in .babelize/local/docs-lock.json (commit it). A re-run only translates paragraphs that are new or changed, so editing one sentence costs one paragraph. Translations of text no page has any more are dropped from it. Translated pages are formatted with your project's Prettier or Biome (its config and ignore file apply), so they pass format checks and an unchanged page isn't rewritten. Babelize-hosted translation is built for UI strings: long pages work, but take about a minute per few paragraphs. Your own LLM key is much faster for docs. A paragraph whose Markdown didn't survive translation keeps its source text, is listed in the summary, and is retried next run (exit code 6, unless --allow-partial).
Run babelize translate --to es,ja --docs-only to translate only the docs, for example on a docs-only site.
Apps that already have i18n
If your app already uses an i18n library (vue-i18n, next-intl, react-i18next, i18next, react-intl) and keeps its translations in one of these layouts, Babelize translates those files where they are and doesn't set up i18n a second time:
| Layout | Example |
|---|---|
| One JSON file per locale | frontend/locales/en.json, messages/en.json |
| A folder per locale, one JSON file per namespace | messages/en/auth.json, public/locales/en/common.json |
| A folder per locale of TypeScript or JavaScript modules | messages/en/auth.ts exporting export const auth = { … } |
- The keys in your source files (
en.json, or every file inmessages/en/) are what gets translated. Source files are never rewritten. - Each target locale is written next to them (
frontend/locales/de.json, ormessages/de/auth.jsonfor each namespace file), with the source's key structure and indentation and nothing extra added. - Message modules are read without running them: each must export one object of plain text values. A new language's modules are copied from another language's (so its
import … from '../en/auth'andsatisfies MessageShape<…>carry over) with only the strings replaced, and the folder'sindex.tsis copied too. New keys are added to an existing module after the last entry of their object. Register the new language where your app loadsmessages/<locale>. - Translations already in your locale files are kept. Only missing keys, and keys whose source text changed, are translated. Locales you don't pass with
--toare left as they are. - Components, i18n config and dependencies aren't changed. Text still hardcoded in components shows up as not wired into code, for you to add to your setup.
- If a code file lists your app's languages (a locale registry, a
supportedLocalesarray), the summary names it so you can register the new language there.
If Babelize can't use your setup as it is, it stops and explains why without changing anything. That happens when a message module holds something that can only be read by running it (a spread, a function call, a template literal with ${…}), or when there's no locale file for the source language (pass --from with the language your files are written in).
How it works
- Copy. Your project is copied to a private temporary directory.
node_modules,.git,.nextand.turboare left out, as are the root-leveldist,buildandcoveragefolders and anything in.gitignore. Folders with those names deeper in your source, such as a Next.js route atapp/build/, are included..babelize/is always included, even when gitignored, because it holds the lockfile. - Run. The pipeline runs on the copy: extract, generate keys, translate, inject, then finalize (add i18n dependencies and config).
- Check. Each target locale must have real translations. If the provider fails, or a locale comes back empty or as a copy of the source text, the run stops and your working tree isn't touched.
- Apply. New and changed files are copied into your project, and files the pipeline removed are deleted. The temporary copy is always cleaned up.
If your project lists prettier or @biomejs/biome in package.json and has it installed, the changed code files are formatted with your own formatter and config before anything is applied. If formatting fails, the run continues with unformatted files and the summary says so.
Your package manager isn't run. If dependencies were added to package.json (for example next-intl or react-i18next), the summary tells you, and you run npm install (or pnpm, yarn, bun) yourself.
Reviewing the result
The summary lists every changed file and a line per locale:
Framework Next.js
Provider Babelize hosted · nllb
Keys 42 in en.json (42 new this run)
Translations 84 new · 0 reused (lockfile, memory or cache)
Credits 70 used · your balance is at app.babelize.co/billing
Files 6 modified, 7 added, 0 deleted
✓ fr 42/42 translated
✓ de 42/42 translated
unchanged: footer.brand — fine for names and codes- failed: the key couldn't be translated. Reasons include a dropped placeholder, an invalid ICU message, untranslated output and a provider error. The reason is shown next to each key. Failed keys are left out of the locale file, never filled with source text, and the next run retries them. All other translations are still written, and the command exits with code
6so CI notices. - QA warnings: the key was translated but the engine flagged something to review, such as a glossary term not kept.
- re-translated, glossary changed: the source text didn't change, but a glossary rule that applies to it did (a never-translate term or a fixed translation), so the key was translated again. Only keys containing the edited term are redone, and they pass the same checks as new translations. Translations edited in the dashboard, or taken from your project's own translation files, are left alone.
- unchanged: the value equals the source text, which is usual for names and codes. It counts as translated; the engine itself rejects output that is just untranslated text.
- not wired into code: a string was extracted and translated, but the injector found nowhere to put the translation call, so your app still shows the literal text. The summary lists the file, the key and the reason. Wire it up by hand with the listed key; later runs reuse that key instead of creating a duplicate.
- left as-is, not translated: text in a module-level constant that is also used as data (an
<option>value list, an object passed to an API), so Babelize didn't give it a key. Nothing changes in your code. If it really is UI text, move it inside the component (or translate it where it's rendered). Two more kinds are left as written on purpose: text in a script your source language doesn't use (an Arabic sample in an English app's RTL demo, a "日本語" label), and language-code badges likeen,FRorpt-BR. Translating them would change what they show. - look unused: keys in your source locale file that have no
t()reference anywhere Babelize can see. This check is only a hint, because references can hide in renamed translators, templates or other file types. These keys are still translated, kept and written like every other key. If they really are dead, remove them from the source locale file yourself. - missing: the locale has no value for the key, so your app falls back to the source locale.
The Credits row appears only with Babelize-hosted translation. With your own key, the provider bills you directly.
Then review and commit:
git diff
npm install # if the summary says dependencies were added
git add -A && git commit -m "i18n: add fr, de"Without prettier or biome, the injected code is valid but may not match your style, so run your formatter before committing.
In CI or when output is piped, the command prints one plain line per pipeline step instead of an animated spinner.
To discard a run instead, use git checkout . && git clean -fd.
Next.js: choosing the language
Next.js projects use next-intl, and each language gets its own URL without moving any of your routes:
/ja/pricingis served by your existingapp/pricingpage, in Japanese. The source language keeps its URLs (/pricing).- A generated
proxy.ts(middleware.tsbefore Next.js 16) does the routing. It sits next toapp/.- It rewrites
/<locale>/…to the page, passing the locale to next-intl in anx-babelize-localerequest header. - It sends hreflang
Linkheaders so search engines find every translation. - It remembers a visitor's language in a
NEXT_LOCALEcookie. A visitor who chose Japanese and opens/pricingis sent to/ja/pricing.
- It rewrites
- The root layout's
<html>gets the activelanganddir, so Arabic, Hebrew, Persian and Urdu render right to left. i18n/locales.tsholds the locale list andlocaleDirection().i18n/request.tsloads the messages.
To switch language from your UI, set the NEXT_LOCALE cookie and go to the page's URL in that language (/ja/…, or no prefix for the source language).
Edit any generated file freely: remove its first line and Babelize leaves it alone on later runs. If your project already has a proxy.ts or middleware.ts, Babelize doesn't touch it. It tells you what to add instead.
To keep a section in the source language until it's translated (docs, a blog), list it: "routing": { "sourceOnly": ["/docs"] }. /ja/docs/… then redirects to /docs/….
Strings a language doesn't have yet show the source text: the generated i18n/request.ts layers each locale over the source messages.
Components now call useTranslations(), so tests that render them need a <NextIntlClientProvider locale="en" messages={en}> around them.
To keep one URL for every language, set "routing": "cookie" in .babelize/config.json. The locale then comes from the NEXT_LOCALE cookie, then the browser's Accept-Language, then the source locale.
Components get const t = useTranslations(), which works in Server Components and in components that Client Components import. Only components that are already async get await getTranslations(). Babelize never makes a component async.
Because the locale is read per request, pages render dynamically rather than statically.
React Native: choosing the language
React Native apps bundle their translations (src/locales/<lang>.json, or locales/ without a src/ folder) and start in the device's language through expo-localization (Expo) or react-native-localize (bare React Native). The generated i18n.ts is imported first by the app's entry point. See React Native & Expo for what's extracted and how to let people pick a language.
The QA report
Every run (except --dry-run, unless you pass --report) writes .babelize/local/report.json: the same data as the summary, with nothing truncated. It has one entry per key with its status in each locale (translated, warning, unchanged, failed, missing), the source text and the translation, plus full lists of failures (reason, whether a retry can help, the error message), QA warnings, unchanged values, missing keys and strings not wired into code, counts per locale, the provider and model, and start/finish timestamps.
--json prints exactly the same object to stdout, so a script can use either. The format is described by a JSON Schema shipped in @babelize/engine at schemas/translate-report.schema.json (TypeScript: import type { TranslateReport } from '@babelize/engine'):
babelize translate --to fr,de --yes --json > report.json
jq '.failures.items[] | "\(.locale) \(.key): \(.reason)"' report.json
jq '.locales[] | {locale, status, translated, total}' .babelize/local/report.jsonok is false when any key failed. A report written to a custom --report path inside the repo is recognised by its $schema field and never counts as an uncommitted change, but git status still shows it unless it's ignored, so prefer a path under .babelize/local/ or outside the repo. When the run itself fails (bad key, dirty tree, …) no report is written, and --json prints {"ok": false, "error": {"code", "message"}} instead.
The first run also adds .babelize/.gitignore containing local/report.json, so the report stays out of git while the lockfile next to it is committed. The report is safe to commit or to ignore; an untracked report never counts as an uncommitted change for the next run.
Re-running: the lockfile
Each run writes .babelize/local/babelize-lock.json to your project root. It records the source text and translation of every key. Commit it. On the next run, only new or changed strings go to your LLM provider. Everything else is reused, which saves time and tokens and keeps reviewed translations stable.
babelize init adds .babelize/ to .gitignore. Reruns on your machine still reuse the lockfile, but teammates and CI won't get it. To share it, run git add -f .babelize/local/babelize-lock.json once, or add !.babelize/local/ to .gitignore. The summary reminds you when the lockfile is ignored.
Checking translations in CI
babelize check QAs the committed locale files without translating anything (missing keys, placeholders, ICU and plural forms, glossary) and exits 1 on errors. Run it in CI after translating, or on every pull request.
Testing without an API key: pseudo-localization
--provider pseudo "translates" offline into pseudo-localized text: Save note becomes ⟦Sávé ñóté⟧, placeholders and tags are kept, and count messages become real ICU plurals for the target locale. Nothing leaves your machine and nothing is billed. Use it to check that every string is wired up (anything still in plain English after switching locale was missed) and to test the pipeline in CI. It is never picked automatically and doesn't accept --model. Don't ship the output: the lockfile marks these values as pseudo, so the next run with a real provider translates them all (nothing is reused), and until then babelize check reports every one as a pseudo_translation error.
babelize translate --to de,ja --provider pseudo --yesExit codes
| Code | Meaning |
|---|---|
0 | Success (or nothing to change), including a partial run with --allow-partial |
2 | Usage problem: not a git repo, uncommitted changes, bad locale, missing --to, or project files changed while the run was in progress (nothing is applied; rerun) |
3 | The provider rejected or couldn't be reached with your key, or BABELIZE_API_KEY or your session was rejected |
4 | Configuration problem: no way to translate (no key set and not signed in), or an unsupported project |
6 | Some keys failed: everything that did translate was written, the failed keys are listed in the report (ok: false) and retried next run. Also used when translation failed as a whole and nothing was changed (including running out of Babelize credits); then there is no report and --json prints an error object. |
1 | Writing the result failed. Changes are rolled back; if a file can't be restored, the command lists it with the git checkout command to restore it. |
128 | Cancelled |
Want the full platform?
babelize translate is the local path. The Babelize platform adds GitHub PRs, translation memory shared across projects, a glossary and brand voice, and a review dashboard.
HTTP API: POST /v1/translate/batch
The endpoint behind Babelize-hosted translation, for the CLI, the MCP server and your own agents. It translates strings only, never code. It accepts a Babelize API key or a signed-in session token.
GET /v1/translate/capabilities (free) says what serves it: { "provider", "model", "plainText" }. plainText: true means the NLLB translator, which needs ICU messages split into plain sentences and placeholders protected first; the CLI does this for you.
curl https://api.babelize.co/api/v1/translate/batch \
-H "Authorization: Bearer $BABELIZE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"sourceLocale": "en",
"targetLocale": "pl",
"entries": [
{ "key": "cart.items", "text": "{count, plural, one {# item} other {# items}}" },
{ "key": "save", "text": "Save", "context": "Save your changes", "element": "button" }
]
}'Request body (unknown fields are rejected):
| Field | Type | Limits |
|---|---|---|
sourceLocale, targetLocale | ISO code, e.g. en, fr, ja | Must be supported and differ |
entries[] | { key, text, context?, element? } | 1–100 entries, unique keys, text ≤ 2,000 characters, context ≤ 1,000, element ≤ 100. context is the sentence a fragment belongs to; element is where it appears (button, label, …) |
glossary[] | { term, translation } | ≤ 50 items, each ≤ 200 characters. Terms the translation must use |
hints | { [key]: note } | ≤ 300 characters each. Per-key notes, e.g. what a previous attempt got wrong |
brandVoice | string | ≤ 1,000 characters |
Locales may carry a region or script (pt-BR, zh-TW, es-419, sr-Latn); the model is told the exact variant.
Idempotency. Send an Idempotency-Key header (8–128 letters, digits, - or _; a UUID works) and reuse it when you retry the same request. For 15 minutes a repeat with the same body returns the stored response with Idempotent-Replayed: true and charges nothing; the same key with a different body is rejected with 422 idempotency_key_reused. The server gives the model at most 90 seconds, so use a client timeout above that (the CLI uses 150 s); if you disconnect before the answer, the model call is cancelled and nothing is charged.
Placeholders, tags and brand names are safest sent as [#VAR_0#]-style tokens, which the model copies unchanged. The CLI does this for you and validates every result.
Response — one result per entry, in request order:
{
"results": [
{ "key": "cart.items", "status": "ok", "value": "{count, plural, one {# produkt} few {# produkty} many {# produktów} other {# produktu}}" },
{ "key": "save", "status": "failed", "reason": "missing_from_response", "retryable": true }
]
}The X-Babelize-Model header names the model used, and X-Credits-Charged the credits charged.
Credits. Each entry costs one translation unit per started 500 characters of text + context (at least one), and a unit costs the same as one string in one language in a platform run (5⁄6 of a credit). The batch is reserved up front; only ok results are charged and the rest is released. Fractions of a credit carry over to your next request rather than being rounded up. An entry you already paid for within the last 15 minutes with the same source and target locale, text, context, element, brand voice and applicable glossary terms is free — that's a retry of a translation you rejected (hints may differ). Duplicate texts within one request are each charged.
| Status | Meaning |
|---|---|
400 | Invalid body, unsupported locale or malformed Idempotency-Key |
401 | Missing or invalid API key |
402 | Not enough AI credits (credits_needed, credits_remaining in the body) |
422 | Idempotency-Key reused with a different body |
429 | Rate limited; wait for Retry-After seconds |
502 | The model failed; nothing was charged. Retry after Retry-After |
503 | Hosted translation unavailable |
Self-hosting the backend
Hosted translation is off unless you enable it. Set these on the backend:
| Variable | |
|---|---|
HOSTED_TRANSLATION_PROVIDER | Required to enable: gemini, anthropic, openai, groq or openrouter. Unset or off disables it. |
HOSTED_TRANSLATION_API_KEY | The platform's LLM key. Use a paid-tier key: free tiers rate-limit far below what a cloud run needs. Defaults to the provider's usual variable (GEMINI_API_KEY, ANTHROPIC_API_KEY, …). |
HOSTED_TRANSLATION_MODEL | Optional. Defaults to the provider's default model (gemini-3.5-flash for Gemini). |
TRANSLATOR_URL | Optional NLLB service. With hosted translation off, it serves this endpoint. With it on, cloud runs use it as a backup for strings the LLM can't be reached for; those are written to the locale file, flagged for review, not billed, and retried with the LLM on the next run. |
REDIS_URL | Recommended when you run more than one backend instance. It holds the idempotency replays, free retries and fractional credits. Without it they live in the backend process: exact on a single instance, and a restart drops them (an unbilled fraction of a credit at most). |
While it is off, cloud runs without a customer key and this endpoint use the NLLB service (TRANSLATOR_URL). With neither configured, this endpoint returns 503.
Last updated: 2026-11-01
Installation & Setup
Install the Babelize CLI (@babelize/cli), log in, verify the connection to your account, configure defaults, and log out. Step-by-step setup with examples.
Check Translations
QA locale files from the terminal or CI: missing keys, lost placeholders, broken ICU and plural forms, glossary violations. Read-only, no account needed.