Sitemap-nya rapi, status code-nya benar, canonical terpasang di semua halaman. Situs kontennya berjalan di atas Next.js dan saya sendiri yang membangunnya, jadi waktu crawler SEO saya arahkan ke sana, yang saya siapkan untuk dibaca cuma daftar pendek berisi tautan internal yang salah ketik.
Yang keluar dari crawler bukan daftar pendek.
Gejalanya: 18 ribu URL yang tidak pernah sengaja saya buat
Crawler menemukan sekitar 18 ribu URL yang bisa dijelajahi dengan pola ?range=, ditambah sekitar 300 halaman dengan pola ?cat=. Semua dilaporkan sebagai URL sah yang layak dikunjungi.
/detail/aaa?range=24h
/detail/aaa?range=7d
/detail/aaa?range=30d
/detail/bbb?range=24h
/detail/bbb?range=7d
...Sumbernya ketemu dalam lima menit. Di halaman detail ada switcher rentang waktu untuk grafik, dan switcher itu saya bangun dari anchor sungguhan, bukan tombol:
{ranges.map((r) => (
<Link key={r} href={`?range=${r}`} scroll={false}>
{r}
</Link>
))}Keputusan itu waktu itu terasa benar. Anchor sungguhan berarti rentangnya bisa dibagikan lewat tautan, bisa dibuka di tab baru, dan tetap hidup tanpa JavaScript. Yang tidak saya hitung: setiap anchor itu, di mata crawler, adalah URL baru. Jumlah halaman detail dikali jumlah opsi rentang, dan angkanya langsung berlipat jadi belasan ribu.
Bagian yang bikin saya bingung bukan jumlahnya. Bagian yang bikin bingung: semua varian itu sudah punya canonical yang benar.
Kenapa saya mengira sudah aman
Tiap halaman detail mengirim canonical ke versi bersihnya, tanpa parameter:
<link rel="canonical" href="https://situs-anda.com/detail/aaa" />Jadi logika saya waktu itu: canonical sudah benar, berarti Google tahu mana versi asli, berarti varian parameter tidak akan jadi masalah. Selesai.
Logika itu benar untuk pertanyaan yang salah. Canonical memang menyelamatkan indeks saya. Yang tidak dia selamatkan adalah crawl budget saya, dan itu dua anggaran yang berbeda.
Akar masalahnya: canonical dan robots menjawab dua pertanyaan berbeda
Ini inti kasusnya, dan saya butuh audit 18 ribu URL untuk benar-benar meresapinya:
- Canonical menjawab: "URL mana yang pantas masuk indeks?" Supaya bisa membaca jawaban itu, crawler harus mengunduh halamannya dulu. Artinya setiap satu dari 18 ribu URL itu tetap dijelajahi, tetap memakan request, tetap memakan waktu server, baru setelah itu dibuang dari kandidat indeks.
- robots.txt menjawab: "Boleh tidak kamu mengambil URL ini?" Jawaban itu diberikan sebelum request terjadi, jadi dia benar-benar memotong crawl.
Dan inilah jebakan yang membuat orang memasang keduanya sekaligus lalu heran kenapa tidak ada yang membaik: keduanya tidak bisa ditumpuk. Kalau kamu men-Disallow sebuah URL, crawler tidak akan mengambilnya, jadi dia juga tidak akan pernah membaca tag canonical di dalamnya. Canonical yang tidak pernah dibaca sama saja dengan canonical yang tidak ada.
Jadi untuk satu URL, kamu sedang memilih:
- Biarkan dijelajahi, canonical terbaca, indeks bersih, crawl budget terbakar.
- Blokir dari robots, crawl budget selamat, canonical tidak pernah terbaca.
Tidak ada opsi ketiga yang memberi keduanya. Yang harus kamu putuskan cuma satu: untuk kelas URL ini, mana yang lebih mahal.
Kenapa ini baru menggigit di skala tertentu
Di situs kecil, perdebatan ini akademis. Seratus varian parameter tidak akan membuat siapa pun kelaparan crawl budget.
Yang berubah di situs konten besar adalah kardinalitas. Parameter faset menempel pada halaman yang jumlahnya sudah banyak, lalu ia mengalikan, bukan menambah. Satu parameter dengan tiga opsi mengubah ribuan halaman jadi belasan ribu URL. Kalau parameter kedua ikut nempel di halaman yang sama, kombinasinya berlipat lagi, dan angka ratusan ribu URL jadi hal yang wajar tanpa satu pun halaman baru pernah ditulis.
Pada titik itu, crawler menghabiskan jatah kunjungannya untuk membaca ribuan salinan halaman yang sama dengan bentuk grafik berbeda, sementara halaman baru yang benar-benar penting antre di belakang. Halaman-halaman varian itu memang tidak masuk indeks. Mereka cuma sudah memakan biayanya lebih dulu.
Perbaikannya: blokir polanya di app/robots.ts
Karena varian parameter ini tidak punya nilai indeks sama sekali, pilihannya jadi gampang. Saya rela canonical-nya tidak terbaca. Yang saya mau adalah crawl-nya berhenti.
Sebelum menulis apa pun, satu pemeriksaan wajib: pastikan tidak ada jalur sah di situs yang memakai nama parameter itu. Kalau ada satu saja route yang bergantung pada range atau cat untuk menampilkan konten yang perlu diindeks, aturan ini akan menguburnya.
grep -rn -A3 "searchParams" app/ | grep -E "range|cat"-A3 di situ bukan basa-basi. Nama parameternya jarang duduk di baris yang sama dengan searchParams; biasanya dia baru muncul di destructuring atau di anotasi tipe satu dua baris di bawahnya. Tanpa baris konteks itu, grep-nya gampang balik kosong dan kamu merasa aman padahal belum memeriksa apa-apa.
Di situs ini hasilnya cuma switcher grafik dan filter kategori di halaman listing yang memang sudah punya versi tanpa parameter. Aman. Baru setelah itu saya kirim aturannya:
// app/robots.ts
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: ["/*range=", "/*cat="],
},
],
sitemap: "https://situs-anda.com/sitemap.xml",
};
}Wildcard di depan itu penting. Disallow: /*range= mencocokkan parameter tersebut di kedalaman path mana pun, bukan cuma di root, dan itu memang yang dibutuhkan karena switcher-nya hidup di halaman detail.
Verifikasinya sederhana, dan lakukan di produksi, bukan di lokal:
curl -s https://situs-anda.com/robots.txtKalau dua baris Disallow itu benar-benar muncul di output, kamu selesai. Halaman bersihnya tetap boleh dijelajahi seperti biasa karena allow: "/" masih berlaku, dan yang terpotong cuma varian berparameter.
Perbaikan yang lebih benar: jangan bikin URL-nya sejak awal
Menutup pola di robots.txt itu obat, bukan penyembuhan. URL-nya masih ada, saya cuma menyuruh crawler tidak usah datang.
Cara yang lebih bersih adalah tidak pernah memproduksi URL itu. Kalau state-nya cuma untuk kenyamanan tampilan dan tidak mengubah konten yang perlu diindeks, pindahkan ke fragment. Fragment tidak pernah menjadi URL terpisah di mata crawler:
const [range, setRange] = useState("24h");
useEffect(() => {
const fromHash = window.location.hash.slice(1);
if (ranges.includes(fromHash)) setRange(fromHash);
}, []);
{ranges.map((r) => (
<button
key={r}
type="button"
aria-pressed={r === range}
onClick={() => {
setRange(r);
history.replaceState(null, "", `#${r}`);
}}
>
{r}
</button>
))}Kamu tetap dapat tautan yang bisa disalin dan dibagikan, #24h ikut menempel di address bar, tapi crawler tidak melihat 18 ribu halaman. Dia cuma melihat satu.
useEffect di atas yang bikin tautan salinan itu benar-benar memulihkan rentangnya, dan pemulihannya memang harus terjadi setelah mount. Fragment tidak pernah ikut dikirim ke server, jadi kalau kamu membacanya di initial state, render server dan render klien akan beda dan kamu dapat hydration mismatch. Satu catatan lagi supaya tidak salah harap: replaceState sengaja tidak menambah entri riwayat, jadi tombol Back tidak akan menyusuri tiap pergantian rentang. Kalau kamu memang mau perilaku itu, pakai pushState.
Harga yang kamu bayar juga jelas: versi tombol ini butuh JavaScript, sesuatu yang versi anchor tidak butuh. Untuk state yang cuma mengubah tampilan, itu pertukaran yang wajar. Untuk state yang mengubah konten yang perlu diindeks, jangan dipindah ke fragment sama sekali.
Bedanya di mana? Anchor ?range= bilang ke crawler "ini dokumen lain". Fragment #24h bilang "ini dokumen yang sama, posisi lihat yang berbeda". Untuk switcher grafik, kalimat kedua itu yang benar sejak awal.
Yang saya bawa pulang
- Canonical dan robots.txt bukan dua lapis pertahanan yang saling menguatkan. Mereka menjawab pertanyaan berbeda, dan menumpuk keduanya di URL yang sama justru membatalkan salah satunya.
- Canonical yang benar tidak menghentikan satu pun request crawler. Untuk berhenti dijelajahi, satu-satunya alat adalah robots.txt.
- Parameter faset mengalikan jumlah URL, bukan menambah. Cek dampaknya dengan mengalikan jumlah halaman dengan jumlah opsi sebelum memutuskan memakai anchor sungguhan.
- Sebelum mengirim aturan
Disallowberbasis pola, grep dulu seluruh route yang membaca nama parameter itu. Aturan satu baris bisa menyembunyikan halaman yang sebenarnya kamu butuh. - Kalau state hanya mengubah tampilan dan bukan konten, simpan di fragment. URL yang tidak pernah lahir tidak perlu dikelola.