Engineering Diary, Day 18: 10 Languages, 224 Routes, and the isZh Massacre — Going Global in 48 Hours
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
MultilingualTextto 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:
| Category | Files Refactored |
|---|---|
| Fleet pages & components | 12 files |
| Insights pages | 18 files |
| Route pages & components | 5 files |
| Destination pages & components | 5 files |
| Airport & FBO pages | 5 files |
| Blog & case study pages | 6 files |
| Operator pages | 4 files |
| Booking & marketing components | 8 files |
| Empty legs pages | 3 files |
| Tools & experience pages | 5 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 supportsfetchPriority— no@ts-expect-errorneeded. - Deferred loading: Non-critical components below the fold (GlobalMapSection, EmptyLegsTeaser, case study cards) now load after the initial paint using dynamic imports with
ssr: falseor 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-labeland 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
| Metric | Before | After |
|---|---|---|
| Supported locales | 4 (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 | ~100 | 224 |
| Localized city name pairs | ~400 (en+zh+fr+es) | 4,480 (10 locales) |
| Pages × locales | 86 × 4 = 344 | 86 × 10 = 860 |
| isZh occurrences | ~200+ | 0 |
| Files changed (total) | — | 153 |
| Lines added | — | 27,184 |
| Lines removed | — | 2,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, ru03c8829— i18n: complete Arabic (ar) translation for all 30 namespacescf8256d— 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 issues3f57ec7— 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
هل أنت مستعد للطيران؟ احصل على عرض سعر تأجير مخصص في ثوانٍ.
ابقَ على اطلاع
عروض الأرجل الفارغة، مسارات جديدة، ورؤى الطيران — مباشرة إلى بريدك.