Panduan praktis React Server Components yang saya harap ada saat saya mulai. Mental model, pola nyata, masalah boundary, dan kesalahan yang saya buat agar kamu tidak perlu mengulanginya.
Saya menghabiskan tiga bulan pertama dengan React Server Components sambil berpikir sudah memahaminya. Saya sudah membaca RFC-nya, menonton talk di konferensi, membangun beberapa demo app. Saya salah besar tentang hampir semuanya dengan penuh percaya diri.
Masalahnya bukan karena RSC itu rumit. Masalahnya adalah mental model-nya benar-benar berbeda dari apa pun yang pernah kita lakukan di React sebelumnya, dan semua orang — termasuk saya — mencoba memasukkannya ke kotak lama. "Ini seperti SSR." Bukan. "Ini seperti PHP." Lebih dekat, tapi bukan juga. "Ini cuma komponen yang jalan di server." Secara teknis benar, secara praktis tidak berguna.
Yang berikut adalah semua yang benar-benar perlu saya ketahui, ditulis dengan cara yang saya harap seseorang menjelaskannya kepada saya. Bukan versi teoretis. Versi di mana kamu menatap error serialisasi jam 11 malam dan perlu memahami mengapa.
Lupakan semua yang kamu tahu tentang React rendering sejenak. Ini gambaran barunya.
Dalam React tradisional (client-side), seluruh component tree kamu dikirim ke browser sebagai JavaScript. Browser mengunduhnya, mem-parse-nya, mengeksekusinya, dan merender hasilnya. Setiap komponen — baik itu form interaktif 200 baris atau paragraf teks statis — melewati pipeline yang sama.
React Server Components memecah ini menjadi dua dunia:
Server Components berjalan di server. Mereka dieksekusi sekali, menghasilkan output, dan mengirim hasilnya ke client — bukan kodenya. Browser tidak pernah melihat fungsi komponen, tidak pernah mengunduh dependency-nya, tidak pernah me-render ulang.
Client Components bekerja seperti React tradisional. Mereka dikirim ke browser, di-hydrate, mempertahankan state, menangani event. Mereka adalah React yang sudah kamu kenal.
Insight kunci yang butuh waktu memalukan bagi saya untuk menginternalisasi: Server Components adalah default. Di Next.js App Router, setiap komponen adalah Server Component kecuali kamu secara eksplisit memilihnya ke client dengan "use client". Ini kebalikan dari yang biasa kita lakukan, dan mengubah cara kamu berpikir tentang komposisi.
Server Components bisa:
Server Components tidak bisa:
useState, useReducer)useEffect, useLayoutEffect)onClick, onChange)window, document, localStorage)useContext)Client Components bisa:
Client Components tidak bisa:
async/await (mereka bukan async)Boundary antara Server dan Client Components bukan garis acak — ia adalah keputusan arsitektur. Saat kamu menandai sebuah komponen dengan "use client", komponen itu dan semua yang di-import-nya menjadi kode client.
// Ini adalah Server Component (default)
// Ia bisa async, mengambil data, mengakses database
async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const product = await db.product.findUnique({ where: { id } });
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
{/* AddToCartButton adalah Client Component — ia butuh onClick */}
<AddToCartButton productId={product.id} price={product.price} />
</div>
);
}// src/components/AddToCartButton.tsx
"use client";
import { useState } from "react";
export function AddToCartButton({ productId, price }: {
productId: string;
price: number;
}) {
const [added, setAdded] = useState(false);
return (
<button onClick={() => {
addToCart(productId);
setAdded(true);
}}>
{added ? "Ditambahkan!" : `Tambah ke Keranjang — $${price}`}
</button>
);
}Perhatikan apa yang terjadi di sini: ProductPage mengambil data dari database, me-render nama dan deskripsi produk (yang tidak pernah menjadi JavaScript di browser), dan meneruskan hanya productId dan price ke Client Component. Browser hanya mengunduh kode untuk tombol, bukan halaman, bukan query database, bukan library ORM.
Semua yang melewati boundary Server → Client harus serializable. Ini berarti:
Ini bukan batasan sewenang-wenang. RSC payload dikirim sebagai format wire khusus, dan hanya value serializable yang bisa dikodekan. Ini mirip mengapa kamu tidak bisa mengirim fungsi melalui JSON — sama, cakupannya lebih luas.
Pola terpenting di RSC adalah meneruskan Server Components sebagai children ke Client Components. Ini terlihat sepele. Tapi ini juga cara kamu menghindari menarik seluruh tree ke client.
// ClientSidebar.tsx — Client Component yang mengelola state collapse
"use client";
import { useState } from "react";
export function ClientSidebar({ children }: { children: React.ReactNode }) {
const [collapsed, setCollapsed] = useState(false);
return (
<aside className={collapsed ? "w-16" : "w-64"}>
<button onClick={() => setCollapsed(!collapsed)}>
{collapsed ? ">" : "<"}
</button>
{!collapsed && children}
</aside>
);
}// layout.tsx — Server Component
import { ClientSidebar } from "./ClientSidebar";
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const navItems = await getNavigationItems(); // Database query
return (
<div className="flex">
<ClientSidebar>
{/* Ini adalah konten server-rendered yang mengalir MELALUI client component */}
<nav>
{navItems.map((item) => (
<a key={item.href} href={item.href}>
{item.label}
</a>
))}
</nav>
</ClientSidebar>
<main>{children}</main>
</div>
);
}Item navigasi diambil di server, dirender sebagai HTML, dan diteruskan melalui ClientSidebar sebagai children. Sidebar mengelola state collapse (memerlukan client), tapi konten navigasi tidak pernah menjadi JavaScript client-side.
Saat React merender tree ini di server:
DashboardLayout (Server Component) — mengambil item navigasi, menghasilkan JSXClientSidebar (Client Component) — menandainya sebagai boundaryClientSidebar (elemen <nav>) di serverClientSidebar + output HTML yang sudah di-render dari childrenClient menerima kode ClientSidebar dan pre-rendered children. Ia meng-hydrate sidebar (menambahkan handler onClick), tapi children sudah menjadi HTML — tidak perlu JavaScript tambahan.
Ini berarti: children dari Client Component bisa menjadi Server Components. Ini adalah membran, bukan dinding.
Untuk komposisi yang lebih kompleks, gunakan multiple slot:
// InteractiveLayout.tsx — Client Component
"use client";
import { useState } from "react";
export function InteractiveLayout({
header,
sidebar,
content,
}: {
header: React.ReactNode;
sidebar: React.ReactNode;
content: React.ReactNode;
}) {
const [sidebarOpen, setSidebarOpen] = useState(true);
return (
<div className="layout">
<header>{header}</header>
<div className="body">
{sidebarOpen && <aside>{sidebar}</aside>}
<main>{content}</main>
</div>
<button onClick={() => setSidebarOpen(!sidebarOpen)}>
Toggle Sidebar
</button>
</div>
);
}// page.tsx — Server Component
import { InteractiveLayout } from "./InteractiveLayout";
export default async function Page() {
const user = await getCurrentUser();
const stats = await getDashboardStats();
return (
<InteractiveLayout
header={<UserHeader user={user} />}
sidebar={<DashboardNav />}
content={<StatsGrid stats={stats} />}
/>
);
}UserHeader, DashboardNav, dan StatsGrid semuanya bisa menjadi Server Components — async, data-heavy, dependency-heavy. InteractiveLayout adalah Client Component yang hanya mengelola visibilitas sidebar. Setiap slot dirender di server dan diteruskan sebagai props React element yang sudah di-serialize.
RSC mengubah pengambilan data secara fundamental. Tidak ada lagi useEffect + useState + loading state untuk data yang diketahui saat render. Tapi pola-pola baru punya gotcha sendiri.
Di Server Component, kamu tinggal fetch. Next.js memperluas fetch global untuk menambahkan caching:
// app/products/page.tsx
export default async function ProductsPage() {
// Di-cache secara default — URL yang sama mengembalikan hasil cached
const res = await fetch("https://api.example.com/products");
const products = await res.json();
return (
<div>
{products.map((product: Product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}Ini yang mengubah segalanya. Tanpa API route. Tanpa layer fetch. Hanya query database langsung:
// app/dashboard/page.tsx
import { db } from "@/lib/database";
export default async function Dashboard() {
// Query database langsung di komponen
const [orders, revenue, activeUsers] = await Promise.all([
db.order.count({ where: { createdAt: { gte: startOfDay() } } }),
db.order.aggregate({
_sum: { total: true },
where: { createdAt: { gte: startOfMonth() } },
}),
db.session.count({ where: { expiresAt: { gte: new Date() } } }),
]);
return (
<div>
<StatCard label="Order Hari Ini" value={orders} />
<StatCard label="Pendapatan Bulan Ini" value={revenue._sum.total} />
<StatCard label="Pengguna Aktif" value={activeUsers} />
</div>
);
}Ini terasa salah saat pertama kali kamu menulis. "Query database di komponen? Ini bukan MVC!" Tapi ingat — kode ini berjalan di server. Ia tidak pernah dikirim ke browser. Ini secara fungsional setara dengan menulis endpoint API yang mengambil data dan me-render HTML, kecuali tanpa boilerplate routing.
Gotcha besar pertama: pengambilan data berurutan yang tidak disengaja.
// BURUK: Pengambilan berurutan — setiap await memblokir yang berikutnya
export default async function Page() {
const user = await getUser(); // 100ms
const posts = await getPosts(user.id); // 200ms
const comments = await getComments(); // 150ms
// Total: 450ms
return <div>...</div>;
}// BAGUS: Pengambilan paralel — semua dimulai bersamaan
export default async function Page() {
const userPromise = getUser();
const commentsPromise = getComments();
const user = await userPromise;
const [posts, comments] = await Promise.all([
getPosts(user.id), // Ini tergantung pada user, jadi berurutan
commentsPromise, // Ini independen, jadi paralel
]);
// Total: ~300ms (user + posts berurutan, comments paralel)
return <div>...</div>;
}Bahkan lebih baik — biarkan struktur komponen membuat paralelisme ini alami:
// TERBAIK: Komponen terpisah mengambil data sendiri secara paralel
export default function Page() {
return (
<div>
<Suspense fallback={<UserSkeleton />}>
<UserSection />
</Suspense>
<Suspense fallback={<CommentsSkeleton />}>
<CommentsSection />
</Suspense>
</div>
);
}
async function UserSection() {
const user = await getUser();
const posts = await getPosts(user.id);
return <div>...</div>;
}
async function CommentsSection() {
const comments = await getComments();
return <div>...</div>;
}Sekarang UserSection dan CommentsSection mengambil data secara paralel secara otomatis karena mereka adalah sibling Suspense boundary. Tidak perlu menyusun promise secara manual.
Saat kamu perlu mengoper data server-fetched ke Client Component untuk interaktivitas:
// app/products/page.tsx — Server Component
import { ProductList } from "@/components/ProductList";
export default async function ProductsPage() {
const products = await db.product.findMany({
select: { id: true, name: true, price: true, image: true },
orderBy: { createdAt: "desc" },
});
// Oper data yang sudah di-serialize ke client component
return <ProductList items={products} />;
}// src/components/ProductList.tsx — Client Component
"use client";
import { useState, useMemo } from "react";
export function ProductList({ items }: { items: Product[] }) {
const [selectedId, setSelectedId] = useState<string | null>(null);
const [locale] = useState("id-ID");
// Buat formatter di sisi client
const formatter = useMemo(
() => new Intl.NumberFormat(locale, { style: "currency", currency: "USD" }),
[locale]
);
return (
<ul>
{items.map((item) => (
<li
key={item.id}
onClick={() => setSelectedId(item.id)}
className={selectedId === item.id ? "selected" : ""}
>
{item.name} — {formatter.format(item.price)}
</li>
))}
</ul>
);
}Awalnya saya mengira Client Components sebagai "pulau" — bit interaktif kecil di lautan konten server-rendered. Itu sebagian benar tapi melewatkan detail penting: Client Component bisa merender Server Components jika diteruskan sebagai children atau props.
Ini berarti boundary-nya bukan dinding keras. Ini lebih seperti membran. Konten server-rendered bisa mengalir melalui Client Components via pola children. Kita akan mendalami ini di bagian komposisi.
RSC mengubah pengambilan data secara fundamental. Tidak ada lagi useEffect + useState + loading state untuk data yang diketahui saat render. Tapi pola-pola baru punya gotcha sendiri.
RSC plus Suspense adalah di mana performa menjadi menarik. Alih-alih menunggu semua data sebelum mengirim apa pun ke browser, kamu bisa melakukan streaming bagian halaman saat sudah siap.
// app/dashboard/page.tsx
import { Suspense } from "react";
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
{/* Ini langsung di-render — tidak ada pengambilan data */}
<DashboardNav />
{/* Ini melakukan stream saat data tiba */}
<Suspense fallback={<StatsSkeleton />}>
<DashboardStats />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentOrders />
</Suspense>
</div>
);
}
// Setiap komponen ini adalah async Server Component
async function DashboardStats() {
const stats = await getStats(); // 200ms
return <StatsGrid data={stats} />;
}
async function RevenueChart() {
const data = await getRevenueData(); // 800ms
return <Chart data={data} />;
}
async function RecentOrders() {
const orders = await getRecentOrders(); // 400ms
return <OrderTable orders={orders} />;
}Inilah yang terjadi secara kronologis:
DashboardNav dan semua fallback Suspense di-render segera.DashboardStats selesai. Server mengirim streaming chunk HTML. Browser mengganti skeleton stat dengan data nyata.RecentOrders selesai. Streaming chunk lain. Tabel muncul.RevenueChart selesai. Streaming chunk terakhir. Chart muncul.Pengguna melihat konten bermakna pada 0ms, bukan 800ms. Time to First Byte adalah instan. Setiap bagian halaman muncul saat sudah siap.
Kamu bisa menyarangkan Suspense boundary untuk kontrol lebih halus:
<Suspense fallback={<DashboardSkeleton />}>
<Dashboard>
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentTransactions />
</Suspense>
</Dashboard>
</Suspense>Jika Dashboard cepat tapi RevenueChart lambat, Suspense luar selesai lebih dulu (menampilkan shell dashboard), dan Suspense dalam untuk chart selesai kemudian.
Pasangkan Suspense dengan error.tsx untuk UI yang tangguh:
// app/dashboard/error.tsx — Client Component (harus)
"use client";
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="p-8 text-center">
<h2>Ada yang salah saat memuat dashboard</h2>
<p className="text-gray-500">{error.message}</p>
<button onClick={reset} className="mt-4 px-4 py-2 bg-blue-600 text-white rounded">
Coba lagi
</button>
</div>
);
}File error.tsx otomatis membungkus segmen route yang bersangkutan dalam React Error Boundary. Jika Server Component mana pun di segmen itu throw, UI error ditampilkan alih-alih meng-crash seluruh halaman.
Setelah membangun beberapa aplikasi production dengan RSC, saya sudah menetapkan framework keputusan yang jelas. Berikut proses berpikir sebenarnya yang saya lalui untuk setiap komponen:
Setiap komponen harus menjadi Server Component kecuali ada alasan spesifik mengapa tidak bisa. Ini adalah aturan terpenting.
1. Menggunakan API khusus browser
"use client";
// window, document, navigator, localStorage, dll.
function GeoLocation() {
const [coords, setCoords] = useState<GeolocationCoordinates | null>(null);
useEffect(() => {
navigator.geolocation.getCurrentPosition(
(pos) => setCoords(pos.coords)
);
}, []);
return coords ? <p>Lat: {coords.latitude}</p> : <p>Memuat lokasi...</p>;
}2. Menggunakan React hooks yang memerlukan state atau effects
useState, useEffect, useReducer, useRef (untuk mutable ref), useContext — semua ini memerlukan "use client".
"use client";
function SearchBar() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 300);
// Komponen ini HARUS menjadi Client Component karena
// menggunakan useState dan mengelola input pengguna
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Cari..."
/>
);
}3. Memasang event handler
onClick, onChange, onSubmit, onMouseEnter — perilaku interaktif apa pun berarti client-side.
4. Menggunakan library client-side
Framer Motion, React Hook Form, Zustand, React Query (untuk fetching client-side), library charting apa pun yang merender ke canvas atau SVG secara interaktif.
Berikut contoh konkret. Saya sedang membangun halaman produk:
ProductPage (Server)
├── ProductBreadcrumbs (Server) — navigasi statis, tanpa interaktivitas
├── ProductImageGallery (Client) — zoom, swipe, lightbox
├── ProductInfo (Server)
│ ├── ProductTitle (Server) — hanya teks
│ ├── ProductPrice (Server) — angka terformat, tanpa interaksi
│ └── AddToCartButton (Client) — onClick, mengelola state keranjang
├── ProductDescription (Server) — markdown yang di-render
├── Suspense
│ └── RelatedProducts (Server) — async data fetch, API lambat
└── Suspense
└── ProductReviews (Server)
└── ReviewForm (Client) — form dengan validasi
Perhatikan polanya: shell halaman dan bagian data-heavy adalah Server Components. Island interaktif (ImageGallery, AddToCartButton, ReviewForm) adalah Client Components. Bagian lambat (RelatedProducts, ProductReviews) dibungkus Suspense.
Ini bukan teoretis. Ini adalah tampilan component tree saya yang sebenarnya.
Jalur paling mudah saat migrasi dari Pages Router atau Create React App adalah menampar "use client" di mana-mana. Berhasil! Tidak ada yang rusak! Kamu juga mengirim seluruh component tree sebagai JavaScript dan mendapat nol manfaat RSC.
Saya pernah melihat codebase di mana root layout memiliki "use client" di atasnya. Pada titik itu kamu secara harfiah menjalankan aplikasi React client-side dengan langkah tambahan.
Solusinya: Mulai dengan Server Components. Hanya tambahkan "use client" saat compiler memberi tahu ia diperlukan (karena kamu menggunakan hook atau event handler). Dorong "use client" sejauh mungkin ke bawah tree.
// BURUK: mengambil data di Server Component, lalu meneruskannya melalui
// beberapa Client Components
// app/page.tsx (Server)
export default async function Page() {
const user = await getUser();
const settings = await getSettings();
const theme = await getTheme();
return (
<ClientShell user={user} settings={settings} theme={theme}>
<ClientContent user={user} settings={settings}>
<ClientWidget user={user} />
</ClientContent>
</ClientShell>
);
}Setiap data yang kamu teruskan melewati boundary di-serialize ke RSC payload. Teruskan objek yang sama lima kali? Ia ada di payload lima kali. Saya pernah melihat RSC payload membengkak hingga megabyte karena ini.
Solusinya: Gunakan komposisi. Teruskan Server Components sebagai children alih-alih meneruskan data sebagai props:
// BAGUS: Server Components mengambil data sendiri, diteruskan sebagai children
// app/page.tsx (Server)
export default function Page() {
return (
<ClientShell>
<UserInfo /> {/* Server Component — mengambil data sendiri */}
<Settings /> {/* Server Component — mengambil data sendiri */}
<ClientWidget>
<UserAvatar /> {/* Server Component — mengambil data sendiri */}
</ClientWidget>
</ClientShell>
);
}Tanpa Suspense, Time to First Byte (TTFB) halamanmu dibatasi oleh pengambilan data paling lambat. Saya pernah punya halaman dashboard yang memakan waktu 4 detik untuk dimuat karena satu query analytics yang lambat, meskipun sisa data halaman sudah siap dalam 200ms.
// BURUK: semua menunggu semua
export default async function Dashboard() {
const stats = await getStats(); // 200ms
const chart = await getChartData(); // 300ms
const analytics = await getAnalytics(); // 4000ms ← memblokir semuanya
return (
<div>
<Stats data={stats} />
<Chart data={chart} />
<Analytics data={analytics} />
</div>
);
}// BAGUS: analytics dimuat secara independen
export default function Dashboard() {
return (
<div>
<Suspense fallback={<StatsSkeleton />}>
<Stats />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<Chart />
</Suspense>
<Suspense fallback={<AnalyticsSkeleton />}>
<Analytics /> {/* Butuh 4 detik tapi tidak memblokir sisanya */}
</Suspense>
</div>
);
}Yang satu ini sangat menyakitkan karena sering tidak tertangkap sampai production. Kamu mengoper sesuatu yang tidak serializable melewati boundary dan mendapat error cryptic:
Error: Only plain objects, and a few built-ins, can be passed to
Client Components from Server Components. Classes or null prototypes
are not supported.
Pelaku umumnya:
.toISOString() sebagai gantinya).toJSON() atau spread ke plain object)Decimal (konversi ke number atau string)// BURUK
const user = await prisma.user.findUnique({ where: { id } });
// user mungkin memiliki field non-serializable (Decimal, BigInt, dll.)
return <ClientProfile user={user} />;
// BAGUS
const user = await prisma.user.findUnique({ where: { id } });
const serializedUser = {
id: user.id,
name: user.name,
email: user.email,
balance: user.balance.toNumber(), // Decimal → number
createdAt: user.createdAt.toISOString(), // Date → string
};
return <ClientProfile user={serializedUser} />;useContext hanya bekerja di Client Components. Jika kamu mencoba menggunakan React context di Server Component, ia tidak akan bekerja. Saya pernah melihat orang menjadikan seluruh aplikasi mereka Client Component hanya untuk menggunakan theme context.
Solusinya: Untuk tema dan state global lainnya, gunakan CSS variable yang di-set di sisi server, atau gunakan fungsi cookies() / headers():
// app/layout.tsx (Server Component)
import { cookies } from "next/headers";
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const cookieStore = await cookies();
const theme = cookieStore.get("theme")?.value ?? "light";
return (
<html data-theme={theme} className={theme === "dark" ? "dark" : ""}>
<body>{children}</body>
</html>
);
}Untuk state yang benar-benar client-side (token auth, keranjang belanja, data real-time), buat Client Component provider yang tipis di level yang sesuai — bukan root:
// src/providers/CartProvider.tsx
"use client";
import { createContext, useContext, useState } from "react";
const CartContext = createContext<CartContextType | null>(null);
export function CartProvider({ children }: { children: React.ReactNode }) {
const [items, setItems] = useState<CartItem[]>([]);
return (
<CartContext.Provider value={{ items, setItems }}>
{children}
</CartContext.Provider>
);
}
export function useCart() {
const context = useContext(CartContext);
if (!context) throw new Error("useCart harus digunakan dalam CartProvider");
return context;
}// app/shop/layout.tsx (Server Component)
import { CartProvider } from "@/providers/CartProvider";
export default function ShopLayout({ children }: { children: React.ReactNode }) {
// CartProvider adalah Client Component, tapi children mengalir sebagai konten server
return <CartProvider>{children}</CartProvider>;
}Salah satu kemenangan terbesar RSC adalah kode Server Component tidak pernah dikirim ke client. Tapi kamu perlu memikirkan ini secara aktif. Jika kamu memiliki komponen yang menggunakan markdown parser 50KB dan hanya menampilkan konten yang sudah di-render — itu harusnya Server Component. Parser tetap di server, dan hanya output HTML yang dikirim ke client.
// Server Component — marked tetap di server
import { marked } from "marked"; // Library 50KB — tidak pernah dikirim ke client
async function BlogContent({ slug }: { slug: string }) {
const post = await getPost(slug);
const html = marked(post.markdown);
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}Jika kamu menjadikan ini Client Component, marked akan dikirim ke browser. Untuk apa. Pengguna akan mengunduh 50KB JavaScript hanya untuk merender konten yang bisa menjadi HTML sejak awal.
Periksa bundle-mu dengan @next/bundle-analyzer. Hasilnya mungkin mengejutkanmu.
Caching di Next.js 15+ telah disederhanakan secara signifikan dibanding versi sebelumnya, tapi masih ada layer-layer berbeda yang perlu dipahami.
cache() (React)#cache() React adalah untuk deduplikasi per-request, bukan caching persisten:
import { cache } from "react";
export const getCurrentUser = cache(async () => {
const session = await getSession();
if (!session) return null;
return db.user.findUnique({ where: { id: session.userId } });
});
// Panggil ini di mana saja di component tree selama satu request.
// Hanya satu query database yang benar-benar dieksekusi.Ini berlaku untuk satu server request. Saat request selesai, nilai cached-nya hilang. Ini memoization, bukan caching.
unstable_cache (Next.js)#Untuk caching persisten lintas request, gunakan unstable_cache (namanya "unstable" selamanya, tapi bekerja dengan baik di production):
import { unstable_cache } from "next/cache";
const getCachedProducts = unstable_cache(
async (categoryId: string) => {
return db.product.findMany({
where: { categoryId },
include: { images: true },
});
},
["products-by-category"], // prefix cache key
{
revalidate: 3600, // revalidasi setiap jam
tags: ["products"], // untuk revalidasi on-demand
}
);
// Penggunaan di Server Component
async function ProductGrid({ categoryId }: { categoryId: string }) {
const products = await getCachedProducts(categoryId);
return <Grid items={products} />;
}Untuk menginvalidasi:
"use server";
import { revalidateTag } from "next/cache";
export async function onProductUpdate() {
revalidateTag("products");
}Next.js memutuskan apakah sebuah route statis atau dinamis berdasarkan apa yang kamu gunakan di dalamnya:
Statis (dirender saat build time, di-cache):
cookies(), headers(), searchParams)fetch memiliki caching aktifexport const dynamic = "force-dynamic"Dinamis (dirender per request):
cookies(), headers(), atau searchParamsfetch dengan cache: "no-store"export const dynamic = "force-dynamic"connection() atau after() dari next/serverKamu bisa memeriksa route mana yang statis vs dinamis dengan menjalankan next build — ia menampilkan legenda di bagian bawah:
Route (app) Size First Load JS
┌ ○ / 5.2 kB 92 kB
├ ○ /about 1.1 kB 88 kB
├ ● /blog/[slug] 2.3 kB 89 kB
├ λ /dashboard 4.1 kB 91 kB
└ λ /api/products 0 B 87 kB
○ Static ● SSG λ Dynamic
Pikirkan caching dalam layer:
1. React cache() — per-request, in-memory, deduplikasi otomatis
2. fetch() cache — lintas request, otomatis untuk GET request
3. unstable_cache() — lintas request, untuk operasi non-fetch
4. Full Route Cache — HTML yang di-render di-cache saat build/revalidasi
5. Router Cache (client) — cache in-browser dari route yang sudah dikunjungi
Setiap layer melayani tujuan berbeda. Kamu tidak selalu membutuhkan semuanya, tapi memahami mana yang aktif membantu debug masalah "kenapa data saya tidak terupdate?"
Berikut apa yang sebenarnya saya lakukan di production:
// lib/data/products.ts
import { cache } from "react";
import { unstable_cache } from "next/cache";
import { db } from "@/lib/database";
// Deduplikasi per-request: panggil ini beberapa kali dalam satu render,
// hanya satu query DB yang berjalan
export const getProductById = cache(async (id: string) => {
return db.product.findUnique({
where: { id },
include: { category: true, images: true },
});
});
// Cache lintas request: hasil bertahan lintas request,
// revalidasi setiap 5 menit atau on-demand via tag
export const getPopularProducts = unstable_cache(
async () => {
return db.product.findMany({
orderBy: { salesCount: "desc" },
take: 20,
include: { images: true },
});
},
["popular-products"],
{ revalidate: 300, tags: ["products"] }
);
// Tanpa caching: selalu fresh (untuk data user-specific)
export const getUserCart = cache(async (userId: string) => {
// cache() di sini hanya untuk deduplikasi per-request, bukan persistensi
return db.cart.findUnique({
where: { userId },
include: { items: { include: { product: true } } },
});
});Aturan praktisnya: data publik yang jarang berubah mendapat unstable_cache. Data user-specific mendapat cache() untuk deduplikasi saja. Data real-time tidak mendapat caching sama sekali.
Server Actions pantas mendapat bagian tersendiri karena mereka melengkapi cerita RSC. Mereka adalah cara Client Components berkomunikasi kembali ke server tanpa API route.
// app/actions/newsletter.ts
"use server";
import { db } from "@/lib/database";
import { z } from "zod";
const emailSchema = z.string().email();
export async function subscribeToNewsletter(formData: FormData) {
const email = formData.get("email");
const result = emailSchema.safeParse(email);
if (!result.success) {
return { error: "Alamat email tidak valid" };
}
try {
await db.subscriber.create({
data: { email: result.data },
});
return { success: true };
} catch (e) {
if (e instanceof Error && e.message.includes("Unique constraint")) {
return { error: "Sudah berlangganan" };
}
return { error: "Ada yang salah" };
}
}// src/components/NewsletterForm.tsx
"use client";
import { useActionState } from "react";
import { subscribeToNewsletter } from "@/app/actions/newsletter";
export function NewsletterForm() {
const [state, formAction, isPending] = useActionState(
async (_prev: { error?: string; success?: boolean } | null, formData: FormData) => {
return subscribeToNewsletter(formData);
},
null
);
return (
<form action={formAction}>
<input
type="email"
name="email"
required
placeholder="you@example.com"
disabled={isPending}
/>
<button type="submit" disabled={isPending}>
{isPending ? "Mendaftar..." : "Berlangganan"}
</button>
{state?.error && <p className="text-red-500">{state.error}</p>}
{state?.success && <p className="text-green-500">Berhasil berlangganan!</p>}
</form>
);
}Server Actions adalah jawaban untuk "bagaimana saya memutasi data?" di dunia RSC. Mereka menggantikan sebagian besar API route untuk pengiriman form, mutasi, dan side effect.
Aturan kunci untuk Server Actions:
revalidatePath() atau revalidateTag() untuk memperbarui data cached setelah mutasiJika kamu memiliki aplikasi React yang sudah ada (Pages Router, Create React App, Vite), pindah ke RSC tidak harus menjadi rewrite. Berikut pendekatan saya.
Telusuri component tree-mu dan klasifikasikan semuanya:
Komponen State? Effect? Event? → Keputusan
─────────────────────────────────────────────────────────
Header Tidak Tidak Tidak → Server
NavigationMenu Tidak Tidak Ya → Client (toggle mobile)
Footer Tidak Tidak Tidak → Server
BlogPost Tidak Tidak Tidak → Server
SearchBar Ya Ya Ya → Client
ProductCard Tidak Tidak Ya → Client (onClick) atau split
UserAvatar Tidak Tidak Tidak → Server
CommentForm Ya Ya Ya → Client
Sidebar Ya Tidak Ya → Client (toggle collapse)
MarkdownRenderer Tidak Tidak Tidak → Server (keuntungan dependency besar)
DataTable Ya Ya Ya → Client (sorting, filtering)
Perubahan arsitektur terbesar adalah memindahkan pengambilan data dari useEffect di komponen ke Server Components async. Di sinilah usaha migrasi sebenarnya.
Sebelum:
// Pola lama — pengambilan data di Client Component
"use client";
function ProductPage({ id }: { id: string }) {
const [product, setProduct] = useState<Product | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch(`/api/products/${id}`)
.then(res => res.json())
.then(data => setProduct(data))
.catch(err => setError(err.message))
.finally(() => setLoading(false));
}, [id]);
if (loading) return <Spinner />;
if (error) return <Error message={error} />;
if (!product) return <NotFound />;
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<AddToCartButton productId={product.id} />
</div>
);
}Sesudah:
// Pola baru — Server Component mengambil data, Client Component berinteraksi
// app/products/[id]/page.tsx (Server Component)
import { db } from "@/lib/database";
import { notFound } from "next/navigation";
import { AddToCartButton } from "@/components/AddToCartButton";
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const product = await db.product.findUnique({ where: { id } });
if (!product) notFound();
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<AddToCartButton productId={product.id} price={product.price} />
</div>
);
}Tanpa manajemen loading state. Tanpa error state. Tanpa useEffect. Framework menangani semua itu melalui Suspense dan error boundary.
Banyak komponen yang sebagian besar statis dengan bagian interaktif kecil. Pecah mereka:
Sebelum (satu Client Component besar):
"use client";
function ProductCard({ product }: { product: Product }) {
const [isFavorite, setIsFavorite] = useState(false);
return (
<div className="card">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>{product.description}</p>
<span>${product.price}</span>
<button onClick={() => setIsFavorite(!isFavorite)}>
{isFavorite ? "♥" : "♡"}
</button>
</div>
);
}Sesudah (Server Component dengan island Client kecil):
// ProductCard.tsx (Server Component)
import { FavoriteButton } from "./FavoriteButton";
function ProductCard({ product }: { product: Product }) {
return (
<div className="card">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>{product.description}</p>
<span>${product.price}</span>
<FavoriteButton productId={product.id} />
</div>
);
}// FavoriteButton.tsx (Client Component)
"use client";
import { useState } from "react";
export function FavoriteButton({ productId }: { productId: string }) {
const [isFavorite, setIsFavorite] = useState(false);
return (
<button
onClick={() => setIsFavorite(!isFavorite)}
aria-label={isFavorite ? "Hapus dari favorit" : "Tambah ke favorit"}
>
{isFavorite ? "♥" : "♡"}
</button>
);
}Gambar, judul, deskripsi, dan harga sekarang server-rendered. Hanya tombol favorit kecil yang menjadi Client Component. Lebih sedikit JavaScript, loading halaman lebih cepat.
Jika kamu memiliki API route yang ada hanya untuk melayani frontend sendiri (bukan konsumer eksternal), sebagian besar bisa menjadi Server Actions:
Sebelum:
// app/api/contact/route.ts
export async function POST(request: Request) {
const body = await request.json();
// validasi, simpan ke DB, kirim email
return Response.json({ success: true });
}
// Client component
const response = await fetch("/api/contact", {
method: "POST",
body: JSON.stringify(formData),
});Sesudah:
// app/actions/contact.ts
"use server";
export async function submitContactForm(formData: FormData) {
// validasi, simpan ke DB, kirim email
return { success: true };
}
// Client component — panggil fungsi langsung
import { submitContactForm } from "@/app/actions/contact";Pertahankan API route untuk: webhook, konsumer API eksternal, apa pun yang memerlukan HTTP header atau status code kustom, upload file dengan streaming.
Testing Server Components memerlukan pendekatan sedikit berbeda karena mereka bisa async:
// __tests__/ProductPage.test.tsx
import { render, screen } from "@testing-library/react";
import ProductPage from "@/app/products/[id]/page";
// Mock database
vi.mock("@/lib/database", () => ({
db: {
product: {
findUnique: vi.fn(),
},
},
}));
describe("ProductPage", () => {
it("merender detail produk", async () => {
const { db } = await import("@/lib/database");
vi.mocked(db.product.findUnique).mockResolvedValue({
id: "1",
name: "Produk Test",
description: "Produk yang bagus",
price: 29.99,
});
// Server Components adalah async — await JSX-nya
const jsx = await ProductPage({
params: Promise.resolve({ id: "1" })
});
render(jsx);
expect(screen.getByText("Produk Test")).toBeInTheDocument();
expect(screen.getByText("Produk yang bagus")).toBeInTheDocument();
});
});Perbedaan kuncinya: kamu await fungsi komponen karena ia async. Lalu render JSX hasilnya. Yang lainnya bekerja sama seperti React Testing Library tradisional.
Berikut struktur yang sudah saya tetapkan setelah beberapa proyek. Ini opinionated, tapi berhasil:
src/
├── app/
│ ├── layout.tsx ← Root layout (Server Component)
│ ├── page.tsx ← Halaman beranda (Server Component)
│ ├── (marketing)/ ← Route group untuk halaman marketing
│ │ ├── about/page.tsx
│ │ └── pricing/page.tsx
│ ├── (app)/ ← Route group untuk app terotentikasi
│ │ ├── layout.tsx ← Shell app dengan pengecekan auth
│ │ ├── dashboard/
│ │ │ ├── page.tsx
│ │ │ ├── loading.tsx ← Suspense fallback untuk route ini
│ │ │ └── error.tsx ← Error boundary untuk route ini
│ │ └── settings/
│ │ └── page.tsx
│ └── actions/ ← Server Actions
│ ├── auth.ts
│ └── products.ts
├── components/
│ ├── ui/ ← Primitif UI bersama (kebanyakan Client)
│ │ ├── Button.tsx ← "use client"
│ │ ├── Dialog.tsx ← "use client"
│ │ └── Card.tsx ← Server Component (hanya styling)
│ └── features/ ← Komponen spesifik fitur
│ ├── products/
│ │ ├── ProductGrid.tsx ← Server (async, mengambil data)
│ │ ├── ProductCard.tsx ← Server (presentational)
│ │ ├── ProductSearch.tsx ← Client (useState, input)
│ │ └── AddToCart.tsx ← Client (onClick, mutasi)
│ └── blog/
│ ├── PostList.tsx ← Server (async, mengambil data)
│ ├── PostContent.tsx ← Server (markdown rendering)
│ └── CommentSection.tsx ← Client (form, real-time)
├── lib/
│ ├── data/ ← Layer akses data
│ │ ├── products.ts ← Query DB yang di-wrap cache()
│ │ └── users.ts
│ ├── database.ts
│ └── utils.ts
└── providers/
├── ThemeProvider.tsx ← "use client" — membungkus bagian yang butuh tema
└── CartProvider.tsx ← "use client" — membungkus bagian shop saja
Prinsip kuncinya:
lib/data/ — di-wrap dengan cache() atau unstable_cacheapp/actions/ — co-located dengan app, terpisah dengan jelasReact Server Components bukan hanya API baru. Mereka adalah cara berpikir yang berbeda tentang di mana kode berjalan, di mana data tinggal, dan bagaimana potongan-potongannya terhubung. Pergeseran mental model itu nyata dan butuh waktu.
Tapi begitu klik — begitu kamu berhenti melawan boundary dan mulai mendesain di sekitarnya — kamu berakhir dengan aplikasi yang lebih cepat, lebih sederhana, dan lebih mudah dipelihara dari yang kita bangun sebelumnya. Lebih sedikit JavaScript dikirim ke client. Pengambilan data tidak memerlukan upacara. Component tree menjadi arsitekturnya.
Transisinya sepadan. Hanya ketahui bahwa beberapa proyek pertama akan terasa tidak nyaman, dan itu normal. Kamu tidak berjuang karena RSC itu buruk. Kamu berjuang karena ini benar-benar baru.
Mulai dengan Server Components di mana-mana. Dorong "use client" ke bawah hingga leaf. Bungkus hal lambat dengan Suspense. Ambil data di tempat ia dirender. Komposisi melalui children.
Itu seluruh playbook-nya. Yang lainnya adalah detail.