GMT+8 SG --:-- 0000 X 0000 Y /
← 全部文章← All writing

VOLO engineering diary

Engineering Diary, Day 18: 10 Languages, 224 Routes, and the isZh Massacre — Going Global in 48 Hours

Originally published on flyvolo.ai as part of the VOLO Engineering Diary (2026-03-10), documenting the build of the VOLO private-aviation platform between February and May 2026. When VOLO changed direction in September 2026 the 25 entries were moved here as an archive. The text is unchanged; the products, pages and links it mentions no longer exist. Original byline: VOLO CTO.

The Problem: Why 10 Locales Wasn't "Just Add More JSON Files"

VOLO launched with two languages — English and Chinese — with French and Spanish added later as lightweight overlays. The i18n layer used next-intl, which is the right tool. But the implementation had a fatal shortcut baked into dozens of page files: the isZh pattern.

Instead of using useTranslations() or getTranslations() to fetch locale-specific strings, pages were doing this:

const isZh = locale === 'zh';
 
// Sprinkled across every render function:
<h2>{isZh ? '我们的机队' : 'Our Fleet'}</h2>
<p>{isZh ? '探索200+飞机' : 'Explore 200+ aircraft'}</p>

This works for 2 locales. It fails catastrophically for 10. Every string would need locale === 'zh' ? ... : locale === 'ja' ? ... : locale === 'ko' ? ... — a maintenance nightmare. The anti-pattern was embedded in 81 files across the entire codebase: fleet pages, destination pages, route pages, insights pages, operator pages, airport pages, blog pages, case study pages, booking components, and marketing components.

Going from 4 to 10 locales meant we had to kill isZh first — everywhere, in one shot.

Step 1: Infrastructure — The 10-Locale Plumbing

Before touching any page files, we laid the foundation:

Routing & Config

  • i18n config (i18n/config.ts): Added ja, ko, ar, de, pt, ru to the locale array. Updated locale display names, direction (RTL for Arabic), and date format preferences.
  • Routing (i18n/routing.ts): Updated path patterns for all 10 locales.
  • Middleware (middleware.ts): Added Accept-Language detection for 6 new locales so Japanese visitors landing on / get redirected to /ja.
  • Language Switcher: Redesigned to handle 10 options without overwhelming the UI — flags, native-script labels, grouped by region.
  • Content types: Extended MultilingualText to support 10-locale string objects across routes, destinations, fleet, services, case studies, and empty legs.

6 New Translation Files

Each locale JSON has ~1,131 keys across 26+ namespaces (common, navigation, hero, fleet, booking, destinations, routes, insights, admin, agent, services, FAQ, and more). We created complete translations for Japanese, Korean, Arabic, German, Portuguese, and Russian — not machine-translated placeholders, but properly localized strings respecting each language's conventions.

Arabic required special attention: RTL text direction, proper numeral formatting, and cultural adaptations for aviation terminology that doesn't have direct Arabic equivalents.

Commit: 6eb909a — 61 files, +5,504/-198 lines. The biggest single commit in VOLO's history at that point.

Step 2: The isZh Massacre — 81 Files, One Anti-Pattern

This was the most tedious but most important step. Every occurrence of the isZh pattern had to be replaced with proper t('namespace.key') calls. The refactor touched:

CategoryFiles Refactored
Fleet pages & components12 files
Insights pages18 files
Route pages & components5 files
Destination pages & components5 files
Airport & FBO pages5 files
Blog & case study pages6 files
Operator pages4 files
Booking & marketing components8 files
Empty legs pages3 files
Tools & experience pages5 files
Translation files (10 locales)10 files

The pattern was always the same:

// Before (isZh anti-pattern):
const isZh = locale === 'zh';
<h2>{isZh ? '详细规格' : 'Detailed Specifications'}</h2>
 
// After (next-intl):
const t = await getTranslations('fleet');
<h2>{t('detailedSpecs')}</h2>

Every string that was previously hardcoded in a ternary had to be: (1) given a translation key name, (2) added to all 10 locale JSON files, and (3) replaced with a t() call. For server components, we used getTranslations(). For client components, useTranslations().

The total: 81 files changed, +5,130/-2,222 lines. We added approximately 300 new translation keys to support strings that had been hardcoded. Commit: daf0119.

Step 3: 224 Routes × 10 Languages

Routes present a unique i18n challenge. The route data files contain city name pairs like "New York → London" that need to be rendered in each locale's native script:

  • English: New York → London
  • Chinese: 纽约 → 伦敦
  • Japanese: ニューヨーク → ロンドン
  • Korean: 뉴욕 → 런던
  • Arabic: نيويورك → لندن
  • German: New York → London
  • Portuguese: Nova York → Londres
  • Russian: Нью-Йорк → Лондон

This isn't just translation — it's transliteration. City names in Japanese use katakana (ニューヨーク), Korean uses hangul (뉴욕), Russian uses Cyrillic (Нью-Йорк), and Arabic uses Arabic script (نيويورك). Each of the 224 routes has an origin and destination city, and each city name needs all 10 locale variants.

We updated all 7 route data files to include MultilingualText objects with localized city names for every route. That's 224 routes × 2 cities × 10 locales = 4,480 localized city names.

Commit: 01b8d4c — 7 files, +5,757/-607 lines.

Step 4: Core Web Vitals

While the i18n work was in progress, we also tackled performance issues flagged by Lighthouse and real user metrics:

  • Hero LCP: Added fetchPriority="high" to the hero section's primary content to ensure it paints within the LCP budget. React 19 natively supports fetchPriority — no @ts-expect-error needed.
  • Deferred loading: Non-critical components below the fold (GlobalMapSection, EmptyLegsTeaser, case study cards) now load after the initial paint using dynamic imports with ssr: false or intersection observer triggers.
  • Adaptive preloader: The VOLO preloader now detects connection speed and skips the animation on slow connections, reducing Time to Interactive.
  • Server-side map data: GlobalMapSection previously fetched route coordinates client-side. We moved the data computation to the server component, eliminating a layout shift and reducing JavaScript bundle size.

During this same pass, we expanded the route catalog from ~100 to 224 routes with full bilingual descriptions (en+zh), real ICAO codes, and market-appropriate pricing across 5 new batch files.

Commit: cf8256d — 11 files, +8,258/-68 lines.

Step 5: Agent Mode i18n + Accessibility

The agent-facing UI (e2b.dev-inspired design) had been built entirely in English. With 10 locales now supported, we internationalized every agent component:

  • AgentHero: Terminal animation text, CTA buttons, trust indicators
  • AgentHome: Section headers, feature descriptions, endpoint cards
  • AgentTerminal: Command examples, response text
  • CommissionTiers: Tier names, descriptions, percentage labels
  • WebMCPDocsSection: Documentation text, code examples, integration guides
  • ProtocolStatus, TrustBar, LiveFeed, CodeTabs: All status text and labels

This added 95+ new i18n keys to the agent namespace across all 10 locale files.

Accessibility Fixes

While touching these components, we fixed accessibility issues that had accumulated:

  • QuickQuoteModal: Added role="dialog", aria-modal="true", aria-labelledby, focus trapping, and Escape key handler.
  • ExitIntentPopup: Same modal accessibility pattern, plus aria-live="polite" for dynamic content.
  • AirportAutocomplete: Added role="listbox", aria-activedescendant, keyboard navigation with arrow keys, and screen reader announcements for result counts.
  • Header: Fixed z-index stacking conflicts between the mobile menu, floating CTA, and modals.
  • FloatingCTA: Proper aria-label and contrast ratio for the gold-on-black button.

Commit: 3f57ec7 — 28 files, +1,451/-265 lines.

Step 6: Google Search Console Cleanup

Between the i18n and UI work, we also addressed GSC coverage issues:

  • robots.txt: Removed overly broad blocks for OpenGraph and Twitter image routes that were preventing Google from rendering social previews.
  • Contact canonical: Added a dedicated layout with a canonical URL for the contact page, which GSC flagged as a duplicate.
  • Double-locale redirect: Added a Next.js redirect rule for URLs like /en/en/... that were appearing in GSC as crawl errors — likely caused by malformed internal links that have since been fixed.

Commit: 3d5c681 — 3 files, surgical fix.

The Numbers

MetricBeforeAfter
Supported locales4 (en, zh, fr, es)10 (+ ja, ko, ar, de, pt, ru)
Translation keys per locale~739~1,131
Total translation keys~2,956~11,310
Route count~100224
Localized city name pairs~400 (en+zh+fr+es)4,480 (10 locales)
Pages × locales86 × 4 = 34486 × 10 = 860
isZh occurrences~200+0
Files changed (total)153
Lines added27,184
Lines removed2,705

The Commit Log

Eight commits across two days:

  • 6eb909a — i18n: expand from 4 to 10 locales (61 files, +5,504/-198)
  • a2e076c — i18n: complete translations for ja, ko, de, pt, ru
  • 03c8829 — i18n: complete Arabic (ar) translation for all 30 namespaces
  • cf8256d — perf: Core Web Vitals optimization + expand routes to 224 (11 files, +8,258/-68)
  • 01b8d4c — i18n: add 8-locale city name translations to all 224 routes (7 files, +5,757/-607)
  • daf0119 — i18n: comprehensive refactor — remove isZh anti-pattern (81 files, +5,130/-2,222)
  • 3d5c681 — fix: resolve Google Search Console coverage validation issues
  • 3f57ec7 — ui/ux: comprehensive polish — i18n, accessibility, visual improvements (28 files, +1,451/-265)

Lessons Learned

  • Kill anti-patterns early. The isZh shortcut saved 10 minutes when we had 2 locales and cost us hours when we needed 10. Every hardcoded ternary was a form of technical debt with compound interest.
  • City names are harder than UI strings. Translating a button label is straightforward. Transliterating "São Paulo" into Japanese katakana (サンパウロ) requires cultural knowledge that translation APIs sometimes get wrong.
  • RTL isn't just dir="rtl". Arabic support required reviewing every flex container, padding/margin direction, and text alignment across the site.
  • React 19 + next-intl v4 is a good stack. Server components with getTranslations() mean zero JavaScript bundle cost for static translated strings. Only interactive components pay the client-side i18n tax.

What's Next

  • Translation quality audit — have native speakers review ja, ko, ar, de, pt, ru translations
  • Locale-specific SEO metadata — separate meta descriptions per locale, not just the title
  • RTL layout testing — comprehensive visual QA for Arabic across all 86 pages
  • Hreflang validation — ensure all 10 locale alternates are correctly linked
  • Content localization beyond UI — blog posts, case studies, and insights in more languages