Panduan membuat portfolio sepenuhnya dwibahasa (EN/ID) — subpath routing dengan next-intl, localization konten per-field di Payload CMS, serta jebakan 404 dan SEO yang saya temui sepanjang jalan.

Saya ingin portfolio saya melakukan dua hal sekaligus: menyajikan konten dalam Bahasa Inggris dan Indonesia, sekaligus membiarkan saya menyunting konten itu tanpa deploy ulang. Itu berarti ada dua jenis "translation" — string UI di dalam kode, dan konten sebenarnya yang tinggal di CMS. Berikut bagaimana semua bagian saling terkait, dan beberapa jebakan yang menghabiskan satu sore saya.
Proyek ini adalah monorepo kecil dengan dua aplikasi yang relevan:
cms/ — Payload adalah aplikasi (Postgres + media di Cloudflare R2) yang menyediakan panel admin dan REST API.v3/ — frontend Next.js 15 (App Router) yang mengambil konten dari API itu.Menyimpan konten di headless CMS berarti situsnya sebagian besar statis/ISR, tapi saya bisa menerbitkan proyek atau tulisan blog baru dari admin tanpa menyentuh kode.
Untuk URL saya memakai next-intl dan subpath routing — /en/... dan /id/.... Konfigurasinya ringkas:
export const routing = defineRouting({
locales: ["en", "id"],
defaultLocale: "en",
localePrefix: "always",
})
Sebuah middleware menangani negosiasi locale dan mengarahkan / ke /en. Seluruh app/ tree dipindah ke bawah segmen dinamis app/[locale]/, dan root layout membaca locale untuk menetapkan <html lang> serta menyediakan messages.
Awalnya saya pakai localePrefix: "as-needed" (supaya Bahasa Inggris tetap di /projects tanpa prefix) tapi beralih ke "always". Alasannya ada di bagian berikutnya — itu membuat 404 berperilaku benar.
String UI ada di messages/en.json dan messages/id.json, dan komponen mengambilnya dengan useTranslations():
const t = useTranslations("nav");
// ...
<Link href="/projects">{t("projects")}</Link>
Payload punya localization kelas satu. Kamu mengaktifkannya sekali:
localization: {
locales: ["en", "id"],
defaultLocale: "en",
fallback: true,
}
lalu tandai masing-masing field localized: true (title, description, body rich-text, dll.). Field non-localized seperti slug, tanggal, dan relasi tetap dibagikan lintas locale — dan itulah yang kamu mau: satu slug, satu set relasi, teks yang diterjemahkan.
Di frontend, setiap fetch tinggal meneruskan locale yang aktif:
const res = await fetch(`${CMS_URL}/api${path}&locale=${locale}`, {
next: { revalidate: 60 },
})
Dengan fallback: true, terjemahan Indonesia yang belum ada akan jatuh ke Bahasa Inggris, jadi situs tidak pernah kosong selagi saya masih menerjemahkan.
Jebakannya: fallback hanya berlaku saat baca. Ketika kamu menulis sebuah locale, field localized yang wajib harus ada untuk locale itu juga. Script seed saya membuat tiap dokumen dalam Bahasa Inggris, lalu menyimpannya ulang dengan locale: "id" untuk field terjemahan — dan error pada field wajib bernama role yang belum saya sertakan di payload Indonesia. Fallback tidak menyelamatkanmu saat menulis; set field localized wajib untuk setiap locale yang kamu sentuh.
Ini si pemakan-waktu sore. Dalam mode gelap, halaman 404 saya berlatar hitam pekat yang mengabaikan toggle tema. Penyebabnya: ketika tidak ada not-found kustom, Next.js menyuntikkan halamannya sendiri dengan style ini:
@media (prefers-color-sceme: dark) {
body { background: #000 }
}Ia berpatokan pada preferensi OS, bukan class tema saya — jadi mengubah situs ke terang tidak berefek apa-apa. Perbaikannya cukup menyediakan not-found.tsx kustom; begitu ada, Next berhenti menyuntikkan default tadi.
Tapi karena root layout berada di bawah app/[locale]/, path yang tidak cocok seperti /random diperlakukan sebagai [locale] = "random", gagal di pengecekan locale pada layout, dan notFound() naik ke root boundary — yang tidak punya layout bertema, jadi kamu kembali ke 404 default Next. Beralih ke localePrefix: "always" plus catch-all (app/[locale]/[...rest]/page.tsx yang memanggil notFound()) membuat setiap path yang tidak cocok jatuh ke locale yang valid dan merender 404 yang bertema dan terlokalisasi. Itu sebabnya saya melepas "as-needed."
Dua locale berarti memberi tahu mesin pencari bagaimana halaman-halaman saling berhubungan. Tiap halaman memancarkan canonical + hreflang alternates:
export function alternates(locale: string, path = "") {
const languages: Record<string, string> = {};
for (const l of routing.locales) languages[l] = `${SITE_URL}/${l}${path}`;
languages["x-default"] = `${SITE_URL}/${routing.defaultLocale}${path}`;
return { canonical: `${SITE_URL}/${locale}${path}`, languages };
}Sitemap mencantumkan tiap halaman sekali beserta alternates bahasanya, dan metadataBase membuat URL Open Graph menjadi absolut. Satu hal lagi yang mudah terlupa selagi situs masih di domain staging: kunci indexing di balik sebuah env flag (robots: { index: false } + Disallow: / di robots.txt) supaya Google tidak mengindeks URL sementara — lalu balik saat rilis.
localePrefix: "always" membuat routing dan 404 jadi terprediksi ketika root layout berada di bawah [locale].fallback bersifat read-only. Field localized yang wajib tetap perlu nilai per locale saat menulis.not-found kustom — yang default mengabaikan temamu.noindex.Tidak ada yang sulit kalau berdiri sendiri; pekerjaannya ada pada bagaimana semuanya berinteraksi. Begitu lapisan-lapisannya rapi, menambah bahasa ketiga tinggal satu entri lagi di array locales dan satu kolom terjemahan lagi.