D
P
0
← Semua artikel Read in English

SEO Teknis, Redirect & Migrasi

18 Ribu URL `?range=` Ikut Ke-crawl Padahal Canonical-nya Sudah Benar? Canonical Tidak Menyelamatkan Crawl Budget

· · 6 menit baca
18 Ribu URL `?range=` Ikut Ke-crawl Padahal Canonical-nya Sudah Benar? Canonical Tidak Menyelamatkan Crawl Budget

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:

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:

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.txt

Kalau 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