June 24, 2026
How to localize a web app with AI without it reading like machine translation
As a language-learning app, we care a lot about localized experiences that feel transcreated, not machine-translated. We also believe in open startup culture, sharing what we learn as we build. So here is a detailed, practical guide to how we localize our web app and our Android and iOS apps into many languages with cheap AI, mistakes included, in the hope other builders can learn from it.
How to localize a web app with AI without it reading like machine translation
If you build a web product, you eventually want it in more than one language. The default paths, a translation API or the browser's translate button, produce copy that reads like a machine wrote it: calques, wrong register, a warm line turned stiff. We build Reelang, a language-learning app, so shipping a clumsy translation of ourselves was never an option, and being findable in a learner's own language is close to the whole business.
We ship a web app and native apps for Android and iOS (the native side through Expo), all in one monorepo and all localized by the same flow. This is that flow, in order, with the parts that actually bit us, shared in the spirit of open startups. Nothing here is specific to us: the tools are open, the models are ones you rent by the token, and the details, including our mistakes, are where machine translation gives itself away.
The short answer: put every string in plain JSON, write a voice guide and a per-language glossary, prove them on the languages you can judge without hand-fixing strings, then let two cheap models (one translating, a different one reviewing) do the rest, re-running only what changes. The sections below are that method as a set of questions you might ask an AI agent, each answered on its own.
What does it mean to transcreate instead of translate?
Transcreation means recreating the effect of a line in the target language, not its words. A calque keeps the English sentence's skeleton and swaps the words; transcreation throws the skeleton away and writes the line a native speaker would from scratch. Decide which one you are aiming for before you touch tooling, because it changes every prompt you write.
The clearest way to see the bar is to look at one line across languages:
| EN | Human speech. Not AI slop. |
| DE | Echte Menschen. Kein KI-Geschwurbel. |
| NL | Echte mensen. Geen kitschmatige intelligentie. |
| ES | Voces reales. Cero relleno de IA. |
The three translations share zero words with each other, and each is closer to the original's intent than a faithful translation would be. German "Geschwurbel" is woolly waffle. The Dutch is a pun on kunstmatige intelligentie (artificial intelligence). Spanish "relleno" is filler. Recreate the contempt, not the dictionary. If your target keeps the English sentence's shape, you translated; if it reads like a native wrote it fresh, you transcreated. That is the standard the rest of the pipeline is built to hit cheaply.
Where should your app's translatable strings live?
Put every user-facing string in a plain-JSON catalog, one file per locale, with English as the source of truth and a short note above each key. We use Paraglide (from the inlang project) as the i18n layer, but the shape matters more than the library.
// context/en/home.jsonc
// Primary CTA on the homepage hero. Informal, imperative, ~24 chars.
"home_hero_cta": "Watch your first video"
A build step compiles the catalogs into type-safe functions, so you never hardcode a user-facing string.
m.home_hero_cta({}, { locale }) // -> "Schau dein erstes Video" for de
One catalog feeds every surface we ship from a single monorepo: the Next.js web app and the React Native apps for Android and iOS (through Expo), all localized by this one flow rather than a separate pipeline per platform. Plain JSON means any model can read and write it, and you can compare each locale against English to find exactly which keys are missing or still English. That machine-readable comparison is what later makes updates cost cents.
The note is the single highest-leverage thing you write, so treat it as the translator's brief, not an English gloss. It is the only per-string context every language shares, and it is inlined verbatim into every translate and review prompt. A weak note does not mistranslate one language; it mistranslates all of them at once, because the model cannot ask what you meant. Concretely:
- Name the concept, do not echo the ambiguous English word. If the UI verb hides a glossary term, say the term. "the words you tap" is really "the words you look up." We shipped a French bug where the note said "tap," so the model wrote toucher (a physical touch, a banned calque) instead of chercher. The note, not the string, was wrong.
- Flag false friends and jargon. If a literal rendering means something else in the target ("no gloss" reads in French as pas de sens, "makes no sense"), say what it must not become. Every piece of jargon (gloss, streak, feed) needs a plain-language unpacking in the note.
- State the effect for action labels. Buttons and links get the destination, not the English words: say where it leads and what the user expects to find there.
- Give the real constraints. Where it appears, tone, a length cap, which placeholders are present and must survive verbatim.
Rule of thumb: if a smart translator who does not speak your product could pick the wrong word from your note, the note is not done.
How do you catch strings you forgot to make translatable?
Add a pseudo-locale: a generated fake language that mangles every English string, so any hardcoded text that was never keyed stands out on screen. Switch it on before you pay to translate anything.
Learn -> [!! Ļéåřñ !!]
Three transforms stack, and each earns its place. Length-bucketed expansion pads short strings the most (a one-word button can grow 100%), because short labels are where layouts explode; if your nav breaks under German or Russian, this surfaces it before a real translation does. Accented look-alike glyphs (Learn to Ļéåřñ) prove the on-screen text actually came from the catalog. Wrapping in [!! ... !!] makes truncation and clipping obvious. Any real English still showing on the pseudo page is a string that was never keyed, which is a bug you want to find yourself, not one a paid translation run silently steps around. We regenerate it on every commit from the staged English, so a new string ships its pseudo form in the same change.
What is a translation voice guide, and what goes in it?
A voice guide is a short document that tells every model how your product sounds and which machine-writing habits to avoid. It is the first of two hand-written inputs fed to every model on every string, and it caps how good the output can get.
Register is the biggest. We talk to one person, casually, so in every language that distinguishes formal from informal address we use informal, including legal and footer copy. German gets du, never Sie. Dutch je. Neutral Spanish tú (no vos, no vosotros). Brazilian Portuguese você. Indonesian kamu, never Anda. A translation can nail every word, miss the register, and still be wrong, because register is what separates a product that feels like a friend from one that feels like a form at the bank.
The anti-AI rules go in here too, because a model left alone reaches for exactly the tells that give it away. Our hard rule, enforced in CI, is no em dashes or en dashes in any locale (the single most reliable machine-writing signal in English), substituted with a comma, period, colon, or a restructure. The carve-out is a dash a language uses as real grammar (the Russian zero-copula link between two nominatives), which is grammar, not decoration. Add your own list of tells to ban: content inflation ("a testament to", "underscores"), copula avoidance ("serves as" instead of "is"), promotional adjectives (seamless, robust, vibrant), forced rule-of-three, and negative parallelism ("not just X, but Y"). The voice guide is also the natural home for the transcreation checklist the reviewer runs: dangling referents, atmospheric drift (a concrete benefit flattened into a mood word), impersonal drift (a personal "you" escaping into an impersonal man or se), and flagship lines going literal.
We keep this guide as one Markdown file and hand the exact same file to the machines that we would hand a human.
What is a localization glossary, and why is it per-language?
A glossary pins how each brand and product word should render in every language, as per-locale forms rather than one global rule. It is the second hand-written input, and it is not a dictionary lookup: the right answer is rarely the same across languages.
// glossary.json: one entry, per-locale forms plus a note
"feed": {
"nl": "feed", // loanword; Dutch speakers use the English word
"ru": "лента", // Cyrillic; raw Latin "feed" reads foreign
"note": "the scrolling video feed on the home screen"
}
Loanwords are a Latin-script reflex, not a universal one. Words like feed, creator, Reels, Shorts ride along in German and Dutch because the script and the social-media register are shared. Drop the same raw Latin into Russian, Arabic, or Japanese and it reads foreign and breaks the line. There you either use the native word (Russian лента for feed) or transliterate a brand-format name into the local script in the declinable form people actually use (Russian рилсы, шортсы, тиктоки, not Reels). Never assume the West's loanwords travel. The only genuine keep-English-everywhere cases are the wordmark itself and standardized codes (for us, the CEFR levels A1 to C2).
Research how people actually search, not how academics write. Our methodology term is "comprehensible input," and the naive heuristic (Latin script, keep the English) produced a dead calque: input comprensible is what papers say, but no Spanish learner Googles it. So for any coined term of art, measure real search behavior (with real Google data, not intuition, see the seeding step below) and bucket the locale: loan-adopting (the anglicism really is the search term, often the Nordic languages), outcome-paraphrase (people search the result, so Spanish and Brazilian Portuguese learners type "videos para aprender inglés," and the methodology label only appears on professional or process pages), or native-script (an established local rendering, as in Russian, Chinese, Arabic). Then split by audience surface: learner-facing slugs get the outcome phrasing, creator and developer pages get the term. Record the evidence in a one-line note on the entry so future runs do not re-litigate it.
The glossary is prompt text, not a mechanical lock. It is tempting to hard-"keep" a term across all locales, but a blanket lock applies to every language and recreates the raw-Latin-in-Russian bug. So the glossary is built around per-locale forms and consumed as instructions the model reads, not a find-and-replace.
Why do placeholders like {language} break translations?
Because a placeholder resolves to one fixed word form, the bare nominative, and most languages need it inflected for the sentence around it. English lets "English" be a noun, an attributive adjective, and take no article all at once ("learn English," "English grammar," "English phrases"). Almost no other language does, so a bare {language} jammed next to a verb or noun calques. Real bugs we shipped and fixed:
- French "Apprends anglais" and "Grammaire anglais" both want inflected forms (l'anglais, and a reframe for the modifier).
- Russian "Грамматика {language}" renders "Грамматика английский" but wants the genitive английского; "видео на {language}" renders "на английский" but wants the prepositional английском.
- German "Nativer Deutsch Katalog" wants an agreeing adjective; Dutch "Engels grammatica" wants Engelse.
Two rules keep this sane. First, one message is one full sentence with a placeholder; never stitch fragments, because a noun inserted into a sentence inflects by its grammatical role. Each locale owns the whole string and decides word order and case. When you must inject a styled or animated span, render the full translated sentence, split it on a sentinel, and drop the node where the translation placed it, rather than concatenating a prefix key and a suffix key.
Second, give the placeholder grammatical forms, or reframe, or split into per-language strings. For "learn X," ask for an object form (languageName(code, locale, 'object')) so French gets l'anglais; for a modifier, reframe to a prepositional "in {language}" so the bare nominative slots in cleanly for all forty-plus languages; and where even that strains, author one natural line per language. More strings always beats one shoehorned line.
Plurals are where English lulls you into a wrong assumption. English has two plural forms, one and other, so {count} video and {count} videos covers every number. Polish has four (one, few, many, other); Arabic has six. The number of variants is per-language, so a translator that maps the English two forms onto two Polish forms is wrong across whole ranges of numbers. On the tooling side, inline ICU inside a flat string does not parse in the inlang message format either (Paraglide mangles it into garbage), so use the matcher object form, one variant per plural category, every placeholder declared. English needs two:
// English: two plural categories
"levels_detail_count": {
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "{count} video and counting.",
"countPlural=other": "{count} videos and counting."
}
}
Polish needs four, and the noun itself takes a different form in each:
// Polish: four plural categories (one / few / many / other)
"levels_detail_count": {
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "{count} film i wciąż przybywa.",
"countPlural=few": "{count} filmy i wciąż przybywa.",
"countPlural=many": "{count} filmów i wciąż przybywa.",
"countPlural=other": "{count} filmu i wciąż przybywa."
}
}
Miss the few or many category and the count reads ungrammatically across whole ranges (in Polish, few covers 2 to 4, many covers 5 to 21, and so on up). Getting the set of categories right per language, not just translating the words, is one more thing the target-language review pass exists to catch.
One more: gender-neutral second person. English "you" carries no gender; Russian, Spanish, Portuguese, and French force one on past-tense verbs, adjectives, and participles. Do not pick a gender. The Russian "именно это ты пришёл учить" assumes a male reader; "именно за этим ты здесь" fits anyone. Restructure to a form that fits any reader, and put that instruction in the voice guide so every locale obeys it.
What typography makes a translation feel native?
The quotation marks, apostrophes, and spacing a native typesetter places without thinking. A translation can be word-perfect and still read foreign because the typography is wrong. The marks that give it away, by language:
- French uses guillemets
« … »with a narrow non-breaking space (U+202F) inside them and before; : ! ?and%, the curly apostrophe’in every elision (l’input, neverl'input), the single-glyph ellipsis…, and accents on capitals (État, notEtat). - German uses low-high quotes
„ … “and a decimal comma, never English" ". - Spanish requires the inverted opening
¿and¡. - Russian uses
« … »and a non-breaking space after one-letter prepositions. - CJK uses full-width punctuation (
。,、, corner brackets「…」) and no ASCII commas or periods inside the run; Arabic and Hebrew are right-to-left with their own comma،and question mark؟.
The important decision: author these in the copy, and do not post-process them with a regex. The right mark is sometimes a judgment call (a real apostrophe versus a feet-and-inches prime, a quote versus an emphasis) that a blanket replace gets wrong, so the review pass checks them by eye. The rule that outlives any table: for a language you do not know, ask what a native compositor would do that a dictionary translation would not, across quotation marks, apostrophes, spacing, punctuation inventory, casing and diacritics, and number formatting, and treat any you cannot answer as research to do before the string ships. Numbers, dates, and lists are the one exception: format those in code with Intl so you never bake a locale's grouping into a string.
What makes a translation read like a machine wrote it?
A handful of predictable pitfalls turn a transcreated line into a calque, and most are invisible to a monolingual reviewer. This is the list the review model hunts for, and a useful self-check for any locale, especially one you cannot read:
| Pitfall | Reads as calque | The fix |
|---|---|---|
| Placeholder inflection | Грамматика английский, Grammaire anglais (bare nominative jammed in) | Give the slot a grammatical form, reframe to "in {language}", or split into per-language strings |
| Wrong plural set | English one/other mapped onto Polish, which has four | One variant per the target's plural categories, in the matcher object form |
| Fragment stitching | a prefix key and a suffix key concatenated in code | One message is one full sentence with a placeholder; inject the styled node |
| Formal register | German Sie, Indonesian Anda | Informal everywhere: du, je, tú, você, kamu |
| Gendered "you" | Russian ты пришёл (assumes a male reader) | Restructure to fit any reader (за этим ты здесь) |
| Raw-Latin loanword | feed or Reels dropped into Cyrillic or Arabic | Native or transliterated form (лента, рилсы) |
| ASCII punctuation | straight "…" in German, ' in French | Native marks: „ … ", « … » with narrow nbsp, ¿ ¡, full-width CJK |
| Em or en dash | — used as decoration | Comma, period, colon, or a restructure |
| False friend from the note | note says "tap", model writes toucher | Name the concept in the note ("look up" becomes chercher) |
| Academic calque for a coined term | input comprensible, which no learner searches | The outcome phrasing people actually search |
| Term drift | "browse" rendered three ways across nav, CTA, footer | One concept, one target word, pinned in the glossary |
| Atmospheric drift | "feel the language" | Keep the concrete, picturable benefit ("how people actually talk") |
| Length creep | a headline grows a trailing clause the source never had | Respect the source length; cut, do not pad |
Most of these are invisible to a monolingual reviewer and invisible to a translation API, which is exactly why they survive into shipped products. Encoding them once, in the note, the voice guide, and the glossary, is what lets a cheap model avoid them at scale.
Should you edit AI translations by hand?
No. Fix the input that produced the bad line, not the line: the voice guide, the glossary, or the key's note, then re-run. This is the discipline that makes the rest work, and it is where we started.
Between the two of us we read German, Dutch, Russian, English, Spanish, and a little Portuguese, so we translated those first under one rule: never correct a string directly. A direct edit fixes one string in one language. A better input fixes that class of mistake in every language, including the ones you cannot read. So we kept tightening the inputs until the raw output on the languages we speak needed no touch-ups, and we were genuinely happy with it. Only then did we extrapolate to languages we do not speak at all, because by that point the guidelines carried the quality, not our proofreading, so there was reason to trust them where we could not check every word. For those languages you read the model's reasoning, not just its output.
How do you find the right words for each language?
Seed the glossary before any translation runs, using a coding agent that checks real search behavior in the target language rather than guessing from a dictionary. Do it one term at a time, and do it once.
We run the seeding inside a coding agent (Claude Code or Kimi Code) that has both open web search and the Serper API (serper.dev, Google results as JSON), so for each term it works in the target language: which phrasing actually returns results, which variant has the most, what autocomplete and "people also search" suggest, and what a competitor like Duolingo calls a streak there. That is how you separate a loanword learners really use from an academic calque nobody types, the input comprensible trap from the glossary section above. The agent writes the entry from that evidence and records the one-line reason. Seeding first is what keeps word choices consistent once the work fans out across dozens of independent lanes; skip it and each lane re-decides (and re-calques) the same term. It is idempotent, so a mature locale's seed is nearly empty and cheap, and a rerun only fills gaps.
How do you translate a whole app cheaply with AI?
Split the site into lanes, one per surface, and run each lane through two passes by two different cheap models: one translates, a second, different model reviews. The second pass is what makes the cheap first pass safe.
- Translate. The source string, its note, the voice guide, and the glossary go in, the line comes out. We use GLM-5.2.
- Review. A second, different model reviews it. Not "translate again." Its only job is to catch the ways a translation is correct and still wrong: a calque, formal register where you wanted informal, a false friend, the wrong quotation marks, a missing accent on a capital. It fixes those before anything is written. We use Kimi K2.6.
The review pass is the whole trick. The model that wrote a line is the worst one to judge it, which is exactly why human translation shops use a separate reviewer. A different model, prompted as a skeptical native-speaker editor, catches most of what the writer missed, and that is what lets the cheaper first pass be safe.
Size the lanes deliberately. Keep a lane under about 120 keys. Go bigger and two things fail: the JSON answer can truncate mid-array, and you get tail drift, where term and register consistency slips in the back half of a long answer. Go too small and you lose alignment, because strings that share a surface (a page, the top nav, the paywall) share vocabulary, and splitting them across lanes makes each lane re-decide the same term. The unit is a surface-coherent group, capped.
Turn off model reasoning for translation. On reasoning-capable models, chain-of-thought roughly doubles latency and multiplies output tokens for identical output; we measured one call drop from about 16 seconds to 7 with reasoning disabled. Translation is not a reasoning task. Leave the judgment to the review pass.
On orchestration: you can drive the whole fan-out from a coding agent that spawns subagents, one lane each with its own reviewer. We started exactly there, first on Claude Code, then on Kimi Code, and both handle the workflow fine. But translating a whole site through your coding agent burns a large amount of tokens you would rather spend on actual development, so run the passes on cheap, dedicated inference instead. We route through OpenRouter to Cloudflare Workers AI with bring-your-own-key: one endpoint, many models, and automatic fallback when a provider goes slow or unresponsive.
The failure modes you will actually hit. Cloudflare Workers AI has a small free daily allocation (about 10,000 "neurons"); when it is spent, every call returns 429 and writes nothing until the daily reset, so a big run either waits or moves to a paid plan. And the largest lanes truncate when reasoning tokens plus the JSON answer overflow the output cap, which is a second reason to cap lane size and disable reasoning. The pipeline is idempotent, so after any partial failure you re-run the same lanes and it picks up exactly what is still untranslated.
Is one AI model best for every language?
No. Big general models are strong where the training text is thick (German, Spanish, French) and thin out the further you go, so test per language and use the best model for each. For Arabic, a model with Arabic as a first-class language beats a bigger general one that treats it as an afterthought. Because every call routes through one OpenRouter endpoint, you build the pipeline once and swap the model per language from config, with no rewrite, comparing on the cost and quality trade-off and pinning the winner per locale.
This matters most for the languages software serves worst, which is also the opportunity. The top languages are saturated with content, while plenty of high-demand languages have almost no properly localized sites. Cheap, native-feeling localization is how a small team competes there, and it is a large part of why we do this at all.
How do you check translation quality in a language you don't speak?
Read the self-audit: the reasoning the model returns for each borderline call, not just the strings it wrote. Every run returns one alongside the output, and it is how you QA a language nobody on the team speaks.
For each borderline call, the word choice or restructure or length trade-off, the audit records why the model decided as it did. That is not for the build, it is for you. You cannot eyeball forty languages, but you can read why a model made each debatable decision and catch a bad pattern in the reasoning before it spreads. Read the reasons, not the output.
How do you keep translations up to date when the source changes?
Wire the catalog to a tool that re-translates only what changed. We use Lingo.dev, and the mechanism worth understanding is its lockfile, i18n.lock. It is not a text diff. The lockfile stores a SHA-256 fingerprint of every source string, so on each run Lingo.dev hashes the current English, compares those fingerprints against the lockfile, and only the strings whose hash changed (plus brand-new ones) enter the translation pipeline. Everything unchanged is skipped and costs nothing.
It stores two hashes per entry, one for the content and one for the key, which buys a property a plain diff cannot. Rename or reorganize a key while its text stays the same, and Lingo.dev sees an identical content hash under a new key hash, keeps the existing translation, and re-translates nothing. So the unit of change is the content, not the key layout: a project with 10,000 keys where 12 changed translates 12, staying consistent by reading the same voice guide and glossary as its prompt.
# hashes the source, compares against i18n.lock, translates only what changed
lingo.dev run
Cost tracks churn, not size. Reword a button and it is cents to push the fix everywhere. Ship a page and it is a few dollars per language, run when the English lands rather than scheduled as a project. A language does not quietly rot back into English, because keeping it current is too cheap to skip.
How do you localize CMS content, not just UI strings?
Run the same flow over your CMS. UI strings are one surface; editorial content in a content management system is another, and half-translating it shows. Ours lives in Payload CMS, and a scheduled Trigger.dev task walks every localized field, compares it against the source, and translates the gaps through OpenRouter, pinned to Cloudflare Workers AI so it runs on free compute. GLM-5.2 handles it by default; the longer, prose-heavy blurbs use a free NVIDIA Nemotron 3 Ultra endpoint (nvidia/nemotron-3-ultra-550b-a55b:free), with GLM as the fallback when that endpoint is rate-limited. Same discipline as the UI: gaps only, structured output, idempotent.
How do you get found in other languages, including by AI assistants?
Localize the URLs too, not just the visible text, because people arrive by searching or asking an assistant, and both surface pages in the language of the question. A product that exists only in English is invisible to most of that.
So put each locale on an address in its own language, keep a per-language sitemap, and ping search engines as pages ship (the IndexNow protocol does this). The localization and the discoverability are the same effort. A perfect translation nobody can find is worth about as much as no page at all.
How much does AI localization cost?
Around a dollar per thousand words for both passes together, translate and review, and everything else scales from that. That is the figure to reason with, not any one team's total.
Multiply it by your own word count rather than anchoring on ours: our catalog is large and content-heavy for a startup, with more surfaces than most, so our totals are not a useful yardstick for a smaller product. After launch you pay only for the churn, so ongoing cost tracks change, not size.
Can AI localization match human translators?
No, and pretending it does would be the exact dishonesty this pipeline exists to avoid. What it does is get a small team a native-feeling result across many languages at a cost a startup can actually pay.
A small team cannot hire native copywriters for twenty-plus languages, have them hand-write every button, empty state, error, and marketing line, then redo it on every English change. This does not equal that. It is also not the cheap, calqued version that made us want to fix this in the first place. The whole exercise is finding the point between, where the result still feels native and the bill stays payable, and you get there by spending your human hours on the voice guide and glossary rather than on individual strings.
What does it take to localize well with AI?
Set the bar, write the inputs (the note, the voice guide, the glossary, the placeholder and typography rules), prove them out on the languages you can judge without ever hand-fixing a string, seed the vocabulary from real search data, then let two cheap models check each other across the rest, swapping the model per language where it earns its keep. The leverage is almost entirely in those written inputs, and ours are plain Markdown and JSON. We are happy to open-source them if they would help someone building something else.