i18n di Production Scale — i18n

i18n di Production Scale Saat aplikasi mendukung 20+ bahasa dengan ribuan translation keys, performance menjadi concern utama. Loading semua terjemahan…

i18n di skala production adalah urusan mengirim kamus yang tepat ke tamu yang tepat, cepat, dan tetap jalan saat ada yang gagal: tiap tamu cuma mengunduh bahasanya, file di-cache lama di CDN, ada cadangan kalau satu bundle gagal dimuat, server dan browser sepakat soal bahasa, dan key yang hilang terlaporkan. Bayangin buku panduan Homestay Kabut dalam empat bahasa. Tamu dari Tokyo cukup diberi buku Jepang, bukan keempatnya. Bukunya disimpan di rak resepsionis terdekat (CDN), sampulnya bertuliskan nomor edisi (hash isi) supaya edisi lama gak tertukar, dan resepsionis mencatat tiap kali ada tamu yang menanyakan halaman yang gak ada (monitoring).

🧪 Tebakan cepat: Kartu kita punya empat kamus kecil. Kalau tiap bahasa dimuat terpisah dengan import() (lazy), apakah tamu Jepang mengunduh lebih sedikit byte dibanding kalau keempat kamus dibundel jadi satu? Jawabannya ada di bagian "Bundle per bahasa: diukur, bukan ditebak".

Bundle per bahasa: diukur, bukan ditebak

// src/i18n.js: id-ID (bahasa sumber) ikut bundle utama, bahasa lain dimuat saat dipilih.
import resourcesToBackend from 'i18next-resources-to-backend';
import idID from './locales/id-ID/booking.json';

i18next.use(resourcesToBackend((lng, ns) => import(`./locales/${lng}/${ns}.json`))).init({
  lng: document.documentElement.lang, // 'ja-JP', ditulis server
  fallbackLng: ['en-US', 'id-ID'],
  load: 'currentOnly',            // minta 'ja-JP' saja, bukan juga 'ja'
  partialBundledLanguages: true,
  resources: { 'id-ID': { booking: idID } },
});

Kami membangun kartu ini dengan Vite 8.0.8. Tiap kamus jadi chunk sendiri dengan hash di namanya: id-ID 518 byte (gzip 330), en-US 664 (364), ja-JP 592 (437), ar-EG 1.316 (574). Lalu kami catat bahasa apa saja yang diminta i18next 26.4.3 untuk tamu ja-JP: ja-JP, en-US, dan id-ID. Bahasa di fallbackLng ikut dimuat supaya t() bisa jatuh ke sana. Yang diunduh tamu Jepang:

Cara Yang diunduh Byte gzip
Satu bundle, keempat kamus di dalamnya index-Bn3ONCFH.js 2.888 1.357
Lazy semua bahasa entry 2.731 + ja-JP 592 + en-US 664 + id-ID 518 4.505 2.448
Lazy, id-ID di bundle utama (kode di atas) entry 3.446 + ja-JP 592 + en-US 664 4.702 2.492

Jadi untuk 10 key, tamu Jepang malah mengunduh lebih banyak: begitu ada import(), Vite menambah kode pemuat sekitar 2 KB, dan bahasa cadangan ikut diunduh. Itu jawaban tebakannya. Hitungannya berbalik saat bahasanya banyak dan kamusnya besar: dengan 20 bahasa dan 2.000 key per bahasa (perkiraan linear dari ukuran chunk kita, sekitar 150 KB per bahasa), satu bundle sekitar 3 MB, sedangkan tamu Jepang dengan cara terakhir mengunduh sekitar 400 KB. Pecah juga per namespace (booking, admin), dan jaga rantai fallbackLng tetap pendek: tiap bahasa cadangan adalah unduhan tambahan. Ukur di build-mu sendiri.

Cache dengan hash isi, lewat CDN

Kalau satu bundle gagal dimuat

i18next.on('failedLoading', (lng, ns, msg) => report('i18n_load_failed', { lng, ns, msg }));
i18next.on('languageChanged', () => {
  const used = i18next.resolvedLanguage;          // bahasa yang benar-benar dipakai t()
  document.documentElement.lang = used;
  document.documentElement.dir = i18next.dir(used);
});

Server dan browser sepakat soal bahasa

Halaman yang dirender server menulis <html lang="ja-JP"> dan teks Jepang. Kalau browser memulai i18next dengan bahasa lain, atau menunggu kamus dimuat dulu, render pertama React beda dengan HTML server: di build production itu "Minified React error #418" (pelajaran Server-side i18n).

// Browser: pakai bahasa dan kamus yang sama dengan server, tanpa menunggu.
const lng = document.documentElement.lang;                    // 'ja-JP', ditulis server
const booking = JSON.parse(document.getElementById('i18n-booking').textContent); // kamus yang disisipkan server
i18next.init({ lng, fallbackLng: ['en-US', 'id-ID'], resources: { [lng]: { booking } }, partialBundledLanguages: true, initAsync: false });

initAsync: false (di i18next 26, pengganti initImmediate) membuat init selesai sebelum render pertama, karena kamusnya sudah ada.

Memantau key yang hilang di production

const seen = new Set();
i18next.init({
  // ...
  saveMissing: true,
  missingKeyHandler: (lngs, ns, key) => {
    const id = `${i18next.language}:${ns}:${key}`;   // bahasa yang diminta
    if (seen.has(id)) return;                         // satu laporan per key per sesi
    seen.add(id);
    navigator.sendBeacon('/api/i18n-missing', JSON.stringify({ id, path: location.pathname }));
  },
});

Dasbor cakupan locale dan RTL

Dari file yang sama dengan CI (pelajaran Translation Workflow), hitung per locale: key yang diterjemahkan, key yang jatuh ke fallbackLng minggu ini (dari laporan di atas), dan halaman yang punya screenshot RTL yang lolos. Contoh kartu kita: en-US 12 dari 12, ja-JP 10 dari 10, ar-EG 20 dari 20, plus screenshot ar-EG (dir="rtl") yang lolos. Pasang batas: misalnya bahasa di bawah 95% gak boleh dirilis.

Menambah bahasa kelima: ko-KR

  1. Tambah ko-KR di locales i18next.config.js, lalu npx i18next-cli extract. Bahasa Korea cuma punya kategori other, jadi nights_other dan total_other.
  2. Tambah bahasa tujuan di TMS. DeepL mendukung KO, termasuk glosarium; isi glosarium (Kamar Teras, Homestay Kabut).
  3. Format dicek: Node 22 menulis harga "IDR 700,000", dan new Intl.DisplayNames(['ko'], { type: 'language' }).of('ko') memberi "한국어" untuk pemilih bahasa.
  4. Font: pastikan ada font Hangul di urutan font-family, lalu jalankan screenshot.
  5. Routing dan SEO: awalan /ko/, hreflang="ko", sitemap, dan daftar locale yang dinegosiasikan server (Accept-Language, Laravel lang/ko.json).
  6. CI: tambah ko-KR di pemeriksaan, pseudo-locale, dan matriks screenshot. Rilis setelah cakupannya lewat batas.

Polyfill Intl: cuma kalau perlu

Data web-features 3.42.0 (Oktober 2026): Intl.PluralRules (Baseline sejak 2019), Intl.Locale (2020), dan Intl.DisplayNames (2021) sudah "widely available". Yang baru: Intl.DurationFormat (Baseline Maret 2025) dan info locale seperti getTextInfo() (Juli 2026, Firefox 153). Temporal belum Baseline (belum ada di Safari). Kartu kita gak butuh polyfill. Kalau tetap memasangnya: @formatjs/intl-pluralrules 6.3.15 yang dipaksa plus data empat locale menambah 70,4 KB (gzip 19,7 KB) di build Vite kami, sekitar 23 kali ukuran keempat chunk kamus (3.090 byte). Muat dengan shouldPolyfill() dan import(), jadi browser modern gak mengunduh apa pun.

⚠️ Jebakan yang sering ketemu

TL;DR: Muat kamus per bahasa dan namespace dengan import() atau i18next-http-backend, tapi ukur dulu: di build Vite 8.0.8 kami, kamus kartu cuma 518 sampai 1.316 byte per bahasa, kode pemuat Vite sekitar 2 KB, dan i18next juga mengunduh bahasa di fallbackLng, jadi tamu Jepang mengunduh 4.505 byte lewat lazy dibanding 2.888 lewat satu bundle; lazy baru menang saat bahasanya banyak dan kamusnya besar. Nama file dengan hash isi dikirim dengan max-age=31536000, immutable; nama tetap dengan no-cache plus ETag. CDN menyimpan kamus dekat dengan tamu, satu URL per bahasa, tanpa Vary: Accept-Language. Kalau bundle gagal, i18next mengulang (5 kali, mulai 350 ms) untuk error 5xx dan jaringan, lalu jatuh ke fallbackLng: ambil lang dan dir dari resolvedLanguage. Server menulis lang dan menyisipkan kamusnya; browser memulai dengan bahasa dan kamus yang sama dan initAsync: false. missingKeyHandler cuma melapor kalau key hilang di semua bahasa; laporkan juga usedLng yang beda dari language lewat returnDetails, satu per key. Bahasa baru (ko-KR) adalah checklist: config, TMS dan glosarium, format, font, routing dan SEO, CI. Polyfill Intl hampir gak dibutuhkan di 2026; kalau perlu, muat bersyarat.

Yang akan kamu pelajari