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:

  1. A missing key is a bug; treat it as one. Diff your locale key sets in CI.
  2. Design fallback chains deliberately. Fall back to the closest readable language, not the site default.
  3. Review machine translation for domain terms. Finance, dates, and units break first.
  4. Generate related languages (like Simplified → Traditional) instead of maintaining two copies.
  5. Centralize language-branch helpers (isChineseLocale, RTL checks, etc.).
  6. Format numbers and currency per locale.
  7. Wire up hreflang and canonical correctly — localization without indexable URLs is wasted effort.
  8. 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.