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
¿Listo para volar? Obtén una cotización de vuelo chárter personalizada en segundos.
Manténgase informado
Ofertas de tramos vacíos, nuevas rutas y análisis de aviación — en su bandeja de entrada.