Panduan Lengkap Data Fetching & Caching di Next.js 16: use cache, PPR, dan Strategi Revalidasi

Bingung dengan model caching baru di Next.js 16? Panduan ini kupas tuntas strategi data fetching — dari pola paralel, direktif "use cache", hingga Partial Prerendering — lengkap dengan contoh kode siap pakai.

Pendahuluan: Era Baru Pengambilan Data di Next.js 16

Kalau Anda sudah baca panduan kami soal fitur-fitur utama Next.js 16 dan Server Actions, pasti sudah cukup familiar dengan konsep Cache Components dan direktif "use cache". Tapi di balik semua itu, ada satu pertanyaan yang rasanya selalu muncul di setiap project: bagaimana cara mengambil dan meng-cache data dengan benar di Next.js 16?

Kedengarannya sederhana. Tapi jawabannya, percayalah, jauh dari itu.

Next.js 16 membawa perubahan paradigma yang cukup drastis — dari model caching implisit yang "ajaib" (dan sering bikin frustrasi saat debugging) ke model eksplisit yang jauh lebih terkontrol. Ditambah lagi dengan hadirnya Partial Prerendering (PPR) sebagai model rendering default baru, cara kita memikirkan data fetching benar-benar berubah. Bukan sekadar upgrade minor, ini perubahan cara berpikir.

Dalam panduan ini, kita akan bahas tuntas strategi data fetching dan caching di Next.js 16 — mulai dari pola-pola dasar, penggunaan "use cache" yang tepat sasaran, konfigurasi cacheLife dan cacheTag, mekanisme revalidasi, sampai bagaimana PPR mengubah arsitektur aplikasi Anda. Semua dilengkapi contoh kode yang (semoga) langsung bisa dipakai di proyek nyata.

Memahami Pola Data Fetching di React Server Components

Mengambil Data Langsung di Server Components

Salah satu keunggulan terbesar React Server Components (RSC) — yang mungkin masih underrated — adalah kemampuan mengambil data langsung di server. Tanpa perlu membuat API route terpisah, tanpa useEffect, dan tanpa state management library yang kadang lebih ribet dari masalahnya sendiri. Cukup pakai async/await seperti kode backend biasa:

// app/products/page.tsx
export default async function ProductsPage() {
  const products = await db.query('SELECT * FROM products WHERE active = true')

  return (
    <div>
      <h1>Daftar Produk</h1>
      {products.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  )
}

Kode di atas berjalan sepenuhnya di server. Query database dilakukan langsung tanpa melewati layer API tambahan, dan hasilnya dikirim ke klien sebagai HTML yang sudah di-render. Tidak ada JavaScript ekstra yang dikirim ke browser untuk komponen ini.

Ini bukan sekadar kemudahan sintaks — ini perubahan arsitektural yang cukup fundamental. Dengan menjalankan data fetching di server, Anda dapat akses langsung ke database, file system, dan layanan internal tanpa perlu mengekspos endpoint publik. Bayangkan bisa query database langsung dari komponen tanpa pusing soal CORS, API key yang bocor, atau latensi tambahan dari network hop ekstra.

Pola Paralel: Menghindari Request Waterfall

Request waterfall adalah pembunuh performa nomor satu di aplikasi web modern. Ini terjadi ketika satu request harus selesai dulu sebelum request berikutnya bisa dimulai — padahal sebetulnya mereka bisa berjalan bersamaan tanpa ada ketergantungan satu sama lain.

Perhatikan contoh anti-pattern berikut ini (saya yakin banyak yang pernah nulis kode kayak gini):

// ❌ Anti-pattern: Sequential fetching (waterfall)
export default async function DashboardPage() {
  const user = await fetchUser()           // 200ms
  const orders = await fetchOrders()        // 300ms
  const analytics = await fetchAnalytics()  // 250ms
  // Total: 750ms (sequential)

  return (
    <Dashboard user={user} orders={orders} analytics={analytics} />
  )
}

Pada contoh di atas, setiap await memblokir eksekusi — fetchOrders() baru dimulai setelah fetchUser() selesai, dan seterusnya. Total waktu tunggu adalah penjumlahan dari semua request, yang jelas tidak efisien.

Solusinya? Gunakan Promise.all() untuk menjalankan request yang independen secara paralel:

// ✅ Best practice: Parallel fetching
export default async function DashboardPage() {
  const [user, orders, analytics] = await Promise.all([
    fetchUser(),           // 200ms
    fetchOrders(),         // 300ms
    fetchAnalytics(),      // 250ms
  ])
  // Total: ~300ms (parallel, ditentukan oleh request terlambat)

  return (
    <Dashboard user={user} orders={orders} analytics={analytics} />
  )
}

Dengan Promise.all(), ketiga request dimulai bersamaan. Total waktu tunggu hanya sebesar request yang paling lambat — dalam kasus ini 300ms, bukan 750ms. Itu penghematan lebih dari 50% hanya dengan satu perubahan kecil!

Pola yang Lebih Baik: Sibling Server Components

Tapi ada pendekatan yang bahkan lebih elegan: pecah data fetching ke dalam sibling Server Components yang masing-masing di-wrap dengan <Suspense>:

// app/dashboard/page.tsx
import { Suspense } from 'react'
import UserInfo from './user-info'
import OrderList from './order-list'
import AnalyticsChart from './analytics-chart'

export default function DashboardPage() {
  return (
    <div className="grid grid-cols-3 gap-4">
      <Suspense fallback={<UserSkeleton />}>
        <UserInfo />
      </Suspense>
      <Suspense fallback={<OrderSkeleton />}>
        <OrderList />
      </Suspense>
      <Suspense fallback={<ChartSkeleton />}>
        <AnalyticsChart />
      </Suspense>
    </div>
  )
}

// app/dashboard/user-info.tsx
export default async function UserInfo() {
  const user = await fetchUser()
  return <div>{user.name}</div>
}

// app/dashboard/order-list.tsx
export default async function OrderList() {
  const orders = await fetchOrders()
  return <ul>{orders.map(o => <li key={o.id}>{o.title}</li>)}</ul>
}

Pendekatan ini punya beberapa keunggulan nyata dibanding Promise.all():

  • Streaming otomatis — setiap komponen di-render dan dikirim ke browser segera setelah datanya tersedia, tanpa menunggu komponen lain selesai
  • Isolasi error — jika satu komponen gagal, yang lain tetap tampil normal
  • Loading state granular — setiap bagian punya skeleton/placeholder-nya sendiri, bukan satu spinner besar yang menutupi semuanya
  • Co-location — data fetching dan rendering berada di tempat yang sama, memudahkan maintenance jangka panjang

Pola Sequential yang Memang Disengaja

Tidak semua sequential fetching itu buruk, ya. Kadang memang ada dependensi data yang nyata dan tidak bisa dihindari:

// ✅ Sequential yang wajar: data dependent
export default async function UserProfilePage({ params }: { params: { id: string } }) {
  const user = await fetchUser(params.id)
  const recommendations = await fetchRecommendations(user.preferences)

  return <Profile user={user} recommendations={recommendations} />
}

Di sini, fetchRecommendations() memang membutuhkan data dari fetchUser() — yaitu user.preferences. Ini bukan waterfall yang bisa dihindari, ini dependensi data yang sesungguhnya. Kuncinya adalah bisa membedakan mana waterfall yang "tidak sengaja" dan mana yang memang diperlukan secara logis.

Menguasai Direktif "use cache" di Next.js 16

Dari Caching Implisit ke Eksplisit

Di Next.js versi sebelumnya (14 dan 15), caching terjadi secara implisit. fetch() di-cache secara default, halaman statis di-cache otomatis, dan banyak pengembang — termasuk saya sendiri — sering kebingungan kenapa data mereka "stuck" atau tidak kunjung update. Model ini, meski performant, menciptakan banyak bug yang sangat menyebalkan untuk di-debug.

Next.js 16 mengubah filosofi ini secara mendasar: tidak ada yang di-cache kecuali Anda secara eksplisit memintanya. Ini dilakukan melalui direktif "use cache":

// Caching di level fungsi
async function getProducts() {
  'use cache'
  return await db.query('SELECT * FROM products')
}

// Caching di level komponen
async function ProductList() {
  'use cache'
  const products = await db.query('SELECT * FROM products')
  return (
    <ul>
      {products.map(p => <li key={p.id}>{p.name}</li>)}
    </ul>
  )
}

// Caching di level halaman (letakkan di paling atas file)
'use cache'
export default async function ProductsPage() {
  const products = await db.query('SELECT * FROM products')
  return <ProductGrid products={products} />
}

Direktif "use cache" bekerja mirip dengan "use client" dan "use server" — ini adalah instruksi untuk compiler Next.js bahwa output dari fungsi atau komponen ini boleh di-cache dan di-reuse. Eksplisit, jelas, dan tidak ada "keajaiban" tersembunyi.

Mengaktifkan Cache Components

Sebelum mulai menggunakan "use cache", pastikan fiturnya sudah diaktifkan di konfigurasi:

// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  cacheComponents: true, // Mengaktifkan Cache Components dan PPR
}

export default nextConfig

Flag cacheComponents: true mengaktifkan dua hal sekaligus: Cache Components (termasuk direktif "use cache") dan Partial Prerendering. Keduanya saling terkait erat dan dirancang untuk bekerja sebagai satu kesatuan.

Apa Saja yang Bisa Di-cache?

Berbeda dengan unstable_cache di versi sebelumnya yang hanya bisa menyimpan data JSON, "use cache" jauh lebih fleksibel — bisa meng-cache:

  • Data serializable — objek, array, string, number
  • Komponen React — termasuk seluruh output JSX
  • Seluruh halaman — saat diletakkan di level file

Compiler Next.js secara otomatis menghasilkan cache key berdasarkan parameter fungsi dan dependensi data. Anda tidak perlu mendefinisikan key secara manual — ini mengurangi kemungkinan cache key yang salah atau collision yang susah dideteksi.

Konfigurasi Cache dengan cacheLife dan cacheTag

cacheLife: Mengatur Durasi Cache

Tidak semua data punya kebutuhan caching yang sama — ini hal yang sering diabaikan. Data produk mungkin cukup di-update beberapa kali sehari, tapi feed berita perlu selalu fresh. Di sinilah cacheLife berperan:

import { cacheLife } from 'next/cache'

async function getProducts() {
  'use cache'
  cacheLife('hours')
  return await db.query('SELECT * FROM products')
}

async function getNewsFeed() {
  'use cache'
  cacheLife('minutes')
  return await fetchLatestNews()
}

async function getStaticContent() {
  'use cache'
  cacheLife('max')
  return await fetchAboutPageContent()
}

Next.js 16 menyediakan profil bawaan yang bisa langsung dipakai tanpa konfigurasi tambahan:

Profil Stale (detik) Revalidate (detik) Expire (detik) Cocok Untuk
'minutes' 60 300 3.600 Data yang sering berubah (feed, notifikasi)
'hours' 3.600 14.400 86.400 Data produk, katalog, harga
'days' 86.400 604.800 2.592.000 Konten blog, artikel, dokumentasi
'max' 2.592.000 31.536.000 Konten statis yang jarang berubah

Anda juga bisa menggunakan konfigurasi kustom dengan objek — dan ini yang membuat cacheLife benar-benar powerful:

import { cacheLife } from 'next/cache'

async function getPostContent(slug: string) {
  'use cache'
  const post = await fetchPost(slug)

  if (!post) {
    cacheLife('minutes') // Cache pendek untuk 404 agar cepat ter-update
    return null
  }

  cacheLife({
    stale: 300,        // Sajikan dari cache selama 5 menit
    revalidate: 3600,  // Revalidate di background setiap 1 jam
    expire: 86400,     // Hapus dari cache setelah 24 jam
  })

  return post
}

Perhatikan bagaimana kita bisa menggunakan cacheLife secara kondisional — untuk kasus post yang tidak ditemukan, gunakan durasi cache pendek supaya ketika konten baru dibuat, ia segera muncul. Detail kecil tapi berdampak besar di production.

cacheTag: Melabeli Cache untuk Invalidasi Terarah

Kalau cacheLife mengatur kapan cache expired, maka cacheTag mengatur bagaimana kita bisa menginvalidasi cache secara spesifik tanpa harus menghapus semuanya:

import { cacheLife, cacheTag } from 'next/cache'

async function getProduct(id: string) {
  'use cache'
  cacheLife('hours')
  cacheTag('products', `product-${id}`)

  return await db.query('SELECT * FROM products WHERE id = ?', [id])
}

async function getCategoryProducts(categorySlug: string) {
  'use cache'
  cacheLife('hours')
  cacheTag('products', `category-${categorySlug}`)

  return await db.query(
    'SELECT * FROM products WHERE category_slug = ?',
    [categorySlug]
  )
}

Perhatikan bagaimana kedua fungsi di atas berbagi tag 'products'. Artinya ketika kita menginvalidasi tag 'products', semua cache yang terkait produk akan di-refresh — baik detail produk individual maupun daftar produk per kategori. Sekali tembak, banyak yang kena (dalam artian yang baik).

Beberapa aturan penting untuk cacheTag:

  • Satu cache entry bisa punya beberapa tag — cukup panggil cacheTag dengan multiple string
  • Maksimal 128 tag per cache entry dengan panjang maksimal 256 karakter per tag
  • Pemanggilan tag yang sama berkali-kali bersifat idempoten — tidak ada efek duplikasi yang perlu dikhawatirkan

Mekanisme Revalidasi: revalidateTag vs updateTag

revalidateTag: Revalidasi di Background (Stale-While-Revalidate)

Di Next.js 16, revalidateTag mengalami perubahan yang cukup signifikan — sekarang membutuhkan argumen kedua berupa profil cacheLife untuk mengaktifkan perilaku stale-while-revalidate:

'use server'

import { revalidateTag } from 'next/cache'

export async function updateProduct(id: string, data: FormData) {
  await db.query('UPDATE products SET name = ? WHERE id = ?', [
    data.get('name'),
    id
  ])

  // ✅ Recommended: SWR behavior dengan profil
  revalidateTag(`product-${id}`, 'max')
  revalidateTag('products', 'hours')
}

export async function publishNewArticle(data: FormData) {
  await db.query('INSERT INTO articles ...', [...])

  // Revalidate daftar artikel dengan durasi kustom
  revalidateTag('articles', { expire: 3600 })
}

Ketika revalidateTag dipanggil dengan profil 'max', ini yang terjadi di balik layar:

  1. Cache entry yang terkait ditandai sebagai stale
  2. Request berikutnya masih mendapat data lama dari cache (sajikan instan)
  3. Di background, Next.js mengambil data baru dan memperbarui cache
  4. Request setelahnya mendapat data yang sudah fresh

Ini adalah pola stale-while-revalidate (SWR) yang sudah terbukti efektif di production — pengguna tidak perlu menunggu, tapi data tetap diperbarui secara berkala.

Penting: bentuk argumen tunggal revalidateTag('tag') sudah deprecated. Pastikan selalu menyertakan argumen kedua, atau Anda akan dapat peringatan yang mengganggu.

updateTag: Invalidasi Langsung untuk Read-Your-Writes

Ada skenario tertentu di mana pengguna harus langsung melihat perubahan yang mereka buat sendiri — misalnya setelah mengedit profil atau memperbarui postingan. Kalau pakai SWR, mereka bisa saja masih lihat data lama untuk beberapa saat. Di sinilah updateTag dibutuhkan:

'use server'

import { updateTag } from 'next/cache'

export async function updateUserProfile(data: FormData) {
  const userId = await getCurrentUserId()

  await db.query('UPDATE users SET name = ?, bio = ? WHERE id = ?', [
    data.get('name'),
    data.get('bio'),
    userId
  ])

  // Langsung expire — request berikutnya PASTI mendapat data baru
  updateTag(`user-${userId}`)
}

Perbedaan kunci antara keduanya — ini yang sering bikin bingung:

Aspek revalidateTag updateTag
Perilaku Stale-while-revalidate Invalidasi langsung
Bisa dipanggil di Server Actions & Route Handlers Server Actions saja
Pengalaman pengguna Respons instan, update di background Menunggu data baru, tapi pasti fresh
Gunakan ketika Update oleh admin/sistem, data yang bisa sedikit stale Update oleh pengguna sendiri, harus lihat perubahan langsung

Strategi Revalidasi di Proyek Nyata

Dalam aplikasi e-commerce, Anda kemungkinan besar akan menggabungkan keduanya sesuai konteks:

'use server'

import { revalidateTag, updateTag } from 'next/cache'

export async function adminUpdatePrice(productId: string, newPrice: number) {
  await db.query('UPDATE products SET price = ? WHERE id = ?', [newPrice, productId])

  // Admin update: SWR cukup, pengguna lain akan lihat update segera
  revalidateTag(`product-${productId}`, 'max')
  revalidateTag('products', 'hours')
}

export async function userUpdateCart(cartId: string, items: CartItem[]) {
  await db.query('UPDATE carts SET items = ? WHERE id = ?', [JSON.stringify(items), cartId])

  // User update: harus langsung terlihat
  updateTag(`cart-${cartId}`)
}

Partial Prerendering (PPR): Model Rendering Masa Depan

Apa Itu PPR dan Mengapa Penting?

Partial Prerendering adalah model rendering baru yang — menurut saya — akhirnya menghilangkan dilema statis vs dinamis yang selama ini selalu jadi perdebatan. Alih-alih harus memilih antara SSG (seluruh halaman statis) atau SSR (seluruh halaman dinamis), PPR memungkinkan satu halaman memiliki bagian statis DAN dinamis sekaligus.

Cara kerjanya sederhana tapi powerful:

  1. Pada saat build time, Next.js merender shell statis — layout, navigasi, dan konten yang tidak bergantung pada data request-time
  2. Komponen yang membutuhkan data dinamis (dibungkus <Suspense>) diganti dengan fallback UI
  3. Saat pengguna mengakses halaman, shell statis langsung disajikan dari edge cache/CDN
  4. Bagian dinamis di-stream dari server dan menggantikan fallback secara progresif

Hasilnya? TTFB (Time to First Byte) yang super cepat karena shell statis disajikan dari CDN — dikombinasikan dengan data real-time yang di-stream dari server. Pengguna melihat konten langsung, bukan layar kosong atau loading spinner selama beberapa detik.

PPR dalam Praktik

Mari kita lihat contoh halaman produk e-commerce yang menggunakan PPR secara menyeluruh:

// app/products/[slug]/page.tsx
import { Suspense } from 'react'
import ProductInfo from './product-info'
import ReviewSection from './review-section'
import RecommendedProducts from './recommended'
import PricingWidget from './pricing-widget'

export default async function ProductPage({ params }: { params: { slug: string } }) {
  return (
    <div className="max-w-6xl mx-auto">
      {/* Static shell - di-prerender saat build */}
      <header>
        <nav>{/* Navigasi statis */}</nav>
      </header>

      {/* Konten produk - bisa di-cache dengan use cache */}
      <Suspense fallback={<ProductInfoSkeleton />}>
        <ProductInfo slug={params.slug} />
      </Suspense>

      {/* Harga dinamis - selalu fresh dari server */}
      <Suspense fallback={<PricingSkeleton />}>
        <PricingWidget slug={params.slug} />
      </Suspense>

      {/* Review - bisa sedikit stale */}
      <Suspense fallback={<ReviewSkeleton />}>
        <ReviewSection slug={params.slug} />
      </Suspense>

      {/* Rekomendasi - personalized per user */}
      <Suspense fallback={<RecommendedSkeleton />}>
        <RecommendedProducts slug={params.slug} />
      </Suspense>

      <footer>{/* Footer statis */}</footer>
    </div>
  )
}

Dan implementasi komponen-komponennya dengan strategi caching yang berbeda-beda sesuai kebutuhan:

// app/products/[slug]/product-info.tsx
import { cacheLife, cacheTag } from 'next/cache'

export default async function ProductInfo({ slug }: { slug: string }) {
  'use cache'
  cacheLife('hours')
  cacheTag('products', `product-${slug}`)

  const product = await db.query(
    'SELECT * FROM products WHERE slug = ?', [slug]
  )

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <img src={product.imageUrl} alt={product.name} />
    </div>
  )
}

// app/products/[slug]/pricing-widget.tsx
// Tanpa 'use cache' — selalu dinamis
import { cookies } from 'next/headers'

export default async function PricingWidget({ slug }: { slug: string }) {
  const region = (await cookies()).get('user-region')?.value ?? 'ID'
  const pricing = await fetchPricing(slug, region)

  return (
    <div className="pricing">
      <span className="price">{pricing.formatted}</span>
      {pricing.discount && <span className="discount">Hemat {pricing.discount}%</span>}
    </div>
  )
}

// app/products/[slug]/review-section.tsx
import { cacheLife, cacheTag } from 'next/cache'

export default async function ReviewSection({ slug }: { slug: string }) {
  'use cache'
  cacheLife('minutes')
  cacheTag(`reviews-${slug}`)

  const reviews = await db.query(
    'SELECT * FROM reviews WHERE product_slug = ? ORDER BY created_at DESC LIMIT 10',
    [slug]
  )

  return (
    <div>
      <h2>Ulasan Pelanggan</h2>
      {reviews.map(review => (
        <ReviewCard key={review.id} review={review} />
      ))}
    </div>
  )
}

Pada contoh di atas, setiap bagian halaman punya strategi caching yang berbeda sesuai kebutuhannya — info produk di-cache berjam-jam, harga selalu dinamis karena bergantung pada region pengguna, dan review di-cache beberapa menit. PPR menyatukan semua ini dalam satu halaman yang terasa cepat dan responsif.

Dampak Performa PPR

Berdasarkan pengujian yang ada, PPR secara dramatis meningkatkan performa halaman secara keseluruhan:

  • TTFB turun dari 350-550ms menjadi 40-90ms — karena shell statis disajikan dari edge cache, bukan menunggu semua data di-fetch terlebih dahulu
  • LCP (Largest Contentful Paint) meningkat signifikan — elemen statis utama langsung terlihat tanpa delay yang tidak perlu
  • CLS (Cumulative Layout Shift) minimal — selama fallback skeleton memiliki dimensi yang sesuai dengan konten final

Streaming dan Suspense: Mengoptimalkan Pengalaman Pengguna

Cara Kerja Streaming di PPR

Ketika PPR diaktifkan, Next.js mengirim respons dalam satu HTTP request, tapi kontennya dikirim secara bertahap alias streaming:

  1. Browser menerima shell statis beserta fallback UI untuk semua Suspense boundary
  2. Pengguna langsung melihat struktur halaman — header, navigasi, skeleton — tanpa blank screen
  3. Setiap komponen dinamis yang selesai di-render langsung di-stream dan menggantikan fallback-nya
  4. Ini terjadi secara paralel — komponen review bisa muncul sebelum pricing widget, tergantung mana yang selesai duluan

Best Practices untuk Suspense Boundaries

Penempatan <Suspense> boundary sangat menentukan kualitas UX streaming — ini detail yang sering diremehkan tapi dampaknya besar:

// ✅ Best practice: Dimension-matched skeleton
function ProductInfoSkeleton() {
  return (
    <div className="animate-pulse">
      <div className="h-8 bg-gray-200 rounded w-3/4 mb-4"></div>
      <div className="h-4 bg-gray-200 rounded w-full mb-2"></div>
      <div className="h-4 bg-gray-200 rounded w-5/6 mb-2"></div>
      <div className="h-64 bg-gray-200 rounded w-full"></div>
    </div>
  )
}

// ❌ Anti-pattern: Generic spinner yang ukurannya beda
function BadSkeleton() {
  return <div className="spinner">Loading...</div>
}

Aturan sederhananya: skeleton harus memiliki dimensi yang sama persis dengan konten final. Ini mencegah layout shift (CLS) saat konten asli menggantikan skeleton — yang kalau dibiarkan, skornya di Core Web Vitals bisa jeblok.

Streaming Data ke Client Components

Ada kalanya Anda perlu mengirim data dari server ke client component yang interaktif. Gunakan React use API untuk ini — pola yang cukup elegan kalau sudah terbiasa:

// app/dashboard/page.tsx (Server Component)
import { Suspense } from 'react'
import StockTicker from './stock-ticker'

export default function DashboardPage() {
  const stockPromise = fetchStockData() // Mulai fetch, tapi jangan await

  return (
    <Suspense fallback={<StockSkeleton />}>
      <StockTicker dataPromise={stockPromise} />
    </Suspense>
  )
}

// app/dashboard/stock-ticker.tsx (Client Component)
'use client'

import { use } from 'react'

export default function StockTicker({ dataPromise }: { dataPromise: Promise<StockData[]> }) {
  const stocks = use(dataPromise) // Resolve promise di client

  return (
    <div className="stock-ticker">
      {stocks.map(stock => (
        <span key={stock.symbol}>
          {stock.symbol}: {stock.price}
        </span>
      ))}
    </div>
  )
}

Pola ini sangat powerful: data fetching dimulai di server (dekat dengan data source), tapi hasilnya di-resolve dan di-render di client component yang interaktif. Ini menggabungkan keunggulan server-side fetching dengan interaktivitas client-side — yang sering kali jadi kebutuhan di aplikasi dashboard atau real-time.

Menghindari Jebakan Umum

1. Jangan Cache Segalanya

Kesalahan yang paling sering terjadi setelah tim upgrade ke Next.js 16 adalah semangat menambahkan "use cache" ke mana-mana — termasuk komponen yang justru membutuhkan data real-time:

// ❌ Jangan cache data yang bergantung pada request-time
async function UserGreeting() {
  'use cache' // Ini akan meng-cache greeting untuk SEMUA user!
  const user = await getCurrentUser()
  return <h1>Halo, {user.name}!</h1>
}

// ✅ Biarkan dinamis untuk data per-user
async function UserGreeting() {
  const user = await getCurrentUser()
  return <h1>Halo, {user.name}!</h1>
}

2. Hindari Build Hang karena Dynamic Data di Cache Boundary

Salah satu jebakan tersembunyi yang cukup menyebalkan: jika Anda mengakses Promise yang resolve ke data dinamis (seperti cookies atau headers) di dalam "use cache" boundary, build Anda bisa hang selama 50 detik sebelum akhirnya timeout:

// ❌ Akan menyebabkan build hang!
async function getPersonalizedContent() {
  'use cache'
  const cookieStore = await cookies() // Dynamic data di cache boundary
  const userId = cookieStore.get('user-id')
  return await fetchContent(userId)
}

// ✅ Pisahkan dynamic dan cached logic
async function getContent(contentId: string) {
  'use cache'
  cacheTag(`content-${contentId}`)
  return await db.query('SELECT * FROM content WHERE id = ?', [contentId])
}

async function PersonalizedSection() {
  const cookieStore = await cookies()
  const userId = cookieStore.get('user-id')?.value
  const contentId = await fetchUserContentId(userId)
  const content = await getContent(contentId) // Cache by contentId, bukan per user
  return <div>{content.body}</div>
}

3. Perhatikan Caching di Self-Hosted Deployment

Kalau Anda men-deploy aplikasi secara self-hosted (bukan di Vercel), ada hal penting yang harus diperhatikan: "use cache" menggunakan in-memory cache secara default. Artinya, jika Anda menjalankan multiple instances (misalnya di Kubernetes), setiap instance punya cache-nya sendiri yang tidak sinkron satu sama lain.

Untuk deployment multi-instance, pertimbangkan menggunakan custom cache handler yang terhubung ke shared cache seperti Redis:

// next.config.ts
const nextConfig: NextConfig = {
  cacheComponents: true,
  cacheHandler: './cache-handler.mjs',
}

// cache-handler.mjs
import Redis from 'ioredis'

const redis = new Redis(process.env.REDIS_URL)

export default class CacheHandler {
  async get(key) {
    const data = await redis.get(key)
    return data ? JSON.parse(data) : null
  }

  async set(key, data, ctx) {
    const ttl = ctx?.revalidate ?? 3600
    await redis.setex(key, ttl, JSON.stringify(data))
  }

  async revalidateTag(tags) {
    for (const tag of tags) {
      const keys = await redis.smembers(`tag:${tag}`)
      if (keys.length) await redis.del(...keys)
      await redis.del(`tag:${tag}`)
    }
  }
}

4. Gunakan no-store Secukupnya

Di era sebelum Next.js 16, banyak pengembang yang "amannya" menggunakan cache: 'no-store' di semua fetch karena bingung dengan perilaku caching implisit. Sekarang, karena default sudah tidak ada cache, Anda tidak perlu lagi menambahkan no-store secara eksplisit — cukup jangan tambahkan "use cache" pada data yang harus selalu fresh. Jauh lebih bersih.

Memilih Strategi yang Tepat: Panduan Keputusan

Berikut tabel ringkasan untuk membantu Anda memilih strategi data fetching yang tepat berdasarkan jenis konten — simpan ini sebagai referensi saat merancang arsitektur halaman:

Jenis Konten Strategi Caching Contoh
Halaman statis (about, FAQ) SSG + PPR shell cacheLife('max') Landing page, kebijakan privasi
Konten editorial (blog, berita) ISR via "use cache" cacheLife('days') + on-demand revalidation Artikel blog, dokumentasi
Katalog produk "use cache" per item cacheLife('hours') + cacheTag Daftar produk, detail produk
Data real-time per user Dynamic (tanpa cache) Tidak di-cache Keranjang belanja, profil, notifikasi
Dashboard analytics "use cache" singkat cacheLife('minutes') Grafik penjualan, statistik pengunjung
Feed sosial/komentar "use cache" + updateTag cacheLife('minutes') Timeline, komentar, review

Kesimpulan: Caching sebagai Keputusan Arsitektur

Next.js 16 telah mengubah caching dari "fitur yang terjadi secara otomatis di belakang layar" menjadi keputusan arsitektur yang sadar dan disengaja. Dengan model opt-in melalui "use cache", ditambah cacheLife dan cacheTag untuk kontrol yang granular, serta mekanisme revalidasi ganda lewat revalidateTag dan updateTag, Anda punya toolkit lengkap untuk membangun aplikasi yang cepat, selalu fresh, dan mudah di-maintain.

Partial Prerendering melengkapi semua ini dengan model rendering hybrid yang menghilangkan kompromi lama antara kecepatan statis dan fleksibilitas dinamis. Satu halaman bisa memiliki bagian yang di-cache selama berminggu-minggu dan bagian yang selalu fresh — semuanya terasa seamless bagi pengguna akhir.

Beberapa takeaway utama dari panduan ini:

  • Default ke tanpa cache di Next.js 16, tambahkan "use cache" hanya ketika Anda yakin data bisa di-cache
  • Gunakan sibling Server Components dengan <Suspense> untuk parallel fetching dan streaming yang lebih granular
  • Pilih profil cacheLife yang sesuai dengan frekuensi perubahan data Anda — jangan semua pakai 'max'
  • Tag cache Anda untuk invalidasi terarah — hindari menginvalidasi seluruh cache hanya karena satu item berubah
  • Gunakan updateTag untuk user actions (read-your-writes) dan revalidateTag untuk background updates
  • Aktifkan PPR untuk mendapat performa terbaik dari kedua dunia — statis dan dinamis dalam satu halaman
  • Buat skeleton yang dimensinya cocok dengan konten final untuk menghindari layout shift yang merusak skor Core Web Vitals

Pada akhirnya, caching bukan lagi fitur yang Anda "on/off" begitu saja — ini bagian integral dari cara Anda merancang arsitektur aplikasi. Dan dengan toolkit yang disediakan Next.js 16, Anda punya kontrol penuh untuk membuatnya bekerja sesuai kebutuhan spesifik project Anda.

Tentang Penulis Editorial Team

Our team of expert writers and editors.