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: HITHasilnya 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:
- Projection kosong: Sanity mengirim SELURUH dokumen apa adanya, lengkap dengan
_id,_rev, dan semua field lain. Field slug ikut dalam bentuk aslinya di dataset, yaitu objek{ "_type": "slug", "current": "the-slug" }. - Projection diisi
{"_type": _type, "slug": slug.current}: Sanity hanya mengirim field yang diproyeksikan itu, danslugsampai sebagai string datar seperti yang diharapkan route.
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
- Kalau webhook menyala tapi hanya sebagian halaman yang ikut segar, ukur dulu dengan
curl -Idan baca headerx-nextjs-cache. Path daftarMISSplus path detailHITberarti transport-nya sehat dan yang rusak cuma satu argumen path. revalidatePathtidak melempar untuk path yang tidak dikenal. Kegagalannya diam, jadi jangan menunggu error muncul di log sebelum mulai curiga.- Di Sanity, Projection kosong berarti dokumen utuh, dan di dokumen utuh field slug itu objek
{ _type, current }, bukan string. - Selalu isi Projection ke bentuk minimal yang route-mu harapkan. Untuk konten yang digerakkan slug,
{"_type": _type, "slug": slug.current}adalah default yang aman. - Isi juga Filter supaya webhook tidak menyala untuk tipe dokumen yang tidak punya halaman.
- Doc-comment yang menuliskan bentuk payload bukan jaminan apa-apa. Yang menentukan ada di dashboard, dan dashboard tidak ikut ter-review bersama kode. Kalau sebuah route sudah terlihat benar tapi tetap tidak bekerja, periksa konfigurasi yang memberinya makan.