Localization sounds like a translation task. In practice, it is a product, SEO, and data-modeling task wearing a translation costume.
I recently localize a small browser game hub into six languages — Simplified Chinese, Traditional Chinese, English, Russian, Japanese, and Korean — with Nuxt and @nuxtjs/i18n.
Here is what actually bit me, and what I would tell anyone doing the same.
The setup
Six locales, with the default language served without a prefix and the rest under a path prefix:
i18n: {
locales: [
{ code: 'zh', language: 'zh-CN', file: 'zh.json' },
{ code: 'zh-TW', language: 'zh-TW', file: 'zh-TW.json' },
{ code: 'en', language: 'en-US', file: 'en.json' },
{ code: 'ru', language: 'ru-RU', file: 'ru.json' },
{ code: 'ja', language: 'ja-JP', file: 'ja.json' },
{ code: 'ko', language: 'ko-KR', file: 'ko.json' },
],
defaultLocale: 'zh',
strategy: 'prefix_except_default',
}
That prefix_except_default strategy is great for SEO, and it also means every internal link has to be built through localePath() so the prefix stays consistent. Any hardcoded /tool/xxx link quietly drops the language.
Lesson 1: A missing key is worse than a missing translation
The bug that started all of this: on the loan calculator, the country tabs showed raw keys instead of labels.
loanCalculator.countries.cn
loanCalculator.countries.jp
loanCalculator.countries.kr
The Japanese, Korean, and Russian locale files were simply missing the entire countries subtree — 32 keys across countries, fields, warnings, and a few more. Every one of those keys rendered as its literal name.
The lesson is not "translate more carefully." It is that a missing key is a visible, ugly bug, while a missing translation is invisible. A raw loanCalculator.countries.jp on screen is far worse than a slightly awkward sentence. So it is worth writing a script that diffs your translation keys and fails when one locale is missing something.
# Compare key sets and report what each locale is missing
python3 - <<'PY'
import json
# load reference and target locale files, flatten keys, diff them...
PY
We caught 32 missing keys this way in one locale alone.
Lesson 2: Fallbacks are a content decision, not just config
The default fallback is "use the default locale." But the default locale was Chinese, and dropping Chinese into a Japanese UI looks like a bug too.
So instead of one global fallback, each locale declares its own chain:
fallbackLocale: {
'zh-TW': ['zh', 'en'], // Traditional Chinese falls back to Simplified
ru: ['en', 'zh'],
ja: ['en', 'zh'],
ko: ['en', 'zh'],
en: ['zh'],
default: ['zh'],
}
The thinking:
- Traditional Chinese users prefer Simplified Chinese over English.
- Japanese / Korean / Russian users prefer English over Chinese.
- English users get Chinese as a last resort.
This is a product decision expressed in config. The rule of thumb: fall back to the closest language the user can still parse, not to whatever the default happens to be.
Lesson 3: Machine translation is dangerous in domain-specific UI
Some of the Japanese, Korean, and Russian loan-calculator strings were machine-translated, and it showed. Some examples that were live on the site:
| Key | Machine output | Correct meaning |
|---|---|---|
principal |
主要 / 교장 (school principal!) | 元金 / 원금 (principal) |
interest |
興味 (curiosity!) | 利息 (interest) |
remaining |
残りの校長 (remaining principal, literally "remaining school principal") | 残りの元金 |
"Principal" and "interest" are fine words — until your translator picks the wrong sense. In finance UIs there is no room for "curiosity" where "interest" was meant.
The lesson: machine translation is a draft, not a deliverable, and domain terms need a glossary. For a calculator, a wrong label is a wrong product.
Lesson 4: Generate Traditional Chinese instead of maintaining it
Two Chinese variants means double the strings to maintain — unless you generate one from the other.
Traditional Chinese is produced from Simplified at runtime using OpenCC-style data: a phrase map first (for Taiwan-idiomatic wording), then a character-level map as a fallback.
export function toHant(text) {
if (!text) return text
const phrase = S2T_PHRASES[text] // whole-sentence map wins
if (phrase) return phrase
let out = ''
for (const ch of text) {
out += S2T[ch] ?? ch // character-level fallback
}
return out
}
// Use it inside templates for inline strings:
hant(locale, '免费在线房贷计算器')
This keeps a single Simplified source of truth and avoids maintaining a nearly-identical second translation. It is not perfect for every sentence, but for UI copy it removes a huge maintenance tax.
Lesson 5: Treat Traditional Chinese as Chinese in logic
Many branches looked like this:
!isChineseLocale(locale) ? englishText : chineseText
Originally this was locale !== 'zh', which sent Traditional Chinese users down the English branch — even though their language is Chinese.
export function isChineseLocale(locale) {
return locale === 'zh' || locale === 'zh-TW'
}
A one-line helper fixed a whole class of "why is this page in English?" bugs. The general point: whenever you branch on "is this Chinese / is this RTL / is this CJK", centralize it in a helper instead of scattering string comparisons.
Lesson 6: Formatting is part of localization
Numbers and currency are not language-neutral. A mortgage amount shown as 1,234.56 is wrong in Japanese yen and Korean won, which conventionally use zero decimals.
const zeroDecimal = currency === 'JPY' || currency === 'KRW'
const digits = zeroDecimal ? 0 : 2
new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
minimumFractionDigits: digits,
maximumFractionDigits: digits,
}).format(amount)
Translating the label but formatting the number wrong still looks broken to a local user.
Lesson 7: Multi-language SEO is its own minefield
With prefix_except_default, useLocaleHead() generates hreflang alternates and canonical links. Two traps I hit:
Trap 1: relative paths leaking into HTML. By default the generated locale file path could expose an absolute server path like /var/www/tools/i18n/locales/ja.json in the markup — which crawlers then try to fetch. Fix:
experimental: {
generatedLocaleFilePathFormat: 'relative',
}
Trap 2: canonical vs. hreflang disagreeing on query strings. Landing links sometimes carry ?from=home. If the canonical points to a clean URL but the hreflang alternate keeps ?from=home, Google sees two conflicting signals and treats pages as "alternate with proper canonical tag" — never indexed as primary. Fix:
experimental: {
alternateLinkCanonicalQueries: true, // strip non-canonical query params
}
The lesson: localization and SEO are the same problem. Getting the language right but the canonical links wrong means the localized page might as well not exist.
Lesson 8: Content in the database needs per-locale fields too
Translations are not only in locale files. Some content — like banner announcements — lives in the database.
Originally that table only had content (Chinese) and contentEn (English). Every non-Chinese locale fell back to English, so Japanese and Korean users never saw a native message.
The fix was straightforward but easy to forget:
ALTER TABLE "Announcement"
ADD COLUMN "contentJa" TEXT,
ADD COLUMN "contentKo" TEXT,
ADD COLUMN "contentRu" TEXT;
…plus a fallback chain in the component:
const textOf = (item) => {
const localized = {
ja: item.contentJa,
ko: item.contentKo,
ru: item.contentRu,
en: item.contentEn,
}[locale.value]
return isChineseLocale(locale.value)
? item.content
: localized || item.contentEn || item.content
}
Any user-generated or CMS content needs the same treatment: per-locale storage plus an explicit fallback order. Locale files alone do not localize a site.
Lesson 9: You cannot "programmatically" open a native select
Small UX reality check. I wanted a banner that guides users to switch to Japanese/Korean/Russian — "open the language selector." But the language switcher is a native/component select, and there is no reliable way to open its dropdown from code.
So the banner does the only honest thing: it tells users where the selector is, then links to the relevant page. Sometimes localization guidance has to be copy, not code.
Takeaways
If I had to compress all of this:
- A missing key is a bug; treat it as one. Diff your locale key sets in CI.
- Design fallback chains deliberately. Fall back to the closest readable language, not the site default.
- Review machine translation for domain terms. Finance, dates, and units break first.
- Generate related languages (like Simplified → Traditional) instead of maintaining two copies.
-
Centralize language-branch helpers (
isChineseLocale, RTL checks, etc.). - Format numbers and currency per locale.
- Wire up hreflang and canonical correctly — localization without indexable URLs is wasted effort.
- Give database content per-locale columns and a fallback chain.
Localization is not a final coat of paint. It touches routing, SEO, the data model, and your product decisions about what a fallback should even mean.
You can poke at the result here:
Browse the localized games hub
If you've localized a site into many languages, I'd love to know how you handled key-diffing and fallbacks — those two saved me the most pain.
Top comments (0)