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
- Nama dengan hash (
booking-717b3OF2.js): isinya gak pernah berubah, jadi kirimCache-Control: public, max-age=31536000, immutable. Kalau terjemahan berubah, hash-nya berubah, nama file berubah, dan browser mengambil yang baru. - Nama tetap (
/locales/ja-JP/booking.jsonlewati18next-http-backend): kirimCache-Control: no-cachedenganETag. Browser selalu bertanya, dan server menjawab304 Not Modifiedkalau belum berubah. - CDN menyimpan salinan di server yang dekat dengan tamu: tamu di Tokyo mengambil kamus Jepang dari server Asia, bukan dari server asal. Karena tiap bahasa punya URL sendiri, cache-nya juga per bahasa. Jangan sajikan bahasa berbeda di URL yang sama lewat
Vary: Accept-Language: cache CDN jadi terpecah per isi header dan hampir gak pernah kena. - JSON dan teks mengecil banyak dengan gzip atau Brotli: kamus ar-EG 1.316 byte jadi 574 byte.
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);
});
- Hasil ukur pelajaran Dynamic Language Switching: kamus
ar-EGyang 404 membuatlanguagejadi"ar-EG"tapiresolvedLanguagejadi"en-US". AmbillangdandirdariresolvedLanguage, supaya teks Inggris gak tampil kanan ke kiri. - i18next 26.4.3 mengulang pemuatan yang boleh diulang sampai 5 kali, dengan jeda 350 ms yang berlipat (350, 700, 1.400, 2.800, 5.600 ms; opsi
maxRetriesdanretryTimeout).i18next-http-backend4.0.2 menandai error 5xx dan jaringan sebagai boleh diulang, 404 sebagai tidak.
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 }));
},
});
- Di i18next 26.4.3,
missingKeyHandlercuma terpanggil kalau key gak ada di semua bahasa rantai.cancelyang hilang dija-JPdiam-diam jatuh keen-UStanpa laporan. Untuk itu, bungkust()denganreturnDetails: truedan laporkan kalauusedLngbeda dengani18next.language(bahasa yang diminta). Jangan bandingkan denganresolvedLanguage: untuk kamusko-KRyang masih kosong,resolvedLanguagesudah jadi"en-US", jadi gak ada yang kelihatan beda. - Jangan pakai
addPathmiliki18next-http-backenddi production: itu menyuruh tiap browser menulis key ke server. - Kirim ke endpoint sendiri atau error tracker, satu laporan per key, supaya ribuan tamu gak membanjiri log.
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
- Tambah
ko-KRdilocalesi18next.config.js, lalunpx i18next-cli extract. Bahasa Korea cuma punya kategoriother, jadinights_otherdantotal_other. - Tambah bahasa tujuan di TMS. DeepL mendukung KO, termasuk glosarium; isi glosarium (Kamar Teras, Homestay Kabut).
- Format dicek: Node 22 menulis harga "IDR 700,000", dan
new Intl.DisplayNames(['ko'], { type: 'language' }).of('ko')memberi "한국어" untuk pemilih bahasa. - Font: pastikan ada font Hangul di urutan
font-family, lalu jalankan screenshot. - Routing dan SEO: awalan
/ko/,hreflang="ko", sitemap, dan daftar locale yang dinegosiasikan server (Accept-Language, Laravellang/ko.json). - CI: tambah
ko-KRdi 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
- Lazy load tanpa mengukur. Untuk kamus kecil, kode pemuat dan bahasa cadangan bisa lebih besar dari kamus yang dihemat.
- Nama file tetap dengan
max-agesetahun. Terjemahan yang diperbaiki gak sampai ke tamu sampai cache-nya habis. Pakai hash, atauno-cacheplus ETag. dirdarilanguage. Kamus Arab gagal, teks Inggris tampil kanan ke kiri. PakairesolvedLanguage.- Mengandalkan
missingKeyHandlersaja. Key yang jatuh ke bahasa cadangan gak pernah dilaporkan. - Polyfill untuk semua orang. 19,7 KB gzip yang gak dipakai browser modern.
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.