D
P
0
← Semua artikel Read in English

Next.js & React di Produksi

Webhook Sanity Menyala Tapi Halaman Per-Slug Tetap Basi? Projection Kosong Bikin `revalidatePath('/glossary/[object Object]')`

· · 5 menit baca
Webhook Sanity Menyala Tapi Halaman Per-Slug Tetap Basi? Projection Kosong Bikin `revalidatePath('/glossary/[object Object]')`

Sebuah situs editorial klien memakai Sanity sebagai CMS dan Next.js dengan ISR satu jam di sisi depan. Alurnya standar dan sudah jalan berbulan-bulan: editor menekan Publish di Studio, Sanity menembakkan webhook ke route revalidate, dan halaman yang terkena langsung diperbarui. Sampai suatu hari seorang editor melapor bahwa koreksinya tidak muncul-muncul.

Yang bikin penasaran, keluhannya cuma separuh benar. Halaman daftar glosarium memang selalu ikut berubah dalam hitungan detik setelah publish. Halaman detail per istilah tidak. Halaman itu bertahan basi persis sampai jendela ISR satu jam habis, lalu segar sendiri seolah tidak pernah ada masalah. Dan tidak ada satu pun error di mana pun. Panel webhook di Sanity mencatat pengiriman berhasil dengan balasan 200 setiap kali, route revalidate tidak melempar apa-apa, log produksi bersih.

Memisahkan bagian yang benar-benar rusak

Karena tidak ada error yang bisa dibaca, saya berhenti menatap kode dan mulai mengukur cache-nya langsung. Next menaruh status cache di header respons, jadi dua curl sudah cukup untuk memisahkan dua kemungkinan besar: webhook-nya tidak pernah sampai, atau webhook sampai tapi ada panggilan invalidasi yang tidak menggigit.

curl -sI https://situs-klien.example/glossary | grep -i x-nextjs-cache
# x-nextjs-cache: MISS
 
curl -sI https://situs-klien.example/glossary/istilah-yang-baru-diedit | grep -i x-nextjs-cache
# x-nextjs-cache: HIT

Hasilnya memotong ruang masalah dengan rapi. Path daftar benar-benar ter-invalidasi, artinya webhook terkirim, autentikasinya lolos, dan route-nya berjalan sampai tuntas. Yang tidak menggigit hanya panggilan yang menyasar path per-slug. Jadi bug-nya bukan di transport, melainkan di satu argumen.

Slug yang ternyata bukan string

Bentuk route revalidate-nya kira-kira seperti ini:

const body = await req.json();
 
revalidatePath("/");
revalidatePath("/glossary");
revalidatePath("/glossary/" + body.slug);
revalidateTag(body._type, "max");

Saya log payload mentah yang masuk, dan di sana jawabannya kelihatan. body.slug bukan string:

{
  "_id": "...",
  "_rev": "...",
  "_type": "glossaryTerm",
  "title": "...",
  "slug": { "_type": "slug", "current": "istilah-yang-baru-diedit" }
}

Sanity mengirim dokumen utuh, bukan payload ringkas yang diasumsikan route. Dan begitu sebuah objek digabung ke string lewat operator +, JavaScript memanggil konversi string bawaannya. Hasilnya [object Object]. Jadi panggilan yang benar-benar dieksekusi setiap kali editor menekan Publish adalah ini:

revalidatePath("/glossary/[object Object]");

Path itu tidak cocok dengan route mana pun, dan revalidatePath tidak melempar untuk path yang tidak dikenal. Dia cuma tidak melakukan apa-apa. Itulah sebabnya log bersih total: dari sudut pandang Next tidak ada yang salah, cuma ada permintaan invalidasi untuk sebuah halaman yang memang tidak ada.

Dua panggilan literal, / dan /glossary, tetap bekerja karena keduanya tidak menyentuh body.slug sama sekali. revalidateTag(body._type, "max") juga aman karena _type memang sudah string di dokumen utuh. Gejala setengah-setengah tadi bukan kebetulan. Itu peta persis dari argumen mana yang ternoda dan mana yang tidak.

Yang mengendalikan bentuk payload

Bentuk payload webhook ditentukan oleh field Projection di konfigurasinya (di Sanity lewat menu API lalu Webhooks lalu Edit). Field itu gampang terlewat karena boleh dibiarkan kosong, dan mengosongkannya tidak berarti "kirim saja bentuk default yang masuk akal". Artinya begini:

Di proyek ini ada dua webhook, satu untuk produksi dan satu untuk staging, dan dua-duanya punya Projection kosong. Tidak ada yang pernah mengisinya sejak hari pertama.

Bagian yang paling menyebalkan dari kasus ini adalah kodenya tidak terlihat salah sedikit pun. Persis di atas handler ada doc-comment yang menuliskan bentuk payload yang benar:

/**
 * Payload: { "_type": _type, "slug": slug.current }
 */

Kontraknya tertulis rapi, kode di bawahnya cocok dengan kontrak itu, dan review kode berapa kali pun tidak akan menemukan apa-apa. Yang tidak pernah cocok justru konfigurasi di dashboard, dan dashboard tidak ikut masuk ke dalam pull request.

Perbaikannya, di dua sisi

Sisi pertama adalah konfigurasi webhook itu sendiri. Projection diisi ke bentuk paling minimal yang route butuhkan:

{"_type": _type, "slug": slug.current}

Sekalian saya isi juga field Filter, yang di kedua webhook sama-sama kosong. Filter kosong berarti webhook menyala untuk setiap perubahan di dataset, termasuk draft dari tipe dokumen internal yang tidak punya halaman publik sama sekali. Membatasinya ke tipe yang memang berhalaman jauh lebih tenang:

_type in ["article", "category", "glossaryTerm"]

Sisi kedua adalah route-nya sendiri, supaya webhook berikutnya yang lupa Projection tidak menimbulkan kegagalan diam yang sama. Sebuah helper kecil yang menerima dua bentuk sekaligus:

function extractSlug(slug: unknown): string | undefined {
  if (typeof slug === "string") return slug;
  if (slug && typeof slug === "object" && "current" in slug) {
    const current = (slug as { current?: unknown }).current;
    if (typeof current === "string") return current;
  }
  return undefined;
}

Lalu pemakaiannya di handler:

const slug = extractSlug(body.slug);
if (slug) revalidatePath(`/glossary/${slug}`);

Dua baris itu sekalian menutup lubang lain di kode awal. Kalau slug memang tidak ada di payload, sekarang tidak ada lagi path karangan yang dikirim ke revalidatePath. Yang tidak punya slug cukup dilewati.

Checklist