Blog/Web engineering

An LLM translation pipeline for i18n: glossary, placeholders and review

How to translate product and website strings with LLMs without breaking placeholders, plurals or tone: glossary, ICU messages, QA checks and review gates.

··9 min read

  • Translation
  • i18n
  • LLM
  • Vue
  • Localization
Three source cards, one translated card with a glossary ribbon, then a checked clipboard.

Key takeaways

  • Protect placeholders, tags and ICU syntax before the model sees the string, and verify them afterwards; that check catches most real breakage.
  • Translate values, never keys, and keep the file shape identical so the build stays diffable and reviewable.
  • A glossary plus a do-not-translate list does more for consistency than prompt wording, because it is enforced mechanically.
  • German and Hungarian need decisions up front: formal or informal address, compound handling, and quote style.
  • Let the model translate, let scripts verify, and let humans review only the strings where a mistake is expensive.

Listen to this article

0:000:00

Every product website ends up with a translation pipeline, whether you plan one or not. The usual version is a spreadsheet, a script somebody wrote once, and a rule that German copy has to be finished before the release branch is cut. It works until a placeholder is renamed, a plural form disappears, or the same button label is translated three different ways.

This post describes the pipeline I use for Vue and Nuxt sites: strings live in JSON, a model translates values with a glossary and explicit context, scripts verify the result mechanically, and humans only look at the strings where a mistake is expensive. It is deliberately boring, and that is the point: translation should be a build step, not an event.

What actually breaks

Machine translation of plain prose is a solved problem for European languages. What breaks in software is everything the sentence does not carry: the {count} that was inside the string, the HTML tag around a link, the plural form that German and Hungarian both need, the product name that must stay in English.

  • A dropped or renamed placeholder, which turns a working page into a runtime error or an empty space.
  • A broken ICU message, because plural categories differ per language and concatenation cannot express them.
  • Translated HTML or Markdown tags, which either break rendering or leak into the visible text.
  • Inconsistent terminology: the same feature called three names across one page.
  • Tone drift, where a formal German interface suddenly addresses the reader informally.

The pipeline

Translation pipeline stagesSource strings are extracted, translated with context and glossary, verified by scripts, reviewed by a human where it matters, and published with the site build.Extractlocale JSONTranslatecontext + glossaryVerifyscriptsReviewdiff viewPublishbuild
Five stages: extract, translate, verify, review, publish. Only two of them involve a model.

The extract step is the only place that produces source strings. If a string is hard-coded in a component, it will never be translated, so the first useful check is a linter that fails on visible text literals in templates. The translate step is stateless: it reads the source file, the glossary and the previous translation for the same key, and writes new values.

Protect placeholders and tags first

A placeholder is a contract between the string and the code that renders it. The model must not translate, reorder or drop it, and the code must not assume it survived. The reliable pattern is to extract every placeholder before the request and compare the list after the response; a mismatch is a hard error, not a warning.

import re

PLACEHOLDER = re.compile(r"\{[A-Za-z_][\w]*(?:,[^}]*)?\}|\{[0-9]+\}|%[sd]|</?[a-z][^>]*>")

def slots(text: str) -> list[str]:
    "Every placeholder and tag in the string, in order of appearance."
    return PLACEHOLDER.findall(text)

def check(source: str, target: str) -> list[str]:
    "Returns the differences; empty list means the translation is safe to ship."
    a, b = slots(source), slots(target)
    return [f"missing {s}" for s in a if s not in b] + [f"added {s}" for s in b if s not in a]

Tags deserve the same treatment. If the source contains <strong>{name}</strong>, the translation may move the tag but must keep both halves and the placeholder. A simple ordered comparison catches the common cases, and an HTML parser catches the rest.

Plurals and selects belong to ICU

German and Hungarian both distinguish singular and plural, so an English-only plural feels harmless right up to the first count of zero. ICU MessageFormat solves this properly, and Vue I18n speaks ICU, which means the message can live in the locale file and stay translatable as a unit.

Stringendehu
Cart count{count, plural, one {# item} other {# items}}{count, plural, one {# Artikel} other {# Artikel}}{count, plural, one {# termék} other {# termék}}
Storage size{size} GB of {total} GB used{size} GB von {total} GB belegt{size} GB a {total} GB-ból
<p>{{ $t('cart.items', { count: items.length }, items.length) }}</p>

// en.json
"cart": { "items": "{count, plural, one {# item} other {# items}}" }

// de.json
"cart": { "items": "{count, plural, one {# Artikel} other {# Artikel}}" }

// hu.json
"cart": { "items": "{count, plural, one {# termék} other {# termék}}" }

Two habits keep this working. Never build a sentence by concatenating a translated fragment with a number or a name, because word order differs; keep the number inside the message. And never let the pipeline see a half-translated plural: translate the whole ICU message as one string, with the same category names, and let a check confirm that every category in the source exists in the target.

Translate values, never keys

The translation step receives a JSON object and returns an object with the same shape. Keys are identifiers and stay untouched; only values change. With structured outputs this is enforceable at the API level, and even without a schema it is the easiest contract to check: the set of keys must be identical before and after.

{
  "page": "checkout",                  // context for the model, not translated
  "glossary": [["cart", "Warenkorb"], ["checkout", "Kassa"]],
  "strings": {
    "cart.title": "Your cart",
    "cart.empty": "Your cart is empty",
    "cart.items": "{count, plural, one {# item} other {# items}}"
  }
}

Grouping matters more than size. Strings that share a page, a component or a tone belong in one request, because the model uses the neighbours as context. Twenty to sixty strings per request is a good range: a retry is cheap, and the context is still specific. Re-translate only what changed, by comparing a hash of the source value, and the daily cost stays close to zero after the first full pass.

Glossary and translation memory

A glossary is the cheapest consistency tool in the pipeline: a list of source terms with their approved target terms and a rule about casing and inflection. The model gets it as input, and a check enforces it afterwards. If a forbidden translation appears, the string is retried once with the violation quoted back; after that it goes to review instead of looping.

SourceGermanHungarianRule
cartWarenkorbkosárnoun, capitalised in German
checkoutKassafizetésdo not translate, do not inflect
AI agentAI-AgentAI-ügynökkeep the English term, gloss it on first use

Translation memory is the other half: exact matches from previous releases are reused without a model call, and fuzzy matches are passed as a suggestion. In a JSON-per-locale setup the memory is just a table of source strings and accepted targets, but the effect is the same: the model only sees what is genuinely new, and reviewers only see what changed since the last release.

German and Hungarian specifics

German punishes inconsistency more than grammar errors. Pick one address form per product, document it, and put it in the style guide that ships with the glossary; a shop that writes "Sie" in the footer and "du" in the onboarding reads as assembled from parts. Nouns stay capitalised, compounds are written together, and quotes are the German ones: „so“, not "so".

Hungarian is agglutinative and has no grammatical gender, which changes the pipeline in two practical ways. First, "user" or "customer" cannot be carried through with pronouns: the translation must either repeat the noun or be rewritten without it. Second, suffixes attach to nouns, so a placeholder inside a case suffix has to be avoided or the message restructured until the suffix sits next to the word.

  • Write a one-page tone note per locale and keep it next to the glossary in the repository.
  • Encode every recurring term as a glossary entry instead of repeating it in the prompt.
  • Let a native speaker fix the tone note once, then treat the fixes as lint rules.
  • Test the result in the layout, not in the JSON: German and Hungarian are usually longer than English.

Checks that catch almost everything

Every failure mode from the first section has a mechanical check. Run them in the same commit as the translation, not in a cleanup sprint later; a pipeline that cannot fail loudly will ship broken strings quietly.

// verify-locales.mjs - runs in CI, exits non-zero on the first real problem
const checks = [
  ['placeholder parity', (src, tgt) => sameSlots(src, tgt)],
  ['icu category parity', (src, tgt) => sameIcuCategories(src, tgt)],
  ['no empty values', (src, tgt) => tgt.trim().length > 0],
  ['glossary respected', (src, tgt) => glossaryViolations(src, tgt).length === 0],
  ['length sanity', (src, tgt) => tgt.length < src.length * 2.2],
]
  • Placeholder and tag parity, per string and per ICU message.
  • Identical key sets between source and target locale files.
  • No empty or unchanged values where the source changed.
  • Glossary violations listed with the offending term, so the retry prompt can quote them.
  • Length ratio bounds, which flag truncation and runaway expansions before a designer sees them.

Cost and throughput

Translation is a small part of an LLM bill because the strings are short. As a rule of thumb one thousand strings of about twenty words each cost roughly 20k input tokens and 25k output tokens; with a mid-tier model priced at three dollars per million input tokens and fifteen dollars per million output tokens, that is about forty-five cents per thousand strings. Frontier models cost several times that and rarely pay off for this task.

PassWhat it seesCost driver
First full runEvery source string plus glossarySource volume, once
Release deltaOnly keys whose source hash changedChange rate
Exact memory hitNothing, copied from memoryZero
ReviewDiffs, flagged violations, samplesHuman minutes

The throughput question matters more than the price. A full pass over a few thousand strings takes minutes and can run in CI; the bottleneck is review, which is why the pipeline should present reviewers with a diff of changed keys and a list of flagged strings instead of a file to read from the top.

Where humans still matter

A review gate is worth its cost when a mistake is expensive: onboarding, pricing pages, legal text, anything a customer will quote back to you. For the long tail of error messages and settings labels, automated checks plus a glossary beat a rushed human pass, and pretending otherwise just shifts the work to the next release.

  1. The pipeline produces a per-locale diff and a list of flagged strings.
  2. A reviewer approves or edits only the flagged and high-visibility entries.
  3. Approved values are written back, which updates the translation memory.
  4. The build publishes the site, and the checks run again on the published artefact.

None of this is exotic. Source strings in JSON, a glossary next to them, a model that translates values, a script that checks contracts, and a human who looks at the diff. The pipeline is boring on purpose, because the interesting parts of a product should never be the translations.

Sources

  1. Unicode ICU User Guide: Formatting Messages
  2. Vue I18n: Message Format Syntax
  3. Nuxt i18n: Getting started
  4. Unicode CLDR: Plural rules
  5. OpenAI: Structured outputs
  6. DeepL API: Glossaries
  7. W3C: Localization vs. internationalization
  8. GNU gettext manual
  9. RFC 5646: Tags for Identifying Languages
  10. Rat für deutsche Rechtschreibung
  11. Microsoft Writing Style Guide

Frequently asked questions

Do I still need a translation management system?

Not necessarily. A JSON-per-locale layout with a review gate in the repository covers most product sites. A TMS becomes worth its price when you have external translators, many locales, or release branches to reconcile.

How large should one translation batch be?

Small enough that a failure is cheap to retry, large enough to give context. In practice 20 to 60 strings per request, grouped by page or component, works well and keeps costs predictable.

Can the model see the whole file for context?

Yes, as read-only context. Send the source file with the strings to translate marked, and let the response contain only the translated values. That keeps the prompt small and the output schema simple.

What about languages with no plural form in English?

ICU handles this, but only if the source string uses ICU too. Move plurals and counts into ICU messages first, then translate the message, not the concatenated sentence.

Is machine translation post-editing still needed?

For high-visibility copy, yes. For long-tail error messages and settings labels, a good glossary and automated checks usually beat a rushed human pass.

Sounds like what you need?

Tell me about your project or role – I’d love to hear from you.