BabelizeBabelize
CLI

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 diff

Or 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 diff

Or 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 diff

What's supported

FrameworkStatus
React (Vite, Create React App and similar client-rendered apps)Supported, with react-i18next
Next.js (App Router)Supported. Set up with next-intl.
DjangoSupported, with gettext .po catalogues. See Django.
VueBeta: review the diff carefully
React Native (Expo Router, classic Expo, bare)Beta: translations bundled with the app, device language. See React Native & Expo.
Static HTMLSupported
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. --force skips 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 varProviderDefault model
ANTHROPIC_API_KEYAnthropicclaude-sonnet-5-5
OPENAI_API_KEYOpenAIgpt-6.1-sol
GEMINI_API_KEYGoogle Geminigemini-3.5-flash
GROQ_API_KEYGroqopenai/gpt-oss-120b
OPENROUTER_API_KEYOpenRouteranthropic/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). --model doesn'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 6 and tells you how to top up; an invalid key or a session that can't be refreshed exits with 3. Rate limits are retried automatically.

Options

FlagDescription
-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-runPrint a unified diff of what would change and write nothing. This still calls your LLM provider (or uses Babelize credits).
--forceRun even if the working tree has uncommitted changes
--replaceTreat --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, --yesSkip 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-partialExit 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-onlyOnly translate the Markdown/MDX docs configured in docs in .babelize/config.json. See Translating Markdown and MDX docs.
--jsonPrint 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:

.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 like src/registry covers everything under it.
  • Excluded files are never read for strings and never rewritten. Pushes that only change them don't trigger a sync.
  • --include and --exclude replace 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:

.babelize/config.json
{
  "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:

LayoutExample
One JSON file per localefrontend/locales/en.json, messages/en.json
A folder per locale, one JSON file per namespacemessages/en/auth.json, public/locales/en/common.json
A folder per locale of TypeScript or JavaScript modulesmessages/en/auth.ts exporting export const auth = { … }
  • The keys in your source files (en.json, or every file in messages/en/) are what gets translated. Source files are never rewritten.
  • Each target locale is written next to them (frontend/locales/de.json, or messages/de/auth.json for 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' and satisfies MessageShape<…> carry over) with only the strings replaced, and the folder's index.ts is copied too. New keys are added to an existing module after the last entry of their object. Register the new language where your app loads messages/<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 --to are 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 supportedLocales array), 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

  1. Copy. Your project is copied to a private temporary directory. node_modules, .git, .next and .turbo are left out, as are the root-level dist, build and coverage folders and anything in .gitignore. Folders with those names deeper in your source, such as a Next.js route at app/build/, are included. .babelize/ is always included, even when gitignored, because it holds the lockfile.
  2. Run. The pipeline runs on the copy: extract, generate keys, translate, inject, then finalize (add i18n dependencies and config).
  3. 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.
  4. 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 6 so 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 like en, FR or pt-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/pricing is served by your existing app/pricing page, in Japanese. The source language keeps its URLs (/pricing).
  • A generated proxy.ts (middleware.ts before Next.js 16) does the routing. It sits next to app/.
    • It rewrites /<locale>/… to the page, passing the locale to next-intl in an x-babelize-locale request header.
    • It sends hreflang Link headers so search engines find every translation.
    • It remembers a visitor's language in a NEXT_LOCALE cookie. A visitor who chose Japanese and opens /pricing is sent to /ja/pricing.
  • The root layout's <html> gets the active lang and dir, so Arabic, Hebrew, Persian and Urdu render right to left.
  • i18n/locales.ts holds the locale list and localeDirection(). i18n/request.ts loads 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.json

ok 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 --yes

Exit codes

CodeMeaning
0Success (or nothing to change), including a partial run with --allow-partial
2Usage 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)
3The provider rejected or couldn't be reached with your key, or BABELIZE_API_KEY or your session was rejected
4Configuration problem: no way to translate (no key set and not signed in), or an unsupported project
6Some 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.
1Writing 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.
128Cancelled

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):

FieldTypeLimits
sourceLocale, targetLocaleISO code, e.g. en, fr, jaMust 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
brandVoicestring≤ 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.

StatusMeaning
400Invalid body, unsupported locale or malformed Idempotency-Key
401Missing or invalid API key
402Not enough AI credits (credits_needed, credits_remaining in the body)
422Idempotency-Key reused with a different body
429Rate limited; wait for Retry-After seconds
502The model failed; nothing was charged. Retry after Retry-After
503Hosted translation unavailable

Self-hosting the backend

Hosted translation is off unless you enable it. Set these on the backend:

Variable
HOSTED_TRANSLATION_PROVIDERRequired to enable: gemini, anthropic, openai, groq or openrouter. Unset or off disables it.
HOSTED_TRANSLATION_API_KEYThe 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_MODELOptional. Defaults to the provider's default model (gemini-3.5-flash for Gemini).
TRANSLATOR_URLOptional 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_URLRecommended 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

How is this guide?

On this page