Lompat ke konten
·26 menit membaca

React Server Components: Mental Model, Pola, dan Jebakan

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.

Bagikan:X / TwitterLinkedIn

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.

Mental Model yang Benar-Benar Bekerja#

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.

Apa yang Bisa Dilakukan Setiap Tipe#

Server Components bisa:

  • Mengambil data langsung (database, API, filesystem)
  • Mengakses resource backend (env variable, credential)
  • Menggunakan library besar tanpa mengirimnya ke client
  • Merender HTML yang dikirim sebagai output yang sudah selesai

Server Components tidak bisa:

  • Menggunakan state (useState, useReducer)
  • Menggunakan effect (useEffect, useLayoutEffect)
  • Menggunakan event handler (onClick, onChange)
  • Menggunakan API browser (window, document, localStorage)
  • Menggunakan context (useContext)

Client Components bisa:

  • Semua yang di atas — state, effect, event, API browser
  • Merender Server Components yang diteruskan sebagai children

Client Components tidak bisa:

  • Mengambil data dengan async/await (mereka bukan async)
  • Mengakses resource server secara langsung
  • Meng-import Server Components (tapi bisa merender mereka sebagai children)

Boundary: Di Mana Client Bertemu Server#

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.

tsx
// 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>
  );
}
tsx
// 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.

Aturan Serialisasi#

Semua yang melewati boundary Server → Client harus serializable. Ini berarti:

  • String, number, boolean, null, undefined
  • Array dan plain object dari yang di atas
  • Date (sebagai ISO string — bukan objek Date)
  • Map, Set, dan class instance — tidak bisa
  • Fungsi — tidak bisa
  • JSX (React element) — bisa (beginilah cara children bekerja)

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 Komposisi: Children Adalah Segalanya#

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.

Pola Wrapper#

tsx
// 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>
  );
}
tsx
// 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.

Mengapa Ini Bekerja#

Saat React merender tree ini di server:

  1. Ia merender DashboardLayout (Server Component) — mengambil item navigasi, menghasilkan JSX
  2. Ia menemukan ClientSidebar (Client Component) — menandainya sebagai boundary
  3. Ia merender children dari ClientSidebar (elemen <nav>) di server
  4. Ia mengirim: kode ClientSidebar + output HTML yang sudah di-render dari children

Client 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.

Pola Slot#

Untuk komposisi yang lebih kompleks, gunakan multiple slot:

tsx
// 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>
  );
}
tsx
// 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.

Pola Pengambilan Data#

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.

Fetch Dasar dengan Caching#

Di Server Component, kamu tinggal fetch. Next.js memperluas fetch global untuk menambahkan caching:

tsx
// 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>
  );
}

Akses Database Langsung#

Ini yang mengubah segalanya. Tanpa API route. Tanpa layer fetch. Hanya query database langsung:

tsx
// 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.

Pengambilan Data Paralel vs Berurutan#

Gotcha besar pertama: pengambilan data berurutan yang tidak disengaja.

tsx
// 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>;
}
tsx
// 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:

tsx
// 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.

Mengoper Data dari Server ke Client#

Saat kamu perlu mengoper data server-fetched ke Client Component untuk interaktivitas:

tsx
// 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} />;
}
tsx
// 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>
  );
}

Miskonsepsi "Island"#

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.

Pola Pengambilan Data#

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.

Streaming dan Suspense#

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.

Streaming Dasar dengan Suspense#

tsx
// 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:

  1. 0ms: Server mulai merender. DashboardNav dan semua fallback Suspense di-render segera.
  2. 0ms: Browser menerima HTML awal — judul, navigasi, tiga skeleton. Pengguna melihat halaman yang terstruktur.
  3. 200ms: DashboardStats selesai. Server mengirim streaming chunk HTML. Browser mengganti skeleton stat dengan data nyata.
  4. 400ms: RecentOrders selesai. Streaming chunk lain. Tabel muncul.
  5. 800ms: 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.

Suspense Bersarang#

Kamu bisa menyarangkan Suspense boundary untuk kontrol lebih halus:

tsx
<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.

Error Boundary dengan Suspense#

Pasangkan Suspense dengan error.tsx untuk UI yang tangguh:

tsx
// 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.

Kapan Menggunakan yang Mana: Pohon Keputusan#

Setelah membangun beberapa aplikasi production dengan RSC, saya sudah menetapkan framework keputusan yang jelas. Berikut proses berpikir sebenarnya yang saya lalui untuk setiap komponen:

Mulai dengan Server Components (Default)#

Setiap komponen harus menjadi Server Component kecuali ada alasan spesifik mengapa tidak bisa. Ini adalah aturan terpenting.

Jadikan Client Component Saat:#

1. Menggunakan API khusus browser

tsx
"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".

tsx
"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.

Pertahankan sebagai Server Component Saat:#

  • Hanya menampilkan data (tanpa interaksi pengguna)
  • Mengambil data dari database atau API
  • Mengakses resource backend (filesystem, env variable dengan secret)
  • Meng-import dependency besar yang tidak dibutuhkan client (markdown parser, syntax highlighter, library tanggal untuk formatting)
  • Merender konten statis atau semi-statis

Keputusan Dunia Nyata dalam Praktik#

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.

Kesalahan Umum (Saya Sudah Melakukan Semuanya)#

Kesalahan 1: Menjadikan Semuanya Client Component#

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.

Kesalahan 2: Prop Drilling Melewati Boundary#

tsx
// 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:

tsx
// 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>
  );
}

Kesalahan 3: Tidak Menggunakan Suspense#

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.

tsx
// 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>
  );
}
tsx
// 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>
  );
}

Kesalahan 4: Error Serialisasi saat Runtime#

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:

  • Mengoper objek Date (gunakan .toISOString() sebagai gantinya)
  • Mengoper Map atau Set (konversi ke array/objek)
  • Mengoper class instance dari ORM (gunakan .toJSON() atau spread ke plain object)
  • Mengoper fungsi (pindahkan logikanya ke Client Component atau gunakan Server Actions)
  • Mengoper hasil model Prisma dengan field Decimal (konversi ke number atau string)
tsx
// 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} />;

Kesalahan 5: Menggunakan Context untuk Semuanya#

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():

tsx
// 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:

tsx
// 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;
}
tsx
// 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>;
}

Kesalahan 6: Mengabaikan Dampak Bundle Size#

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.

tsx
// 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.

Strategi Caching#

Caching di Next.js 15+ telah disederhanakan secara signifikan dibanding versi sebelumnya, tapi masih ada layer-layer berbeda yang perlu dipahami.

Fungsi cache() (React)#

cache() React adalah untuk deduplikasi per-request, bukan caching persisten:

tsx
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):

tsx
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:

tsx
"use server";
 
import { revalidateTag } from "next/cache";
 
export async function onProductUpdate() {
  revalidateTag("products");
}

Rendering Statis vs Dinamis#

Next.js memutuskan apakah sebuah route statis atau dinamis berdasarkan apa yang kamu gunakan di dalamnya:

Statis (dirender saat build time, di-cache):

  • Tanpa fungsi dinamis (cookies(), headers(), searchParams)
  • Semua panggilan fetch memiliki caching aktif
  • Tanpa export const dynamic = "force-dynamic"

Dinamis (dirender per request):

  • Menggunakan cookies(), headers(), atau searchParams
  • Menggunakan fetch dengan cache: "no-store"
  • Memiliki export const dynamic = "force-dynamic"
  • Menggunakan connection() atau after() dari next/server

Kamu 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

Hierarki Caching#

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?"

Strategi Caching Dunia Nyata#

Berikut apa yang sebenarnya saya lakukan di production:

tsx
// 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: Jembatan Kembali#

Server Actions pantas mendapat bagian tersendiri karena mereka melengkapi cerita RSC. Mereka adalah cara Client Components berkomunikasi kembali ke server tanpa API route.

tsx
// 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" };
  }
}
tsx
// 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:

  • Selalu validasi input (fungsinya bisa dipanggil dari client — perlakukan seperti API endpoint)
  • Selalu kembalikan data serializable
  • Mereka berjalan di server, jadi kamu bisa mengakses database, filesystem, secret
  • Mereka bisa memanggil revalidatePath() atau revalidateTag() untuk memperbarui data cached setelah mutasi

Pola Migrasi#

Jika kamu memiliki aplikasi React yang sudah ada (Pages Router, Create React App, Vite), pindah ke RSC tidak harus menjadi rewrite. Berikut pendekatan saya.

Langkah 1: Petakan Komponenmu#

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)

Langkah 2: Pindahkan Pengambilan Data ke Atas#

Perubahan arsitektur terbesar adalah memindahkan pengambilan data dari useEffect di komponen ke Server Components async. Di sinilah usaha migrasi sebenarnya.

Sebelum:

tsx
// 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:

tsx
// 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.

Langkah 3: Pecah Komponen di Boundary Interaksi#

Banyak komponen yang sebagian besar statis dengan bagian interaktif kecil. Pecah mereka:

Sebelum (satu Client Component besar):

tsx
"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):

tsx
// 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>
  );
}
tsx
// 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.

Langkah 4: Konversi API Route ke Server Actions#

Jika kamu memiliki API route yang ada hanya untuk melayani frontend sendiri (bukan konsumer eksternal), sebagian besar bisa menjadi Server Actions:

Sebelum:

tsx
// 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:

tsx
// 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.

Langkah 5: Testing Komponen RSC#

Testing Server Components memerlukan pendekatan sedikit berbeda karena mereka bisa async:

tsx
// __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.

Struktur File untuk Proyek RSC#

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:

  • Server Components tidak memiliki directive — mereka adalah default
  • Client Components ditandai secara eksplisit — kamu bisa tahu sekilas
  • Pengambilan data tinggal di lib/data/ — di-wrap dengan cache() atau unstable_cache
  • Server Actions tinggal di app/actions/ — co-located dengan app, terpisah dengan jelas
  • Provider membungkus minimum yang diperlukan — bukan seluruh app

Intinya#

React 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.

Artikel Terkait