DeepL
DeepL is a machine translation service that often produces noticeably better output than Google Translate for European languages. It is a good fit for the same round trip as the Google Sheets guide, but instead of a spreadsheet formula you call the DeepL API from a small script and let it fill in the target locale column.
This guide shows that round trip end to end: export a CSV, run a script that fills one locale column via DeepL, then preview and apply the import.
Like any machine translation, DeepL output should be reviewed before shipping. Use it for first-draft translations, layout testing, or seeding a new locale before a translator gets involved. For production-quality copy, route the result through a human reviewer.
When to use this#
- You already use DeepL for translation, or you tried Google Translate and the quality was not enough.
- You want a repeatable script you can re-run as new strings are added, instead of opening a spreadsheet by hand.
- You want glossary support so brand names and product terms come through unchanged.
- You want better placeholder preservation than
GOOGLETRANSLATEtypically gives you.
For agent-driven translation, see AI translations. For the simplest spreadsheet-based flow, see Google Sheets.
Prerequisites#
- A DeepL API key. The DeepL API Free plan is enough to try the workflow; production usage usually requires DeepL API Pro.
- Node.js available on your machine.
- The official
deepl-nodepackage installed as a dev dependency, or any HTTP client you prefer.
$ npm install --save-dev deepl-nodeSet your key in the environment:
$ export DEEPL_AUTH_KEY=your-api-key-hereStep 1: Export a CSV with the source locale#
Export the messages you want to translate and include the source locale you will translate from:
$ npx messagevisor export \ --locale=en \ --locale=de \ --target=web \ --onlyUntranslated \ --output=exports/de-web.csvThe CSV will look like:
messageKey,messageDescription,en,enStatus,de,deStatusauth.signin,Sign in button label,Sign in,direct,,missingauth.signout,Sign out button label,Sign out,direct,,missingdashboard.welcome,Dashboard welcome heading,"Welcome back, {name}",direct,,missingThe de column is empty because the locale has no direct translations yet. The script in the next step fills it in.
Step 2: Write a translation script#
Create a script that reads the CSV, sends each source value to DeepL, and writes the translated value into the target column.
import { readFile, writeFile } from "node:fs/promises";import * as deepl from "deepl-node";const [, , inputPath, sourceLocale, targetLocale] = process.argv;if (!inputPath || !sourceLocale || !targetLocale) { console.error( "Usage: node scripts/translate-with-deepl.mjs <csv-path> <sourceLocale> <targetLocale>", ); process.exit(1);}const translator = new deepl.Translator(process.env.DEEPL_AUTH_KEY);// DeepL uses ISO 639-1 codes (sometimes with a region suffix like "EN-GB"// or "PT-BR"). Map your Messagevisor locale keys to DeepL codes here.const deeplSource = sourceLocale.toUpperCase().split("-")[0]; // "en" -> "EN"const deeplTarget = mapTarget(targetLocale);function mapTarget(locale) { // DeepL requires the regional code for some languages. const overrides = { en: "EN-US", "en-US": "EN-US", "en-GB": "EN-GB", pt: "PT-PT", "pt-BR": "PT-BR", "pt-PT": "PT-PT", }; if (overrides[locale]) return overrides[locale]; return locale.toUpperCase();}const raw = await readFile(inputPath, "utf8");const rows = parseCsv(raw);const header = rows[0];const sourceIndex = header.indexOf(sourceLocale);const targetIndex = header.indexOf(targetLocale);if (sourceIndex === -1 || targetIndex === -1) { console.error( `CSV header must include both "${sourceLocale}" and "${targetLocale}" columns.`, ); process.exit(1);}for (let i = 1; i < rows.length; i++) { const row = rows[i]; const sourceValue = row[sourceIndex]; const existing = row[targetIndex]; if (!sourceValue || existing) continue; // skip empty sources and existing values const result = await translator.translateText( sourceValue, deeplSource, deeplTarget, { // Preserve placeholder integrity. See "ICU and placeholders" below. preserveFormatting: true, }, ); row[targetIndex] = result.text;}await writeFile(inputPath, stringifyCsv(rows), "utf8");// Use a real CSV parser in production. The minimal version below is for// illustration only and does not handle quoted multiline cells.function parseCsv(text) { return text .split(/\r?\n/) .filter((line) => line.length > 0) .map((line) => line.split(","));}function stringifyCsv(rows) { return rows.map((row) => row.join(",")).join("\n") + "\n";}For anything beyond a quick experiment, swap the inline parseCsv / stringifyCsv helpers for a real CSV library such as csv-parse and csv-stringify. Messagevisor's import expects strict CSV quoting, so a hand-rolled parser will break on multiline values, embedded commas, or escaped quotes.
Run it:
$ node scripts/translate-with-deepl.mjs exports/de-web.csv en deStep 3: Handle ICU and placeholders#
DeepL is better than most machine translation services at preserving placeholders, but it is not perfect. Three things help:
Use preserveFormatting#
The preserveFormatting: true option keeps DeepL from altering punctuation, capitalization, and spacing in ways that can damage ICU syntax. The script above already enables it.
Use tag handling for ICU placeholders#
DeepL supports tagHandling: "xml" plus ignoreTags to mark spans that must not be translated. ICU placeholders are not XML, but you can wrap them in XML tags before sending and unwrap them after:
function protectPlaceholders(input) { return input.replace(/\{[^}]+\}/g, (match) => `<x>${match}</x>`);}function unprotectPlaceholders(input) { return input.replace(/<x>([^<]+)<\/x>/g, "$1");}const wrapped = protectPlaceholders(sourceValue);const result = await translator.translateText(wrapped, deeplSource, deeplTarget, { preserveFormatting: true, tagHandling: "xml", ignoreTags: ["x"],});row[targetIndex] = unprotectPlaceholders(result.text);This handles {name}-style placeholders. ICU plural and select blocks like {count, plural, one {# item} other {# items}} are more fragile because DeepL may translate the inner literal text. For ICU-heavy messages, prefer the AI translations workflow, which gives an agent the context to keep these blocks intact.
Use a DeepL glossary for brand terms#
DeepL supports glossaries that pin specific source terms to specific target translations. This is the right place to enforce "Workspace" stays "Workspace" in every language, and "Pro plan" stays "Pro plan":
const glossary = await translator.createGlossary( "messagevisor-brand-terms", "en", "de", new deepl.GlossaryEntries({ entries: { Workspace: "Workspace", "Pro plan": "Pro plan" }, }),);const result = await translator.translateText(sourceValue, "en", "DE", { glossary: glossary.glossaryId, preserveFormatting: true,});Glossaries are per source/target language pair, so create one per pair you support and reuse the IDs.
Step 4: Preview the import#
Always preview before applying:
$ npx messagevisor import exports/de-web.csv --locale=deThe CLI reports changed messages, changed overrides, skipped rows, and warnings. Skim the summary before applying.
Step 5: Apply the import#
$ npx messagevisor import exports/de-web.csv --locale=de --applyThe importer writes the new de translations into the existing messages/ files.
Step 6: Validate and review#
$ npx messagevisor lint$ npx messagevisor test$ npx messagevisor catalogThe catalog is the fastest way to spot wrong-tone translations and ICU placeholder bugs. For RTL targets, see RTL language support.
Translating multiple locales#
Loop the script over a list of target locales:
import { execFileSync } from "node:child_process";const csvPath = "exports/multi-web.csv";const sourceLocale = "en";const targetLocales = ["de", "fr", "nl-NL", "es"];for (const target of targetLocales) { execFileSync( "node", ["scripts/translate-with-deepl.mjs", csvPath, sourceLocale, target], { stdio: "inherit" }, );}Then preview and apply the import in a single pass:
$ npx messagevisor import exports/multi-web.csv$ npx messagevisor import exports/multi-web.csv --applyWithout --locale, the importer reads every known locale column in the CSV.
Cost and rate limits#
The DeepL API Free plan caps you at a fixed character volume per month. For larger projects, DeepL API Pro charges per character translated.
To keep cost predictable:
- always run with
--onlyUntranslatedso DeepL only sees strings that actually need translating - cache results in the CSV by skipping rows that already have a non-empty target value (the example script does this)
- pin the source locale you translate from so you do not pay for re-translation across regional source variants
- audit the script output before re-running it on every push
For pseudo-localization or layout testing, do not use DeepL. Use a pseudo-localization workflow instead so you do not burn budget on throwaway translations.
Tips and variations#
Run from CI#
The same script works in a GitHub Actions workflow that auto-translates new keys on every pull request. See Auto-translating new keys in CI.
Pair with regional inheritance#
For regional locales (en-GB from en, pt-BR from pt), use --onlyDirectlyUntranslated on export and --prune on import so values matching the parent get dropped:
$ npx messagevisor export --locale=en --locale=en-GB --target=web --onlyDirectlyUntranslated --output=exports/en-GB-web.csv$ node scripts/translate-with-deepl.mjs exports/en-GB-web.csv en en-GB$ npx messagevisor import exports/en-GB-web.csv --locale=en-GB --prune$ npx messagevisor import exports/en-GB-web.csv --locale=en-GB --prune --applyCombine with a translator pass#
A common pattern is: DeepL fills in a first draft, the catalog shows it to a translator, the translator edits the parts that need work, and the result is committed. The CSV step is optional once the translations are in messages/ files: the translator can edit the YAML directly through a pull request.

