Di Next.js modern, performa sering kali ditentukan bukan hanya oleh seberapa cepat API Anda, tetapi oleh bagaimana hasil pengambilan data disimpan, digunakan ulang, dan diperbarui. Masalahnya, banyak bug di aplikasi production justru muncul karena perilaku cache yang tidak sesuai ekspektasi: halaman katalog tidak ikut berubah setelah stok diperbarui, dashboard admin menampilkan data lama, atau route yang seharusnya cepat malah selalu memukul backend.

Pada Next.js 16, strategi caching perlu dipikirkan di beberapa level sekaligus: level route, level fetch, dan level output rendering. Ketiganya saling memengaruhi. Karena itu, memahami hanya satu opsi seperti revalidate atau cache: 'no-store' saja biasanya tidak cukup.

Artikel ini fokus pada pengambilan keputusan cache yang presisi, bukan sekadar cara mengambil data. Kita akan menggunakan contoh aplikasi produk dengan tiga kebutuhan berbeda:

  • API produk sebagai sumber data utama
  • Halaman katalog yang boleh sedikit stale demi performa
  • Halaman admin yang harus selalu segar

Memahami Tiga Level Caching yang Sering Tertukar

1. Cache di level output rendering

Ini adalah cache terhadap hasil render route atau halaman. Jika sebuah route dianggap statis, Next.js dapat menyajikan hasil render yang sudah disiapkan sebelumnya. Ini cocok untuk halaman yang tidak perlu berubah pada setiap request.

Contohnya, katalog produk publik sering cocok menggunakan output yang dapat digunakan ulang, selama perubahan tidak harus muncul detik itu juga.

2. Cache di level fetch

Di dalam Server Components, Next.js memperlakukan fetch() secara khusus. Respons dari request ke API dapat di-cache dan di-reuse antar-render, tergantung konfigurasi seperti:

  • cache: 'force-cache'
  • cache: 'no-store'
  • next: { revalidate: 60 }
  • next: { tags: [...] }

Level ini sangat penting karena dua route berbeda bisa saja mengonsumsi sumber data yang sama. Jika data di-cache di level fetch, invalidasi bisa menjadi lebih granular daripada sekadar me-render ulang seluruh halaman.

3. Keputusan dinamis di level route

Route dapat dipaksa menjadi dinamis, misalnya dengan konfigurasi seperti dynamic = 'force-dynamic'. Konsekuensinya, route tidak boleh diperlakukan seperti output statis. Ini sering dipakai untuk halaman yang bergantung pada cookie, session, header request, atau data yang harus selalu baru.

Aturan praktis: jika halaman harus selalu mencerminkan state request saat ini, pikirkan dynamic route. Jika yang perlu segar hanyalah sebagian data, sering kali lebih baik atur cache di level fetch daripada membuat seluruh route dinamis.

Membedakan Data Statis, Dinamis, force-dynamic, dan no-store

Data statis

Data statis adalah data yang aman untuk digunakan ulang antar-request tanpa harus selalu mengambil ulang dari sumber. Ini tidak berarti datanya tidak pernah berubah; yang dimaksud adalah perubahan tidak harus langsung terlihat pada setiap request.

Contoh:

  • kategori produk
  • daftar brand
  • katalog publik yang cukup diperbarui tiap 5 menit

Pendekatan umum:

async function getCatalog() {
  const res = await fetch('https://api.example.com/products', {
    next: { revalidate: 300, tags: ['products'] }
  })

  if (!res.ok) throw new Error('Gagal memuat katalog')
  return res.json()
}

Dengan konfigurasi ini, Next.js boleh menggunakan hasil cache dan merevalidasi data setiap 300 detik.

Data dinamis

Data dinamis adalah data yang dapat berubah antar-request dan perubahan tersebut penting untuk request saat ini. Namun, tidak semua data dinamis harus membuat seluruh route menjadi force-dynamic. Kadang cukup tandai request tertentu dengan cache: 'no-store'.

Contoh:

  • stok real-time pada checkout
  • profil pengguna saat ini berdasarkan session
  • daftar pesanan admin yang terus berubah

Kapan memakai force-dynamic

Gunakan force-dynamic ketika Anda memang ingin route dirender ulang di setiap request dan tidak ingin Next.js memperlakukan output route sebagai statis.

export const dynamic = 'force-dynamic'

export default async function AdminPage() {
  const res = await fetch('https://api.example.com/admin/orders', {
    cache: 'no-store'
  })

  const orders = await res.json()
  return <AdminOrders orders={orders} />
}

Ini cocok untuk halaman admin yang sensitif terhadap freshness. Trade-off-nya jelas: performa dan kemampuan cache turun, tetapi konsistensi data meningkat.

Kapan memakai cache: 'no-store'

no-store memberi tahu bahwa respons fetch tidak boleh disimpan dan harus selalu diambil ulang. Ini berguna jika hanya request tertentu yang harus selalu fresh.

async function getAdminProducts() {
  const res = await fetch('https://api.example.com/admin/products', {
    cache: 'no-store'
  })

  if (!res.ok) throw new Error('Gagal memuat data admin')
  return res.json()
}

Jika Anda menggunakan no-store secara agresif di seluruh aplikasi, Anda memang menghilangkan kebingungan soal stale data, tetapi Anda juga kehilangan banyak manfaat caching. Biasanya ini tanda bahwa desain invalidasi perlu diperbaiki, bukan semua cache dimatikan.

Studi Kasus: API Produk, Katalog, dan Halaman Admin

Katalog publik: cepat, boleh sedikit stale

Misalkan Anda memiliki halaman /products yang menampilkan daftar produk. Halaman ini menerima trafik tinggi, tetapi perubahan harga dan stok tidak harus muncul detik itu juga untuk semua pengunjung.

export default async function ProductsPage() {
  const res = await fetch('https://api.example.com/products', {
    next: { revalidate: 300, tags: ['products'] }
  })

  if (!res.ok) throw new Error('Gagal memuat produk')
  const products = await res.json()

  return <ProductCatalog products={products} />
}

Pendekatan ini cocok bila:

  • trafik tinggi
  • respons API relatif mahal
  • stale beberapa menit masih dapat diterima

Jika ada halaman detail produk seperti /products/[slug], Anda bisa menggunakan tag cache per produk agar invalidasi lebih terarah.

async function getProduct(slug) {
  const res = await fetch(`https://api.example.com/products/${slug}`, {
    next: { revalidate: 300, tags: [`product:${slug}`] }
  })

  if (!res.ok) throw new Error('Produk tidak ditemukan')
  return res.json()
}

Halaman admin: harus selalu segar

Berbeda dari katalog, halaman admin biasanya dipakai untuk operasi sensitif: ubah harga, aktifkan produk, cek order masuk, lihat stok terbaru. Menampilkan data yang stale di sini dapat memicu keputusan bisnis yang salah.

export const dynamic = 'force-dynamic'

export default async function AdminProductsPage() {
  const res = await fetch('https://api.example.com/admin/products', {
    cache: 'no-store',
    headers: {
      Authorization: `Bearer ${process.env.ADMIN_API_TOKEN}`
    }
  })

  if (!res.ok) throw new Error('Gagal memuat produk admin')
  const products = await res.json()

  return <AdminProductTable products={products} />
}

Kombinasi force-dynamic dan no-store memang terasa ketat, tetapi untuk dashboard admin ini sering kali justru pilihan paling aman dan mudah dipahami.

Menggabungkan strategi: halaman publik cepat, mutasi admin memicu invalidasi

Skenario yang lebih presisi adalah: halaman publik tetap di-cache, tetapi setiap kali admin memperbarui produk, cache publik ikut dibersihkan. Di sinilah revalidasi berbasis event lebih unggul dibanding menunggu interval waktu.

Contoh route handler untuk update produk:

import { revalidateTag, revalidatePath } from 'next/cache'
import { NextResponse } from 'next/server'

export async function POST(req) {
  const body = await req.json()

  const updateRes = await fetch(`https://api.example.com/admin/products/${body.id}`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body)
  })

  if (!updateRes.ok) {
    return NextResponse.json({ error: 'Update gagal' }, { status: 500 })
  }

  revalidateTag('products')
  revalidateTag(`product:${body.slug}`)
  revalidatePath('/products')
  revalidatePath(`/products/${body.slug}`)

  return NextResponse.json({ ok: true })
}

Dengan pola ini:

  • katalog tetap cepat karena cache aktif
  • detail produk tetap cepat
  • begitu admin update data, halaman publik terkait dapat diperbarui lebih cepat tanpa menunggu timeout revalidate

Memilih Revalidasi Berbasis Waktu atau Event

Revalidasi berbasis waktu

Gunakan ketika:

  • perubahan data terjadi rutin tetapi tidak harus instan
  • Anda ingin implementasi sederhana
  • sumber data tidak punya hook/event saat data berubah

Kelebihan:

  • mudah diterapkan
  • stabil untuk data publik
  • tidak butuh alur invalidasi tambahan

Kekurangan:

  • selalu ada jendela stale
  • sulit presisi jika perubahan tidak teratur

Revalidasi berbasis event

Gunakan ketika:

  • ada aksi mutasi yang jelas, misalnya update produk
  • Anda ingin cache cepat tetapi tetap responsif terhadap perubahan
  • Anda bisa memicu invalidasi dari route handler, server action, atau webhook

Kelebihan:

  • lebih presisi
  • cache publik tetap efisien
  • stale window bisa diperkecil drastis

Kekurangan:

  • alur lebih kompleks
  • mudah lupa meng-invalidasi semua tag/path yang relevan

Dalam praktiknya, banyak aplikasi menggunakan kombinasi keduanya: ada revalidate sebagai fallback, lalu event invalidation untuk update penting.

Kesalahan Umum yang Membuat Cache Tidak Sesuai Ekspektasi

Menganggap satu fetch dinamis membuat seluruh aplikasi selalu segar

Tidak selalu. Anda perlu melihat apakah route, output render, dan request lain di dalam route tersebut juga sesuai dengan kebutuhan freshness. Satu request no-store tidak otomatis memperbaiki semua stale data lain.

Terlalu cepat memakai force-dynamic

Ini memang menyelesaikan banyak kebingungan, tetapi sering berlebihan. Jika hanya daftar notifikasi yang harus fresh, belum tentu seluruh route perlu dinamis. Coba pecah tanggung jawab cache di level fetch.

Lupa bahwa halaman publik dan admin punya kebutuhan berbeda

Jangan samakan strategi cache untuk dua konteks ini. Publik biasanya mengejar throughput dan latency, sementara admin mengejar akurasi dan freshness.

Tidak memberi tag cache yang cukup spesifik

Jika semua data hanya memakai tag products, setiap perubahan kecil bisa membuat invalidasi terlalu luas. Tambahkan tag granular seperti product:slug atau category:sepatu bila memang dibutuhkan.

Tips Debugging Saat Cache Terasa “Aneh”

1. Audit setiap fetch() di route

Periksa apakah masing-masing request menggunakan:

  • cache: 'no-store'
  • next: { revalidate: ... }
  • tag cache yang benar

Sering kali bug bukan di route utama, tetapi di helper function yang ternyata masih memakai konfigurasi cache default yang tidak sesuai.

2. Bedakan bug data backend dengan bug cache Next.js

Uji endpoint API secara langsung dengan curl atau Postman. Jika API sendiri masih mengembalikan data lama, masalahnya bukan di Next.js.

curl https://api.example.com/products
curl https://api.example.com/admin/products

3. Cek alur mutasi dan invalidasi

Jika admin mengubah produk tetapi katalog tidak ikut berubah, tanyakan:

  • apakah update benar-benar berhasil di backend?
  • apakah revalidateTag atau revalidatePath benar-benar dipanggil?
  • apakah tag yang di-invalidasi sama dengan tag saat fetch?

Mismatch kecil seperti product-123 versus product:123 sudah cukup membuat invalidasi gagal.

4. Log metadata penting saat debugging

Saat perlu, tambahkan logging di server untuk melihat path, parameter, timestamp, dan jalur mutasi. Ini membantu membedakan apakah stale data berasal dari cache aplikasi, API upstream, atau CDN.

5. Mulai dari strategi sederhana, lalu tambah presisi

Jika tim masih sering bingung, jangan langsung mendesain cache yang terlalu rumit. Mulai dengan aturan yang mudah dipahami:

  • publik: revalidate + tag
  • admin: force-dynamic + no-store
  • mutasi: invalidasi path/tag yang relevan

Setelah stabil, baru optimalkan granularitasnya.

Rekomendasi Pengambilan Keputusan

Berikut panduan ringkas yang biasanya efektif:

  1. Gunakan cache default atau revalidate untuk halaman publik yang bisa sedikit stale.
  2. Gunakan no-store untuk data yang benar-benar harus fresh pada setiap request.
  3. Gunakan force-dynamic bila seluruh route harus dirender dinamis dan bergantung pada request saat ini.
  4. Gunakan tag cache untuk invalidasi yang lebih presisi daripada sekadar refresh seluruh route.
  5. Gabungkan revalidasi waktu dan event agar sistem tetap cepat sekaligus responsif terhadap perubahan.

Poin terpentingnya adalah ini: caching di Next.js 16 bukan fitur tunggal, melainkan sistem keputusan. Jika Anda menentukan dengan jelas mana data yang boleh stale, mana yang harus segar, dan kapan invalidasi harus terjadi, Anda bisa mendapatkan kombinasi yang sehat antara performa dan konsistensi.

Untuk aplikasi produk, strategi yang paling sering berhasil adalah: katalog publik di-cache dengan revalidate dan tag, sedangkan halaman admin dibuat selalu segar dengan route dinamis dan fetch no-store. Dari sana, tambahkan invalidasi berbasis event agar perubahan dari admin segera tercermin di halaman publik tanpa mengorbankan kecepatan seluruh aplikasi.