D
P
0

Next.js

Kuota CDN Sanity 1 Juta Ludes Sebelum Sebulan? `force-dynamic` Diam-diam Bikin Semua Child Fetch Jadi `no-store`

3 Agustus 2026·5 menit baca
Kuota CDN Sanity 1 Juta Ludes Sebelum Sebulan? `force-dynamic` Diam-diam Bikin Semua Child Fetch Jadi `no-store`

Paket Growth di Sanity memberi kamu satu juta request API CDN per bulan. Angka itu kedengarannya lega sampai kamu melihat meterannya menyentuh 80 persen di hari ke-17, lalu mentok 100 persen sebelum bulan itu selesai. Di sebuah situs klien yang saya pegang, itu terjadi dua siklus berturut-turut: kuota sebulan penuh habis dalam rentang 17 sampai 22 hari.

Bagian yang tidak masuk akal: trafik manusianya minim. Bukan "agak sepi", tapi benar-benar sedikit. Tidak ada kampanye, tidak ada lonjakan, tidak ada halaman yang tiba-tiba viral. Sejuta request, dan analytics-nya menunjukkan trafik yang muat di satu layar. Angka sebesar itu jelas bukan datang dari orang, jadi pertanyaannya berubah: kalau bukan pengunjung, siapa yang menembak query sebanyak ini?

Tersangka pertama: bot

Tebakan awal saya jelas, crawler. Bot memang tidak kelihatan di analytics yang berbasis JavaScript, jadi mereka gampang dijadikan tersangka. Saya catat hipotesis itu dan sempat cukup yakin.

Tapi menyalahkan bot terlalu enak sebagai jawaban, dan ada yang tidak nyambung. Bot cuma pengali. Kalau setiap kunjungan halaman hanya sesekali menyentuh API karena sisanya dilayani dari cache, crawler paling rajin sekalipun tidak akan membakar sejuta request. Pertanyaan yang benar bukan siapa yang mengetuk pintu, tapi kenapa setiap ketukan tembus sampai ke origin.

Soal siapa yang sebenarnya mengetuk, itu cerita terpisah, dan hasilnya bukan yang saya duga. Yang saya kejar duluan adalah kebocoran cache-nya.

Grep yang seharusnya saya jalankan dari menit pertama

Ada satu perintah yang sekarang selalu saya jalankan lebih dulu setiap kali kuota API terbakar tanpa penjelasan:

grep -rn "force-dynamic" app --include="page.tsx"

Hasilnya langsung menusuk. Dua kelompok route detail, masing-masing punya ratusan slug, dibuka dengan baris yang sama:

export const dynamic = "force-dynamic";

Baris itu tidak salah ketik dan tidak ditaruh sembarangan. Dia biasanya masuk sebagai jalan pintas yang sah: route-nya sempat bermasalah waktu Next.js mencoba meng-optimasi statis, force-dynamic menyelesaikan masalah itu, selesai. Yang jarang disadari, satu baris itu punya efek samping yang jauh lebih luas daripada sekadar "jangan pre-render".

Kenapa satu baris itu jadi mahal

Di Next.js 15 ke atas, export const dynamic = "force-dynamic" tidak cuma mengatur cara route dirender. Dia juga mengubah default cache untuk fetch di bawahnya. Setiap fetch dalam pohon render yang tidak membawa konfigurasi cache eksplisit akan diperlakukan seperti cache: "no-store".

Efeknya berantai. Tidak ada entri yang masuk Data Cache, tidak ada hasil yang dipakai ulang antar request, tidak ada revalidasi yang bisa menahan beban. Setiap render menembak API lagi dari nol, seolah data itu belum pernah diambil sebelumnya.

Sekarang kalikan angkanya seperti yang akhirnya saya lakukan di kertas:

  • ratusan slug per kelompok route, dikali dua kelompok
  • beberapa panggilan Sanity per halaman, karena satu halaman detail biasanya butuh konten utamanya, setting global, dan beberapa blok pendukung
  • dikali berapa kali halaman itu dirender dalam sebulan

Tidak butuh trafik besar untuk sampai ke tujuh digit. Yang dibutuhkan cuma cache yang tidak pernah kena sama sekali.

Perbaikannya: pasang baseline di satu tempat

Untungnya semua akses Sanity di proyek itu lewat satu wrapper terpusat. Itu keberuntungan arsitektur yang baru terasa harganya di momen seperti ini, karena perbaikannya cukup di satu file, bukan di ratusan pemanggilan.

export async function sanityFetch<T>(
  query: string,
  params: Record<string, unknown> = {},
  options: SanityFetchOptions = {},
): Promise<T> {
  return client.fetch<T>(query, params, {
    next: {
      revalidate: options.revalidate ?? 300,
      tags: options.tags ?? [],
    },
  });
}

Intinya sederhana: tidak ada lagi fetch yang boleh berangkat tanpa opsi cache. Kalau pemanggil tidak menyebutkan apa-apa, dia tetap dapat baseline lima menit plus tag, dan tag itu yang nanti dipakai untuk invalidasi terarah saat editor mengubah konten.

Langkah keduanya, di halaman per-slug, saya tukar deklarasi dinamisnya dengan revalidasi biasa:

export const revalidate = 300;

Hasilnya: berhenti berdarah, belum sembuh

Perbaikan ini menghentikan pendarahan yang paling parah, dan itu memang tujuannya saat itu. Tapi jujur saja, dia tidak menyelesaikan masalahnya.

Siklus berikutnya masih menunjukkan sekitar 150 ribu request per hari. Jauh lebih waras dari sebelumnya, tetap terlalu tinggi untuk situs dengan trafik manusia sekecil itu. Baseline lima menit ternyata cuma memindahkan masalahnya, bukan mencabutnya, dan penyebab sisanya sama sekali bukan yang saya duga.

Bagian itu baru ketahuan setelah saya berhenti menebak dan mulai membaca log request satu per satu, dan itu cerita tersendiri.

Nuansa yang baru saya verifikasi belakangan

Waktu itu saya memperlakukan force-dynamic sebagai racun yang harus dibuang dari semua route. Setelah insidennya reda, saya menelusuri source Next.js untuk memastikan, dan ternyata sikap itu berlebihan. Batasannya persis seperti yang saya temukan di lapangan, tidak lebih luas: hanya fetch tanpa konfigurasi yang jatuh jadi no-store, sementara revalidate positif yang ditulis eksplisit tetap dihormati.

Artinya kalau kamu memang butuh force-dynamic karena ada alasan sah di pohon render, kamu tidak wajib mengorbankan Data Cache. Cukup pastikan setiap fetch membawa next: { revalidate: N } sendiri. Kombinasi itu sah dan tetap kena cache. Yang berbahaya bukan force-dynamic-nya, tapi force-dynamic yang bertemu fetch polos tanpa opsi apa pun.

Yang saya bawa pulang

  • Kalau kuota API terbakar tanpa lonjakan trafik, cari force-dynamic di app/**/page.tsx sebelum mencurigai hal lain. Itu diagnostik lima detik yang bisa memangkas berjam-jam.
  • force-dynamic bukan cuma soal rendering. Dia menurunkan default no-store ke setiap fetch di bawahnya yang tidak eksplisit.
  • Fetch tanpa konfigurasi cache bukan pilihan netral. Di route dinamis, dia justru pilihan paling mahal.
  • Satu wrapper terpusat untuk semua panggilan API itu investasi. Saat harus memasang baseline, kamu cukup menyentuh satu file.
  • Eksplisit selalu menang. force-dynamic menghormati revalidate yang kamu tulis sendiri, jadi tulislah.