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
cacheTagdengan 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:
- Cache entry yang terkait ditandai sebagai stale
- Request berikutnya masih mendapat data lama dari cache (sajikan instan)
- Di background, Next.js mengambil data baru dan memperbarui cache
- 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:
- Pada saat build time, Next.js merender shell statis — layout, navigasi, dan konten yang tidak bergantung pada data request-time
- Komponen yang membutuhkan data dinamis (dibungkus
<Suspense>) diganti dengan fallback UI - Saat pengguna mengakses halaman, shell statis langsung disajikan dari edge cache/CDN
- 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:
- Browser menerima shell statis beserta fallback UI untuk semua Suspense boundary
- Pengguna langsung melihat struktur halaman — header, navigasi, skeleton — tanpa blank screen
- Setiap komponen dinamis yang selesai di-render langsung di-stream dan menggantikan fallback-nya
- 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.