SEO Teknis Dwibahasa: hreflang untuk Indonesia dan Inggris

Foto oleh Internet Archive Book Images via Wikimedia Commons (No restrictions)
Tidak. Panduan canonicalization Google menyebutkan bahwa ketika hreflang dipakai, tentukan canonical dalam bahasa yang sama, atau bahasa pengganti terdekat jika tidak ada. Canonical di /id/ yang menunjuk ke /en/ berarti meminta Google mengindeks URL Inggris, sehingga halaman Indonesia berhenti bersaing sama sekali. Bentuk paling aman adalah canonical yang menunjuk dirinya sendiri di setiap locale, dibangun oleh helper yang sama dengan yang dipakai sitemap.
Keduanya wajib menurut Google. Setiap versi bahasa harus mencantumkan dirinya sendiri sekaligus semua versi lainnya, dan jika dua halaman tidak saling menunjuk, tag-nya diabaikan, bukan sekadar diturunkan bobotnya. Cara paling andal adalah satu fungsi yang hanya menerima path dan mengembalikan map alternates yang sama untuk setiap locale, supaya tautan satu arah tidak bisa muncul di kode.
Google menggambarkan x-default sebagai fallback untuk pengguna yang setelan bahasanya tidak cocok dengan versi lokal mana pun. Di situs dua bahasa, ia biasanya menunjuk ke default locale, yang di sini adalah URL Inggris berprefix. Yang lebih penting daripada pilihannya adalah konsistensi: setiap tempat anotasi itu dipancarkan harus menyebut target yang sama, dan target itu harus selesai tanpa redirect.
Karena ia mewarisi target query dari halaman Inggris asalnya, dan itu sering bukan pertanyaan yang benar-benar diketik developer Indonesia. Output mesin juga menerjemahkan kosakata teknis yang justru dibiarkan tetap Inggris, seperti deploy, error, dan rate limit, sehingga halamannya tidak memuat string yang dicari orang. Menulis judul Indonesia lebih dulu, untuk query Indonesia, mengubah apa yang diperebutkan halaman itu.
Baca hasil build, bukan source-nya, untuk kedua locale dari URL yang sama. Grep HTML yang dihasilkan untuk link canonical, link hreflang, dan nilai inLanguage di JSON-LD, lalu pastikan empat hal berbeda per locale dan satu hal identik. Canonical harus menunjuk dirinya sendiri, x-default harus selesai tanpa redirect, inLanguage harus cocok dengan bahasa teksnya, dan blok hreflang harus sama di kedua halaman.

Foto oleh Internet Archive Book Images via Wikimedia Commons (No restrictions)
Ringkasan Utama
SEO teknis dwibahasa gagal di dua titik: canonical yang menyeberang bahasa, dan halaman Indonesia yang hanya menerjemahkan versi Inggris. Beri setiap locale canonical yang menunjuk dirinya sendiri, hreflang timbal balik lengkap dengan x-default, dan judul yang ditulis untuk query Indonesia, yang sering kali merupakan pertanyaan berbeda dan bukan pertanyaan yang sama dalam bahasa lain.
Bug ini tidak terlihat dari dalam situs. Setiap artikel Indonesia menampilkan heading Indonesia, isi Indonesia, dan excerpt Indonesia, lalu memasarkan dirinya di hasil pencarian dengan title dan meta description berbahasa Inggris. Tidak ada yang rusak di halamannya. Masalahnya hanya muncul di satu tempat yang tidak bisa saya lihat dari browser sendiri: hasil pencarian yang diterima pembaca Indonesia, dalam bahasa yang salah, menunjuk ke heading yang tidak cocok dengannya.
Blog ini menjalankan 576 tulisan dalam bahasa Inggris dan Indonesia di atas Next.js App Router dengan next-intl, dengan locale prefix selalu ada di path. Semua bagian teknis di bawah ini berasal dari kodenya sendiri — lib/seo.ts, generateMetadata di layout artikel, app/sitemap.ts, dan i18n/routing.ts. Aturan yang harus datang dari Google, bukan dari saya, dikutip di bagian akhir, dan saya tandai mana yang mana.
Satu title SEO dan satu description per tulisan tersimpan di lib/blog-meta.ts, ditulis tangan dalam bahasa Inggris dan sudah dihitung panjangnya terhadap batas truncation Google. Route Indonesia membaca objek yang sama, karena memang hanya itu objek yang ada. Itulah seluruh bug-nya: satu field per tulisan diam-diam mengerjakan tugas per locale, di 576 halaman sekaligus.
String terjemahannya sebenarnya sudah ada. Setiap tulisan punya heading dan excerpt Indonesia di messages/id.json, karena itulah yang dirender halaman Indonesia. Memetakan keduanya ke slot metadata hanya butuh satu fungsi kecil, dan hasilnya membuat ketidakcocokan tadi mustahil secara struktur, bukan sekadar diperbaiki sekali: description sekarang diambil dari key yang sama dengan teks yang terlihat, jadi edit berikutnya tidak bisa menggeser salah satunya saja.
// lib/blog-seo.ts
// English keeps the hand-written SEO pair from lib/blog-meta.ts: keyword
// first, length-checked, deliberately NOT the same string as the display
// heading. Indonesian has no such pair, so it falls back to the translated
// heading and excerpt the page already renders — which means the metadata
// and the visible H1 cannot disagree, because they come from one key.
export function resolveBlogSeoText(
locale: SiteLocale,
seo: BlogSeoText,
localized: { title?: string; excerpt?: string },
): BlogSeoText {
if (locale === "en") return seo;
return {
title: localized.title?.trim() || seo.title,
description: localized.excerpt?.trim()
? truncateForMetaDescription(localized.excerpt)
: seo.description,
};
}Excerpt adalah copy untuk tampilan, bukan untuk hasil pencarian, jadi ia perlu dipotong. Di seluruh katalog, median panjang excerpt sekitar 169 karakter terhadap budget 155 karakter, dan potongan keras akan memutus kata di tengah, yang terbaca seperti copy rusak di SERP. Memotong di spasi terakhir lalu menambahkan elipsis bukan rekayasa yang pintar, tetapi itulah beda antara snippet yang enak dibaca dan snippet yang terlihat terbengkalai.
Ini kesalahan yang paling mahal sekaligus paling terlihat tidak berbahaya. Arahkan canonical halaman Indonesia ke URL Inggris, dan Anda tidak sedang menghapus duplikat apa pun. Anda sedang meminta Google mengindeks halaman Inggris sebagai gantinya, dan versi Indonesia berhenti bersaing untuk apa pun. Tidak ada build yang gagal, tidak ada linter yang protes, dan halamannya tetap tampil sempurna bagi setiap manusia yang membukanya.
Panduan canonicalization Google membahas kasus ini secara langsung: ketika hreflang dipakai, tentukan canonical dalam bahasa yang sama, atau bahasa pengganti terbaik jika tidak ada. Google juga menyarankan canonical yang menunjuk dirinya sendiri di halaman canonical itu. Dan Google menyatakan tegas bahwa metode ini tidak wajib, yang justru memperburuk keadaan, karena canonical hanyalah sinyal, sehingga sinyal yang salah diserap tanpa suara dan Anda baru tahu dari halaman yang hilang, bukan dari error.
// lib/seo.ts — one builder, used by generateMetadata AND by app/sitemap.ts,
// so the two can never disagree about the locale prefix or the trailing slash.
export function buildCanonical(locale: SiteLocale, path = ""): string {
const url = `${BASE_URL}/${locale}${path}`;
return url.endsWith("/") ? url : `${url}/`;
}
export function buildLanguageAlternates(path = ""): Record<string, string> {
return {
en: buildCanonical("en", path),
id: buildCanonical("id", path),
"x-default": buildCanonical("en", path),
};
}
// In the post layout, per locale:
//
// alternates: {
// canonical: buildCanonical(pageLocale, routePath), // Right: itself
// languages: buildLanguageAlternates(routePath), // same map both sides
// }
//
// Wrong: canonical: buildCanonical("en", routePath)
// Rendered on /id/, that asks Google to index the English URL instead.
// The Indonesian page stops competing for anything, and nothing errors.Bangun canonical dan map hreflang dari fungsi yang sama. Kalau yang satu ditulis tangan per route dan yang lain digenerate, keduanya akan melenceng pada tulisan pertama yang path-nya berubah, dan canonical yang menunjuk ke bahasa lain tidak bisa dibedakan dari salah ketik biasa sampai halamannya keluar dari indeks.
Google menyebut dua syarat dan satu konsekuensi. Setiap versi bahasa harus mencantumkan dirinya sendiri sekaligus semua versi lainnya, halaman-halaman itu harus saling menunjuk, dan jika dua halaman tidak saling menunjuk, tag-nya akan diabaikan. Bukan diturunkan bobotnya, melainkan diabaikan, yang di situs dua bahasa berarti pasangan yang menjadi alasan seluruh sistem ini dibangun tidak pernah benar-benar ada.
Timbal balik itu gampang dipenuhi dan gampang hilang, jadi implementasi paling aman adalah yang tidak bisa mengungkapkan kondisi rusaknya. Di sini buildLanguageAlternates hanya menerima path dan tidak menerima locale, lalu mengembalikan map tiga entri yang sama — en, id, x-default — siapa pun yang memanggilnya. Halaman Inggris dan halaman Indonesia karena itu memancarkan blok hreflang yang identik byte per byte, dan tidak ada jalur kode yang bisa menghasilkan tautan satu arah.
x-default adalah entri yang Google gambarkan sebagai fallback untuk pengguna yang setelan bahasanya tidak cocok dengan versi lokal mana pun. Milik kami mengarah ke URL Inggris, karena Inggris adalah default locale dan path tanpa prefix bukan halaman nyata di sini. Yang penting bukan locale mana yang Anda pilih, melainkan bahwa targetnya sama di setiap tempat anotasi itu dipancarkan, dan justru di situlah kegagalan yang dibahas bagian berikutnya.
Google mengizinkan tiga implementasi — elemen link di HTML, HTTP header, atau sitemap — dan menyatakan terus terang bahwa memakai ketiganya tidak memberi keuntungan apa pun di Search dan jauh lebih sulit dikelola daripada memilih satu. Bagian sulit dikelola itu bukan catatan gaya. Di situs ini ia menghasilkan dua sistem yang sama-sama merasa memiliki jawabannya, dan jawaban keduanya berbeda.
Middleware next-intl menyediakan alternate links untuk mesin pencari sebagai bagian dari tugasnya. Halaman kami sudah memancarkan hreflang di head lewat field alternates.languages milik Next.js, jadi set dari middleware itu duplikat, dan bukan duplikat yang identik. x-default miliknya menunjuk ke path tanpa prefix, yang melakukan 307 redirect, sementara HTML menunjuk ke URL Inggris yang berprefix. Satu response, dua anotasi, dua target x-default berbeda, dan perilaku yang Google nyatakan untuk anotasi yang bertentangan adalah mengabaikannya.
// i18n/routing.ts
export const routing = defineRouting({
locales: ["en", "id"],
defaultLocale: "en",
localePrefix: "always",
// The locale is always in the URL path, so the NEXT_LOCALE cookie is
// redundant. Disabling it removes the per-response Set-Cookie that was
// stopping Cloudflare from edge-caching the static HTML.
localeCookie: false,
// Every page already emits hreflang in head via alternates.languages.
// Leaving this ON emitted a SECOND set as an HTTP Link header whose
// x-default pointed at the unprefixed "/blog/SLUG/" — a URL that
// 307-redirects, and a different target from the "/en/blog/SLUG/" in the
// HTML. Google may discard hreflang entirely when the annotations
// conflict, which on a bilingual site risks losing the en/id pairing.
alternateLinks: false,
});Perbaikannya satu flag, dan alasannya berlaku umum: pancarkan anotasi dari lapisan yang sudah memegang konteks paling banyak. Lapisan metadata tahu slug, locale, dan canonical yang baru saja ia bangun satu baris sebelumnya; middleware hanya tahu sebuah URL dan daftar locale. Mematikan flag itu juga membuka satu perubahan lain, karena locale selalu ada di path sehingga cookie NEXT_LOCALE menjadi mubazir — menghapusnya menghilangkan Set-Cookie di setiap response yang selama ini menghalangi Cloudflare melakukan edge-cache pada HTML statisnya.

Argumen menolak machine translation di sini bersifat mekanis, bukan moral. Halaman hasil terjemahan mewarisi target query dari halaman asalnya. Tulisan Inggrisnya dibuat untuk pertanyaan berbahasa Inggris, jadi melewatkannya ke penerjemah menghasilkan halaman Indonesia yang bersaing untuk pertanyaan yang sama, hanya dalam kata-kata Indonesia. Kalau developer Indonesia tidak merumuskan pertanyaannya seperti itu, halaman tersebut bukan jawaban yang lebih lemah untuk query yang hidup, melainkan jawaban untuk query yang tidak pernah diketik siapa pun.
Ada efek turunan yang lebih mudah dilihat. Output mesin ikut menerjemahkan kosakata teknis, padahal kosakata teknis justru yang dibiarkan tetap Inggris oleh developer Indonesia. Halaman yang menulis perangkat lunak sumber terbuka padahal semua pembacanya akan menulis open source bukan salah, melainkan asing, dan ia gagal memuat string yang benar-benar dicari orang. Kata-kata yang paling mungkin diterjemahkan adalah kata-kata yang paling perlu dibiarkan utuh.
Panduan Google untuk situs multibahasa menyampaikan versi paling ringan dari poin ini: menerjemahkan hanya boilerplate sementara isi utamanya tetap satu bahasa menghasilkan pengalaman pengguna yang buruk. Versi yang lebih keras adalah versi yang menentukan apakah pekerjaannya layak dilakukan. Locale kedua yang tidak dicari siapa pun bukan pintu masuk kedua. Ia URL kedua yang harus Anda jaga tetap sinkron dengan yang pertama, selamanya, tanpa trafik.
Begitu locale kedua menargetkan query-nya sendiri, riset kata kunci berhenti menjadi penerjemahan dan berubah menjadi riset kecil. Pola yang terus saya temukan: kata benda teknisnya tetap Inggris, sementara bingkai di sekelilingnya diterjemahkan. Developer Indonesia menulis cara deploy, bukan cara menyebarkan. Mereka mencari error 502 nginx, bukan padanan kamusnya.
| Istilah atau bingkai | Bentuknya di query Indonesia | Pengaruhnya pada judul |
|---|---|---|
| deploy | Tetap Inggris. Frasa yang wajar adalah cara deploy ke VPS, bukan cara menyebarkan ke VPS | Biarkan kata kerjanya utuh dan terjemahkan hanya kata tanya di sekitarnya |
| error | Tetap Inggris, begitu juga string error-nya. Kata kamus galat hidup di UI terjemahan, bukan di kotak pencarian | Taruh teks error apa adanya di judul dan jangan dilokalkan |
| rate limit | Tetap Inggris. Belum ada padanan Indonesia yang benar-benar diketik developer backend | Biarkan konsepnya Inggris dan terjemahkan kalimat yang memuatnya |
| how to, guide, difference | Diterjemahkan, dan justru di sinilah bagian produktifnya: cara, panduan, bedanya, kelebihan dan kekurangan | Di sinilah judul Indonesia paling jauh berbeda dari judul Inggrisnya |
| cost and pricing | Diterjemahkan sekaligus diganti satuannya: biaya, harga, per bulan, rupiah alih-alih dolar | Tulisan tentang biaya infrastruktur perlu satuan lokal di judul atau ia menjawab pertanyaan lain |
Saya tidak punya data query di balik tabel itu, dan saya tidak akan berpura-pura punya. Domain ini tidak memiliki properti Search Console, jadi barisnya berasal dari cara developer di sekitar saya benar-benar menulis dan dari apa yang terus ingin dikatakan draf Indonesia saya sendiri — dasar yang lebih lemah daripada analytics dan jauh lebih kuat daripada mesin penerjemah. Perlakukan sebagai dugaan awal untuk diuji di properti Anda, bukan sebagai temuan.
Konsekuensi praktisnya ada pada asal judul Indonesia. Di situs ini, meta title Indonesia adalah heading tampilan yang diterjemahkan, diselesaikan lewat fungsi yang sama dengan yang memperbaiki description, jadi heading itulah keputusan kata kuncinya, bukan sekadar keterangan. Menulisnya sebagai terjemahan harfiah dari judul Inggris membuang satu-satunya kesempatan halaman itu menjawab pertanyaan yang berbeda, dan tidak ada slot kedua untuk menebusnya.
Tulis judul Indonesianya sebelum Anda menerjemahkan satu paragraf pun dari isinya. Kalau judul Indonesia yang jujur ternyata hanya terjemahan lurus dari judul Inggris, tulisan itu mungkin memang tidak punya tambahan apa pun untuk dikatakan dalam bahasa Indonesia, dan waktunya lebih baik dipakai untuk tulisan yang punya.
Sitemap-nya melakukan loop atas kedua locale dan atas setiap registry konten, jadi sebuah tulisan masuk ke sana karena didaftarkan, bukan karena ada yang mengedit XML. Setiap entri membawa canonical berprefix locale miliknya sendiri, map hreflang yang sama, dan lastmod yang dibaca dari dateModified tulisan itu, bukan dari waktu build. Bagian terakhir itu koreksi nyata: lastmod yang selalu berbunyi sekarang mengajari Google mengabaikan field-nya, dan yang ini sempat menstempel 68 URL yang tidak berubah.
Structured data tunduk pada aturan yang sama dengan title. Schema artikel menyatakan inLanguage id-ID di halaman Indonesia, jadi headline, description, dan abstract-nya juga harus berbahasa Indonesia. Tag bahasa lokal yang duduk di atas teks Inggris adalah kontradiksi yang bisa dideteksi crawler tanpa perlu dibaca manusia, dan perbaikannya adalah fungsi yang sama dengan yang memperbaiki meta description — satu resolver memberi makan halaman, metadata, dan schema sekaligus.
Mekanik paling tidak menarik justru yang benar-benar menjatuhkan halaman. Kedua file pesan harus punya pohon key yang identik, dan next-intl melempar error pada key yang hilang alih-alih menurun perlahan. Satu poin yang ditambahkan ke fragmen Inggris lalu lupa ditambahkan ke fragmen Indonesia tidak menghasilkan daftar yang lebih pendek; ia merusak satu locale dari satu halaman, dan kegagalan seperti itu bisa bertahan berbulan-bulan karena tidak ada bagian lain dari situs yang menyadarinya. Tiap file sekarang lebih dari 5 MB, hampir semuanya isi artikel, jadi pemeriksaannya harus berupa script, bukan kebiasaan.

Ini batas dari tulisan ini. Domain ini tidak punya properti Search Console, jadi saya tidak punya impresi terpisah per locale, tidak punya daftar query, tidak punya laporan international targeting, dan tidak punya hitungan error hreflang. Apa pun yang saya sampaikan tentang ranking, click-through, atau locale mana yang menang akan menjadi karangan, dan angka karangan adalah cara utama tulisan seperti ini menjadi salah. Yang berikut ini adalah metode dan alasannya; angkanya harus datang dari properti Anda sendiri.
Yang tetap bisa Anda verifikasi tanpa itu semua adalah apakah halamannya memancarkan yang Anda kira ia pancarkan, dan di situlah sebagian besar kerusakan sebenarnya berada. Baca hasil build, bukan source-nya, untuk kedua locale dari tulisan yang sama, karena source menunjukkan niat sedangkan HTML menunjukkan yang benar-benar dikirim. Empat hal harus berbeda antara kedua file itu dan satu hal harus sama.
# Read the BUILD OUTPUT, not the source. Both locales of one post.
slug=bilingual-technical-seo-indonesian-english
for loc in en id; do
f=".next/server/app/$loc/blog/$slug.html"
echo "--- $loc"
grep -o 'rel="canonical" href="[^"]*"' "$f"
grep -o 'hreflang="[^"]*" href="[^"]*"' "$f"
grep -o '"inLanguage":"[^"]*"' "$f"
done
# --- en
# rel="canonical" href="https://www.matthewswong.com/en/blog/SLUG/"
# hreflang="en" href=".../en/blog/SLUG/"
# hreflang="id" href=".../id/blog/SLUG/"
# hreflang="x-default" href=".../en/blog/SLUG/"
# "inLanguage":"en-US"
#
# --- id
# rel="canonical" href="https://www.matthewswong.com/id/blog/SLUG/" itself
# hreflang="en" href=".../en/blog/SLUG/" byte-identical to the
# hreflang="id" href=".../id/blog/SLUG/" block on the /en/ page
# hreflang="x-default" href=".../en/blog/SLUG/"
# "inLanguage":"id-ID"Empat pernyataannya: canonical menunjuk dirinya sendiri dan berprefix locale di masing-masing halaman, x-default selesai tanpa redirect, inLanguage bernilai id-ID di halaman Indonesia, dan title serta description di file itu berbahasa Indonesia. Satu yang harus sama adalah blok hreflang, yang mestinya berisi tiga baris yang persis sama di keduanya. Tambahkan sepasang entri sitemap dan itulah seluruh permukaan mekanisnya, sisanya urusan redaksi.
Bagian mekanis adalah bagian yang murah, dan layak diotomatiskan sampai tidak bisa salah: satu fungsi membangun canonical, satu membangun map hreflang, keduanya dipakai halaman sekaligus sitemap, dan tak satu pun bisa menghasilkan tautan satu arah. Bagian yang mahal adalah redaksional dan tidak ada script yang mengerjakannya untuk Anda. Locale kedua adalah pertanyaan kedua, dan begitu judul Indonesia berubah menjadi terjemahan judul Inggris, halaman itu berhenti memperebutkan apa pun.
Sumber dan bacaan lanjutan