

1/1/1970
Cara Aku Mengimplementasikan i18n ke 20 Bahasa dalam 3 Hari
Halo! Aku baru saja menyelesaikan tugas super besar: menerjemahkan Foony ke 20 bahasa yang berbeda. Proyek ini lumayan gila karena hampir semua file di codebase harus kusentuh, tapi semuanya berhasil beres dalam 3 hari saja.
Di bawah ini aku akan ceritakan bagaimana aku melakukannya, angka-angka detail di balik perubahan ini, dan kenapa aku memutuskan untuk bikin library terjemahan sendiri (lagi) daripada pakai standar industri.
Kenapa bukan i18next?
Waktu pertama kali kepikiran buat nambah terjemahan, aku langsung melirik standar industrinya: i18next dan react-i18next.
Tapi pada akhirnya aku memutuskan untuk mengoptimalkan kemudahan dirawat oleh AI. i18next itu kuat banget, tapi variasi API-nya bisa bikin LLM berhalusinasi atau menulis kode yang tidak konsisten. Dengan membatasi library ke t() dan interpolate() yang simpel, aku bisa memastikan lebih dari 10 agent paralel bisa menulis kode yang 100% type-safe dengan hampir tanpa intervensi manusia.
Aku juga agak waswas bergantung ke ekosistem besar yang suatu saat bisa saja bikin breaking changes. Setelah pernah "terbakar" migrasi menyakitkan seperti React Router v5 dan MUI v4 → v5, aku tahu betul kalau kebiasaan sering memutus kompatibilitas mundur itu terlalu umum di dunia JavaScript. Biaya menambah fitur pluralization nanti jauh lebih kecil dibanding memigrasi manual 139k baris kode sekarang.
Aku ingin sesuatu yang super sederhana, sangat ringan, dan benar-benar pas dengan kebutuhan timku.
Jadi aku buat sendiri.
Aku membangun subset kecil 3 KB yang sengaja dibatasi dan didesain khusus untuk memungkinkan refactoring otomatis oleh AI dengan akurasi tinggi. Ini memungkinkan aku bertindak sebagai satu engineer yang menyelesaikan beban kerja tim 5 orang selama 3 minggu hanya dalam 3 hari.
Implementasi Kustom
Aku membuat library i18n minimal yang ukurannya sekitar 3 KB gzipped. Library ini mengekspose dua fungsi utama: getTranslation() untuk konteks non-React dan hook useTranslation() untuk komponen.
Keduanya mengembalikan t() untuk penggantian string sederhana dan interpolate() ketika aku perlu menyisipkan komponen React ke dalam string terjemahan (seperti link atau ikon). Dua-duanya mendukung penggantian variabel, misalnya "Hello {{thing}}", {thing: 'World'}.
Ini fungsi inti t():
export function t(key: TranslationKeys, values?: Record<string, string | number>, locale?: SupportedLocale): string {
let namespace: string = '';
let translationKey: string = key;
// Cek apakah key mengandung '/' - ini menandakan namespace
const slashIndex = key.indexOf('/');
if (slashIndex !== -1) {
const parts = key.split('/');
namespace = parts.slice(0, -1).join('/');
translationKey = parts[parts.length - 1];
}
const targetLocale = locale ?? currentLocale;
const text = getTranslationValue(targetLocale, namespace, translationKey);
if (values) {
return interpolateString(text, values);
}
return text;
}
Dan hook React-nya:
export function useTranslation() {
const [language] = useLanguage();
return useMemo(() => ({
t: (key: TranslationKeys, values?: Record<string, string | number>) =>
t(key, values, language),
interpolate: (key: TranslationKeys, components: Record<string, ReactNode>) =>
interpolate(key, components, language),
}), [language, version]);
}
Inti seluruh library ini cuma sekitar 580 baris kode. Library ini menangani:
- Lazy-loading file terjemahan supaya kita tidak mengirim semua 20 bahasa ke setiap user.
- Code-splitting terjemahan berdasarkan "namespace" (misalnya
common,misc,games/{gameId}). - Locale "debug" yang menampilkan key mentah supaya aku bisa memastikan semua sudah terhubung dengan benar.
Supaya sistem ini tetap mudah dirawat, aku juga menambahkan dokumentasi lengkap di shared/src/i18n/README.md, mencakup semuanya dari struktur file sampai contoh pemakaian di client dan server. Karena aku tidak memakai library standar, referensi ini penting banget untuk onboarding anggota tim baru (atau untuk mengingatkan diriku di masa depan, atau LLM, bagaimana sistem ini bekerja).
Dalam Angka
Biar kamu bisa kebayang skala update ini, berikut yang berubah di codebase:
- 20 bahasa didukung (plus satu locale debug untuk dev).
- 360 file locale dibuat.
- 139.031 baris kode terjemahan.
- 3.938 pemanggilan
t()ditambahkan di seluruh client. - 728 file source dimodifikasi.
- 18 file source bahasa Inggris yang jadi sumber kebenaran (16 game + common + misc).
Mengorkestrasi dengan Agent
Kalau dikerjakan manual, ini mungkin akan makan waktu berbulan-bulan dengan pekerjaan mekanis yang bikin bosan. Sebagai gantinya, aku mengorkestrasi lebih dari selusin agent Cursor sekaligus untuk mengerjakan bagian beratnya.
Aku mulai dengan memecah codebase ke dalam "section" berdasarkan folder. Setiap game di Foony punya folder dan namespace terjemahannya sendiri. Ini menjaga ukuran initial load tetap kecil karena kamu cuma memuat terjemahan untuk game yang sedang kamu mainkan.
Aku menjalankan beberapa agent Cursor secara paralel. Aku memberi tiap agent satu section khusus, misalnya "ubah game Catur supaya pakai terjemahan," dan agent itu akan berjalan file demi file, mencari string yang tampil ke user dan menggantinya dengan t('games/chess/some.key').
Lalu agent tersebut menambahkan key itu ke file locale bahasa Inggris yang tepat dengan komentar JSDoc yang menjelaskan "apa" dan "di mana" string itu dipakai. Context ini penting saat membuat terjemahan untuk bahasa lain, karena membantu LLM memahami apakah "Save" artinya "Simpan Konfigurasi Game" atau "Simpan Gambar Draw & Guess Kamu".
Quality Control
Aku meninjau cepat semua kode yang dihasilkan. Agent-agent ini hasilnya lumayan bagus, tapi sesekali tetap bikin kesalahan, misalnya menaruh hook useTranslation setelah sebuah return awal.
Terjemahan yang strongly-typed sangat membantu. Ini memastikan semua terjemahan di setiap locale punya semua key yang benar (dan tidak punya key yang salah). Ini juga memastikan pemanggilan t() dan interpolate() memakai string terjemahan yang benar-benar ada.
Sistem tipe mengekstrak semua kemungkinan key terjemahan dari file sumber bahasa Inggris:
/**
* Mengekstrak semua path yang mungkin dari tipe object bersarang, dan membuat key dengan notasi titik.
* Contoh: {a: string, b: {c: string, d: {e: string}}} → 'a' | 'b.c' | 'b.d.e'
*/
type ExtractPaths<T, Prefix extends string = ''> = T extends string
? Prefix extends '' ? never : Prefix
: T extends object
? {
[K in keyof T]: K extends string | number
? T[K] extends string
? Prefix extends '' ? `${K}` : `${Prefix}.${K}`
: ExtractPaths<T[K], Prefix extends '' ? `${K}` : `${Prefix}.${K}`>
: never
}[keyof T]
: never;
export type TranslationKeys =
| ExtractPaths<typeof import('./locales/en/index').default>
| `misc/${ExtractPaths<typeof import('./locales/en/misc').default>}`
| `games/chess/${ExtractPaths<typeof import('./locales/en/games/chess').default>}`
| `games/pool/${ExtractPaths<typeof import('./locales/en/games/pool').default>}`
// ... dan seterusnya untuk semua game
Ini memberi autocomplete TypeScript yang sempurna, dan typo sekecil apa pun di key terjemahan akan ketahuan saat compile. Agent jadi tidak bisa bikin kesalahan seperti t('games/ches/name') karena TypeScript langsung menandainya.
Lokalisasi
Setelah semua string bahasa Inggris dikonversi, aku memecah lagi sisa pekerjaan locale. Aku membuat tiap agent bertanggung jawab mengubah satu file locale bahasa Inggris ke satu bahasa tertentu.
Misalnya, aku memberi agent prompt seperti ini:
Please ensure that ar/games/dinomight.ts has all the translations from en/games/dinomight.ts.
Use `export const account: DinomightTranslations = {`.
Iterate until there are no more type errors for your translation file (if you see errors for other files, ignore them--you are running in parallel with other agents that are responsible for those other files).
Your translations must be excellent and correct for the jsdoc context provided in en.
You must do this manually and without writing "helper" scripts, and with no shortcuts.
Aku sempat kepikiran menyuruh Cursor membuat script yang mengirim tiap file ini ke LLM dan membiarkan LLM yang menghasilkan terjemahannya, tapi aku ingin sedikit menghemat biaya LLM. Memakai script untuk hanya mengupdate terjemahan yang masih kosong ternyata pendekatan yang lebih bagus, dan kemungkinan besar nanti aku akan pakai solusi serupa lagi. Aku ingin bisa melacak string mana yang butuh update / terjemahan, tapi sekaligus tetap menjaga semuanya sederhana. Mungkin nanti pekerjaan terjemahan akan kupindah ke database atau semacamnya.
Aku juga menambahkan locale "debug" yang hanya tersedia di environment development. Ini memungkinkan aku melihat semua string yang sudah diganti untuk memastikan semuanya bekerja (plus menurutku ini keren). Saat kamu memakai locale debug, t() akan mengembalikan key yang dibungkus tanda kurung:
if (targetLocale === 'debug') {
return `⟦${key}⟧`;
}
Jadi alih-alih melihat "Welcome to Foony!", kamu akan melihat ⟦welcome⟧, sehingga mudah menemukan terjemahan yang belum terisi.
Terakhir, agent lain mengimplementasikan routing /{locale}/** supaya URL seperti /ja/games/chess diarahkan ke bahasa yang benar (dalam contoh ini bahasa Jepang).
Menerjemahkan Blog
Menerjemahkan string UI itu satu hal, tapi bagaimana dengan postingan blog? Aku tidak mau harus mengatur dan mengelola lebih banyak agent lagi hanya untuk menerjemahkan semua postingan blog.
Masalah ini aku selesaikan dengan menyuruh seorang agent membuat script (scripts/src/generateBlogTranslations.ts) yang mengotomatisasi seluruh proses.
Cara kerjanya seperti ini:
- Script memindai direktori
client/src/posts/enuntuk mencari file MDX berbahasa Inggris. - Script mengecek terjemahan yang belum ada di folder locale lain (misalnya
posts/ja,posts/es). - Kalau ada terjemahan yang hilang, script akan membaca konten bahasa Inggris dan mengirimkannya ke Gemini 3 Pro Preview dengan prompt khusus untuk menerjemahkan konten sambil mempertahankan format Markdown.
- Lalu script menyimpan file baru ke lokasi yang tepat.
Di frontend, aku menggunakan import.meta.glob untuk mengimpor semua file MDX ini secara dinamis. Komponen PostPage kemudian cukup mengecek locale user saat ini dan melakukan lazy-load file MDX yang sesuai. Kalau terjemahan belum ada (karena script belum dijalankan), halaman akan dengan mulus fallback ke bahasa Inggris.
Penutup
Pada titik ini, aku punya situs yang berfungsi penuh dan sudah diterjemahkan ke semua 20 locale!
Tiga hari ini memang agak gila, tapi hasilnya adalah situs yang sudah terlokalisasi penuh dan terasa (hampir) native untuk pengguna di seluruh dunia. Dengan membangun library kustom yang ringan dan memanfaatkan agent AI untuk pekerjaan refactoring yang membosankan, aku berhasil melakukan sesuatu yang mungkin terasa mustahil setahun yang lalu: full i18n dalam 3 hari untuk sebuah website kompleks oleh 1 engineer saja.
Masa depan pemrograman bukan lagi soal seberapa cepat kamu menulis kode. Yang penting adalah bagaimana kamu mengorkestrasi agent AI dan punya pemahaman domain yang dalam untuk memverifikasi hasil kerja mereka.