← Back to blog

How We Made VORA Bilingual Without a Heavy Localization Stack

How VORA handles Korean-English bilingual support with paired HTML pages and runtime dictionaries — without a full localization framework. Tradeoffs, sync problems, and migration plans.

by Jay7 min readVORA B.LOG

Here's something nobody warns you about when you build a bilingual app: the translation isn't the hard part. Keeping two versions of every page in sync without losing your mind -- that's the hard part.

VORA supports Korean and English. I didn't use a localization framework. I used paired HTML files and a runtime dictionary. It worked. Then it almost didn't.

🗂️ The Paired File Approach

VORA English Landing Page

For every static page, I created two files:

  • page.html (English)
  • page_ko.html (Korean)

Simple. No build pipeline overhead, no i18n library, clear language-specific URLs for SEO. It felt like the pragmatic choice.

It was. Until the page count grew past "manageable."

😱 The Sync Problem (a.k.a. My Recurring Nightmare)

Every structural update had to be duplicated. Every. Single. One.

Move a button on the English page? Better remember to move it on the Korean page too. Change a link? Both files. Update metadata? Both files. Forget either one and you get:

  • English pages linking to Korean destinations (users love this)
  • Metadata saying "English" on a Korean page (Google loves this)
  • One version three commits ahead of the other (I love this)

The worst part? None of this crashes anything. It's all silent. You only notice when a user tells you something feels weird, or when Google starts indexing the wrong language version.

Here's a real example of what happened. I updated the hreflang tag structure in about.html:

<!-- about.html (English) — UPDATED -->
<link rel="canonical" href="https://vora.vibed-lab.com/about.html" />
<link rel="alternate" hreflang="en" href="https://vora.vibed-lab.com/about.html" />
<link rel="alternate" hreflang="ko" href="https://vora.vibed-lab.com/about_ko.html" />
<link rel="alternate" hreflang="x-default" href="https://vora.vibed-lab.com/about.html" />

But I forgot to update about_ko.html:

<!-- about_ko.html (Korean) — STILL OLD -->
<link rel="canonical" href="https://vora.vibed-lab.com/about.html" />
<link rel="alternate" hreflang="en" href="https://vora.vibed-lab.com/about.html" />
<!-- Missing the ko hreflang pointing to itself! -->
<link rel="alternate" hreflang="x-default" href="https://vora.vibed-lab.com/about.html" />

The Korean page didn't know it existed. Google saw conflicting signals. For three weeks, the Korean "About" page ranked worse and users got sent to the English version. Silent. No errors. Just... slowly worse search visibility on the Korean side.

I only caught it when I started checking SEO metrics by language.

🔤 Runtime Strings for Dynamic UI

For the actual app UI -- the interactive parts -- I didn't duplicate JavaScript logic. That would've been insane.

Instead, I used a small runtime dictionary selected by document context. One behavior path, two sets of strings. This kept the most complex part of the app in sync without the dual-file nightmare.

Here's what this actually looks like in code:

// Single i18n object, loaded once at initialization
this.i18n = {
  ko: {
    recording: '녹음 중',
    waiting: '대기 중',
    paused: '일시정지',
    startRecording: '녹음 시작',
    stopRecording: '녹음 중지',
    recordingStarted: '녹음 시작됨',
    apiKeyNotSet: 'API 키 미설정',
    placeholderQuestion: '질문을 입력하세요...',
    noiseSuppression: '노이즈 억제가',
    enabled: '활성화되었습니다.'
  },
  en: {
    recording: 'Recording',
    waiting: 'Waiting',
    paused: 'Paused',
    startRecording: 'Start Recording',
    stopRecording: 'Stop Recording',
    recordingStarted: 'Recording Started',
    apiKeyNotSet: 'API Key Not Set',
    placeholderQuestion: 'Type your question...',
    noiseSuppression: 'Noise suppression is',
    enabled: 'enabled.'
  }
};
 
// Detect language from document at startup
this.currentLang = document.documentElement.lang === 'en' ? 'en' : 'ko';
 
// Use translation with a simple getter
t(key) {
  return this.i18n[this.currentLang][key] || key;
}
 
// In your UI code:
statusElement.textContent = this.t('recordingStarted');  // Works in both languages

The detection happens once at page load. The HTML file already has <html lang="ko"> or <html lang="en">, so the JavaScript reads that lang attribute and picks the right dictionary. No complexity, no API calls, no extra parsing. The same JavaScript file runs in both app.html and app_ko.html and just... works.

🌐 How Language Detection Works (Without the Magic)

Users need to land on the right page version. There are three patterns I considered:

1. URL-based detection (what I chose)

The simplest: page.html for English, page_ko.html for Korean. Links and routing just... use the right file. No detection needed — the URL is the signal.

<!-- Navigation in about.html -->
<a href="about_ko.html">한국어</a>
 
<!-- Navigation in about_ko.html -->
<a href="about.html">English</a>

Pros: Works immediately, no JavaScript needed, SEO-friendly URLs. Cons: More files to manage.

2. Browser locale detection

Read navigator.language and auto-redirect Korean-speaking visitors to page_ko.html:

const userLang = navigator.language || navigator.userLanguage;
if (userLang.startsWith('ko') && !window.location.pathname.includes('_ko')) {
  window.location.href = window.location.pathname.replace('.html', '_ko.html');
}

Pros: Seamless UX. Cons: Breaks SEO (redirect chains), slower, requires JavaScript.

3. Manual toggle button

Let users switch language on the page itself. No redirect, just swap the dictionary from en to ko.

<button onclick="changeLanguage('ko')">한국어</button>
<button onclick="changeLanguage('en')">English</button>

Pros: Instant, user control. Cons: Doesn't help first-time visitors, doesn't signal language to search engines.

For VORA, I went with URL-based because it's the most honest approach: each language gets its own URL, its own metadata, its own place in Google's index. No redirects, no JavaScript tricks, no assumptions about what the user wants.

🔍 SEO and Language Signaling

VORA English Blog Index

International discoverability needs explicit language mapping between page pairs -- hreflang tags, canonical URLs, the whole deal. (For the full reference, see The Complete SEO Guide. For a real-world cleanup story with commit diffs, see CryptoBacktest's i18n SEO Chronicles.)

The syntax isn't hard. The discipline is. One copy-paste mistake in a hreflang tag and Google silently downgrades your search quality. No error message. No warning. Just fewer clicks, three weeks later, and you're wondering what happened.

Policy pages needed clean language separation -- one Korean version, one English version, each clearly owned. Not a bilingual franken-document that confuses everyone, including the legal team that doesn't exist yet.

🔮 What I'd Do Differently at Scale

Right now the lightweight approach still works. But I can see the ceiling.

At larger scale (more than 20-30 pages), I'd move to a template-driven approach:

The migration plan:

  1. Extract content to YAML or JSON files
# content/about.yml
title:
  en: "About Us"
  ko: "회사 소개"
description:
  en: "VORA is a professional AI meeting assistant..."
  ko: "VORA는 전문 AI 회의 어시스턴트입니다..."
sections:
  mission:
    en: "Our mission is to..."
    ko: "우리의 미션은..."
  1. Use a single HTML template with variable substitution
<!-- template/page.html -->
<html lang="{{ LANG }}">
<head>
  <title>{{ title[LANG] }}</title>
  <meta name="description" content="{{ description[LANG] }}" />
  <link rel="canonical" href="{{ canonical[LANG] }}" />
  <!-- hreflang tags auto-generated -->
</head>
<body>
  <h1>{{ sections.mission[LANG] }}</h1>
</body>
</html>
  1. Build both versions from one source

A simple build script (Node.js, Python, whatever) that:

  • Reads content/about.yml
  • Renders template/page.html twice (once for en, once for ko)
  • Outputs about.html and about_ko.html
  • Verifies that both files have matching structure (no orphan elements)
  1. Add CI checks that fail the deploy if sync drifts
// verify-bilingual-sync.js
const aboutEnDOM = parseHTML(readFile('about.html'));
const aboutKoDOM = parseHTML(readFile('about_ko.html'));
 
const enHeadings = aboutEnDOM.querySelectorAll('h1,h2,h3').length;
const koHeadings = aboutKoDOM.querySelectorAll('h1,h2,h3').length;
 
if (enHeadings !== koHeadings) {
  throw new Error(`Heading count mismatch: EN=${enHeadings}, KO=${koHeadings}`);
}

The goal: Make it physically impossible to update one language without updating the other. Remove the human from the sync loop entirely. The build fails before you can deploy a mismatch.

🎯 The Real Lesson

If you're building bilingual support, the risk isn't bad translations. It's update drift over time. Two files that start identical will slowly, silently diverge -- and you won't notice until something embarrassing happens.

Pick an architecture that matches your current size. But have a plan for when manual sync starts feeling like a second job. Because it will.

2026.02.19

Written by

Jay

Licensed Pharmacist · Senior Researcher

Building production-grade AI tools across medicine, finance, and productivity — without a CS degree. Domain expertise first, code second.

About the author →
ShareX / TwitterLinkedIn