D
P
0
← Semua artikel Read in English

SEO Teknis, Redirect & Migrasi

FAQ Bertab Tak Terbaca AI Crawler? Server-Render Semua Seratus Q&A ke HTML Statis

· · 5 menit baca
FAQ Bertab Tak Terbaca AI Crawler? Server-Render Semua Seratus Q&A ke HTML Statis

Versi gulungan panjangnya ditolak lebih dulu, dan itu penolakan yang masuk akal. Seratus pasang tanya jawab yang ditumpuk jadi satu halaman memang melelahkan dibaca. Bentuk yang akhirnya dipakai adalah sepuluh kategori dengan pill bergaya tab yang kompak, satu kategori tampil pada satu waktu.

Materinya datang dari klien. Mereka menyerahkan seratus pasang tanya jawab, dan orang dari pihak klien menyampaikan bahwa halaman FAQ inilah cara orang menemukan mereka lewat asisten AI seperti ChatGPT dan Claude. Halaman itu mereka posisikan sebagai bagian dari cara mendapat klien, bukan pelengkap di footer.

Jadi satu halaman yang sama melayani dua pembaca dengan tuntutan yang berlawanan. Yang satu ingin melihat sedikit dulu, yang satu lagi cuma membaca apa yang benar-benar ada di HTML-nya.

Versi pertamanya tidak seadanya

Halaman FAQ-nya lahir di satu ronde revisi dari klien yang masuk lewat WhatsApp, dan dikirim sebagai satu commit yang menyentuh tujuh belas berkas, bareng perombakan drawer mobile di ronde yang sama. Versi pertama itu sudah membawa pencarian lintas kategori, blok FAQPage JSON-LD yang mengemit semua seratus entri untuk SEO, dan tautan di nav, footer, serta sitemap.

Kalau daftar itu yang dilihat, halamannya kelihatan sudah beres untuk mesin.

Yang tercatat sebagai masalahnya

Catatan masalah halaman ini menunjuk ke satu hal. Konten yang di-mount secara kondisional di sisi client tidak ikut ada di HTML mentah, jadi crawler tanpa JavaScript dan AI crawler tidak melihatnya, padahal justru pembaca itu yang jadi alasan halamannya dibangun. Akarnya dibaca sebagai conditional render yang bergantung pada state tab.

Bentuk umumnya kira-kira begini, dan tidak ada yang aneh dari cara menulisnya:

{categories.map((cat) =>
  cat.slug === activeCategory ? (
    <CategoryPanel key={cat.slug} category={cat} />
  ) : null
)}
<button onClick={toggle}>{question}</button>
{isOpen && <div className="answer">{answer}</div>}

Buat mata manusia dua-duanya bekerja persis seperti yang diminta. Klik pill, kategori lain hilang. Klik pertanyaan, jawabannya muncul. Tapi tidak satu pun dari keduanya menyembunyikan sesuatu. Keduanya menolak membuat elemennya. Waktu halaman dirender di server, state tabnya masih nilai awal dan semua jawaban masih tertutup, jadi cabang yang kalah tidak menghasilkan apa pun untuk dicetak. Sisanya bukan tersembunyi, sisanya tidak pernah ada.

Situsnya static export Next, output: 'export' dengan images.unoptimized: true, jadi HTML yang sampai ke crawler benar-benar berkas hasil build. Tidak ada lapisan server yang bisa menambalnya belakangan.

Perbaikannya

Pengerasan SEO-nya tidak ikut di commit yang memperkenalkan halaman FAQ, dia commit sendiri. Isinya satu aturan: semua seratus Q&A server-render ke HTML statis, dan tidak boleh ada satu bagian pun yang di-mount secara kondisional.

Untuk kategori, yang nonaktif dipegang atribut hidden. Bentuk umumnya kira-kira begini:

{categories.map((cat) => (
  <section
    key={cat.slug}
    id={`category-${cat.slug}`}
    hidden={cat.slug !== activeCategory}
  >
    <CategoryPanel category={cat} />
  </section>
))}

Sepuluh section tetap dicetak semuanya dan sembilan di antaranya membawa hidden. Atribut itu menyembunyikan elemen lewat stylesheet bawaan browser dan membuat assistive technology melewatinya, tanpa mengeluarkan teksnya dari dokumen yang dikirim.

Untuk jawaban yang tertutup, yang dipakai CSS grid-rows yang bergerak dari 0fr ke 1fr. Bentuk umumnya kira-kira begini:

.answer {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 200ms ease;
}
 
.answer[data-open="true"] {
  grid-template-rows: 1fr;
}
 
.answer-inner {
  overflow: hidden;
}

Elemennya tidak pernah keluar dari DOM dan yang berubah cuma tinggi barisnya, jadi animasi buka tutupnya tetap dapat tanpa mengukur tinggi sendiri dan tanpa max-height tebakan yang patah begitu jawabannya kepanjangan.

Yang tidak ikut berubah sama sekali adalah UX tab kompaknya, dan memang itu yang diinginkan.

Cara memeriksanya

Pemeriksaan pertama dilakukan di staging dengan menarik HTML mentahnya lewat curl, karena itu yang dilihat AI crawler yang tidak menjalankan JavaScript, dan hasilnya dinyatakan konsisten dengan blok FAQPage JSON-LD.

Yang mencatat angka adalah pemeriksaan di produksi. Seratus pertanyaan dan seratus jawaban server-render ke HTML mentah, dan blok FAQPage JSON-LD memuat semua seratus entri, dicocokkan lewat skrip terhadap berkas data FAQ-nya. Pemeriksaan itu dicatat sebagai pemenuhan kebutuhan yang datang dari klien tadi, dan di sesi yang sama 33 dari 33 rute membalas 200 dan delapan header keamanan hadir.

Menghitungnya di sisi structured data bisa sesederhana ini:

curl -s "$FAQ_URL" > faq.html
grep -o '"@type":"Question"' faq.html | wc -l

Angka itu sendirian tidak membuktikan apa-apa, karena dia cuma mengukur blok JSON-LD. Yang bikin pemeriksaannya berarti adalah membandingkan angka itu dengan dua hitungan lain: berapa teks pertanyaan dan jawaban yang benar-benar muncul di body HTML yang sama, dan berapa entri yang ada di berkas data FAQ-nya. Ketiganya harus bertemu di angka yang sama, dan kalau suatu saat ada yang memindahkan satu bagian kembali ke conditional render, ketiganya akan berpisah.

Dua jebakan pemeriksaan dari projek yang sama

Yang pertama, curl polos pernah menghasilkan negatif palsu, tapi bukan di halaman FAQ ini. Host statiknya membalas 308 dari alamat ber-slash ke alamat tanpa slash, dan tanpa -L curl balik dengan body kosong. Efeknya sebuah kunci publik kelihatan seperti tidak ikut ter-bake ke bundel, padahal dengan -L kuncinya memang ada di chunk statiknya.

Yang kedua, memverifikasi variabel env lewat sitemap.xml tidak membuktikan apa pun di projek ini, karena variabel URL situsnya punya fallback hardcoded yang persis sama. Keluarannya identik entah env-nya keisi atau tidak.

Pelajaran

Pertanyaan yang saya bawa keluar dari sini berlaku jauh di luar halaman FAQ. Alat yang dipakai memeriksa sesuatu, apakah dia punya cara untuk bilang tidak? Blok JSON-LD yang lengkap seratus tidak bercerita apa pun tentang isi body di sebelahnya, sama seperti sitemap yang punya fallback identik tidak bercerita apa pun tentang variabel env. Pemeriksaan yang tidak bisa gagal bukan pemeriksaan.

Sisanya tinggal satu kebiasaan. Conditional render bukan menyembunyikan, dia menolak membuat. Kalau sebuah teks harus bisa ditemukan mesin, dia tidak boleh berdiri di belakang state. Cetak semuanya lebih dulu, baru sembunyikan yang perlu disembunyikan lewat atribut hidden dan CSS. Dan bagian yang paling melegakan, tabnya tetap tab.