We run a small web app that does Korean Saju — four-pillars fortune reading — plus compatibility and daily fortunes. It started as a Korean-only product, because that's where the tradition lives. Then the analytics came in: most of our visitors were not in Korea. The US was our largest traffic source, with Japan, Taiwan, and Mexico behind it. They were landing on a fully Korean interface and bouncing.
So we localized it into nine languages: Korean, English, Japanese, Simplified Chinese, Traditional Chinese, Spanish, French, Vietnamese, and Indonesian. This post is the engineering retrospective — not the "i18n is important" pep talk, but the specific decisions that turned out to matter, and the mistakes that cost us conversions while we figured them out.
Lesson 1: Language detection is one branch, guarded by another
Our original language resolution was a single line, and it encoded a fatal assumption:
const lang = localStorage.getItem('app_lang') || 'ko';
First-time visitor from California → no saved preference → Korean UI. We had a language switcher with all nine languages, but it lived in a menu. New visitors don't excavate menus on a page they can't read; they leave.
The fix is small, but the shape of the fix matters more than the code:
let lang = localStorage.getItem('app_lang');
if (!lang) {
const nav = (navigator.language || 'en').toLowerCase();
if (nav.startsWith('ko')) lang = 'ko';
else if (nav.startsWith('zh')) lang = /^zh-(tw|hk|mo|hant)/.test(nav) ? 'zh-tw' : 'zh-cn';
else {
const map = { ja: 'ja', es: 'es', fr: 'fr', vi: 'vi', id: 'id' };
lang = map[nav.slice(0, 2)] || 'en';
}
}
The two design rules we'd defend in any codebase:
- Auto-detection only fires when there is no saved choice. A user who explicitly picked a language must never be "helpfully" overridden — not on a new session, not after you ship a new detection heuristic. The saved preference is senior.
- The incumbent audience must see zero diff. Our existing users were Korean. Because the new branch only runs for visitors with no stored preference, a returning Korean user's experience was byte-for-byte unchanged. When you retrofit i18n onto a live product, structuring the change so the current audience cannot be affected is what lets you ship it without a week of regression paranoia.
One more practical detail: we added a ?lang= query override that takes precedence over everything. It costs three lines and it's how you test eight languages you don't speak without fighting your own browser's locale (more on that in Lesson 5).
Lesson 2: zh is not a language, it's a script decision
Our first pass routed every zh-* visitor to one Chinese build. That's wrong in a way that's invisible to non-readers and glaring to actual users: a visitor from Taiwan expects Traditional characters (繁體), a visitor from the mainland or Singapore expects Simplified (简体). Serving the wrong script reads somewhere between "quaint" and "broken."
We maintained the Traditional translation as the source and generated the Simplified variant mechanically — OpenCC's traditional-to-simplified conversion is the standard tool and it's reliable for UI strings. Routing: zh-TW, zh-HK, zh-MO, and anything tagged Hant → Traditional; zh-CN, zh-SG, Hans, and bare zh → Simplified. Two builds, one source of truth, no drift.
The same "one language tag, two realities" trap exists elsewhere (pt-PT vs pt-BR, es-ES vs Latin American Spanish), but Chinese is the case where getting it wrong changes the writing system, not just word choice.
Lesson 3: Localization is a funnel property, not a page property
This is the expensive lesson. Our main app is a single-page application with a proper i18n dictionary — every string keyed, all nine languages present. We translated it, checked the homepage in each language, and called it done.
The checkout page was a separate HTML document. Different file, different scripts, no access to the SPA's dictionary. It had never been translated. So the funnel for a US visitor was: localized homepage → localized reading → tap "unlock full reading" → wall of Korean payment UI → gone. Our funnel metrics showed people reaching checkout and converting at zero, and we initially suspected the payment integration itself. We walked the live checkout end to end with a headless browser first — the payment window opened fine. The integration was never broken. The language was.
What we actually changed on the checkout document:
-
A static-label table, applied via
textContent. The page's fixed strings (form labels, button text, trust badges) get swapped from a small per-language table on load. We deliberately usedtextContentrather thaninnerHTML— translated strings are still untrusted-ish content flowing into the DOM, and thetextContentpath is immune to a whole class of injection accidents. The Korean path runs no swap at all: zero mutation for the incumbent audience, same principle as Lesson 1. -
Currency framing. Prices are charged in KRW, and pretending otherwise would be dishonest. But
₩50,000is unparseable to most of the world. For non-Korean locales we render the KRW amount with an approximate USD conversion alongside —50,000원 (≈$36.23)— clearly marked as approximate. Comprehension, not conversion-rate cosplay. - Payment method ordering by locale. Korean visitors see the domestic PG (card/bank rails that only accept Korean-issued cards) first. Everyone else sees the international option first, visually emphasized, with the domestic option de-emphasized below. Same options for everyone — the default prominence follows what can actually work for that user. Showing a US visitor a payment button their card will be rejected by, above the one that works, is a self-inflicted wound.
The portable rule: if your funnel spans more than one document, every document needs its own localization pass, and your "is it translated?" checklist must follow the money path, not the page list. A translated app with an untranslated checkout converts exactly like an untranslated app.
Lesson 4: Domain terminology — when the word itself is in another script
Generic i18n advice assumes you're translating UI chrome: "Submit," "Settings," "Log out." Our domain vocabulary is centuries-old terminology written in hanja (Chinese characters): 사주 (四柱, "four pillars"), 오행 (五行, "five elements"), the ten Heavenly Stems and twelve Earthly Branches. These words don't have casual equivalents, and each target language has a different correct relationship to them:
- Japanese and both Chinese variants already use these characters natively. The right move is to keep the hanja (四柱, 五行) — translating them into explanatory phrases would be like translating "HTTP" for a developer audience. For Chinese there's a subtlety: the Simplified build needs the simplified character forms, which the OpenCC pass handles along with everything else.
- Latin-script languages (English, Spanish, French, Vietnamese romanization contexts, Indonesian) get the romanized term once with a gloss — "Saju (four-pillars reading)" — and the plain-language term thereafter. Vietnamese is an interesting middle case: the tradition exists there under its own established names, so some terms have native equivalents rather than glosses.
- Korean keeps the hangul term with hanja in parentheses where ambiguity matters, which is standard practice in the genre.
The engineering consequence: we built a per-language glossary table for domain terms, separate from the UI string files, and treated it as the single source of truth. Without it, every translated paragraph improvises its own rendering of 오행 — we found "five elements," "Five Phases," and a phonetic "ohaeng" coexisting in early drafts. Users notice that kind of drift long before they can articulate it. If your product has real domain vocabulary, split the glossary from the UI strings and make every translation pass consume it.
Lesson 5: You cannot QA a language you can't read — but you can QA the system
Honest constraint: we don't read Vietnamese or Indonesian. What we can verify mechanically:
- Every key exists in every language — a script diffs the dictionaries and fails the build on missing keys, so no language silently falls back mid-sentence.
- Layout survives the longest string. German is the famous offender, but our French and Indonesian strings also ran 30–40% longer than Korean. Headless-browser screenshots per language catch truncation and button overflow without reading a word.
-
The funnel works in each locale:
?lang=xxoverride → land → start a reading → reach checkout → payment window opens. Scripted end to end. - Your own locale is a blindfold. After we shipped the checkout translation, it looked unchanged to us — because we were testing from Korea, and the Korean path correctly mutates nothing. The fix existed; we just couldn't see it from where we sat. Test from the target locale via the override, always.
What we couldn't verify mechanically — register, tone, whether a fortune reading sounds like a fortune reading in Spanish — we accepted as a known risk and an open channel for native-speaker corrections, several of which arrived and were right.
The shortlist
- Auto-detect from
navigator.language, but only when no explicit choice exists; never override a saved preference; design so the incumbent audience sees zero diff. - Split
zhby script (OpenCC for mechanical Traditional→Simplified), and route region/script subtags deliberately. - Localize the funnel, not the page list — separate documents (checkout!) need their own pass; show approximate familiar-currency amounts; order payment methods by what can actually succeed for that user.
- Maintain a domain-term glossary per language, separate from UI strings; keep hanja where the audience reads it, gloss-then-romanize where it doesn't.
- QA the system — key completeness, layout under longest strings, end-to-end funnel per locale via a
?lang=override — and admit what you can't review.
The result of all of this isn't a localization showcase; it's just an app that no longer throws away the majority of its own traffic at the door. If you're curious what a 1,000-year-old Korean divination system looks like served in nine languages, it lives at sajuapp.app — and the checkout page, finally, is in your language.
Top comments (0)