Kenapa ini penting
Ini konsep paling penting di App Router, dan sumber kebingungan terbesar bagi developer yang datang dari React biasa. Di Next.js, setiap komponen secara default adalah Server Component: berjalan di server, tidak dikirim sebagai JavaScript ke browser, dan tidak bisa memakai useState atau onClick. Kalau kamu paham kapan memakai server dan kapan client, aplikasimu lebih cepat, lebih aman, dan lebih sedikit bug.
Konsep inti
Dua jenis komponen
Analogi restoran: Server Component adalah dapur, tempat bahan mentah (database, API key) diolah dan pelanggan tidak pernah masuk. Client Component adalah meja pelanggan, tempat interaksi terjadi: menekan bel, memilih saus, mengobrol. Makanan dimasak di dapur lalu diantar ke meja; bahan mentahnya tetap di dapur.
| Butuh... | Pakai |
|---|---|
| Ambil data dari database/API | Server Component |
| Memakai API key, token, password | Server Component |
| Mengurangi JavaScript yang dikirim ke browser | Server Component |
useState, useReducer, useEffect |
Client Component |
Event handler: onClick, onChange, onSubmit di client |
Client Component |
API browser: window, localStorage, geolocation |
Client Component |
| Custom hook dan Context | Client Component |
"use client" adalah batas, bukan label
Tulis 'use client' di baris paling atas file (sebelum import). Ini menandai batas antara dunia server dan client. Semua yang di-import dan dirender langsung oleh file itu ikut masuk bundle client. Jadi kamu tidak perlu menulis 'use client' di setiap file anak; cukup di "pintu masuk" bagian interaktif.
Akibatnya, letakkan 'use client' serendah mungkin di pohon komponen. Jangan jadikan seluruh halaman Client Component hanya karena ada satu tombol suka. Jadikan tombolnya saja Client Component.
Bagaimana keduanya bekerja
- Di server, Server Component dirender menjadi format khusus bernama RSC Payload (React Server Component Payload), berisi hasil render plus "tempat kosong" untuk Client Component dan alamat file JS-nya.
- Dari RSC Payload dan Client Component, Next.js membuat HTML awal.
- Di browser, HTML tampil duluan (cepat, belum interaktif), lalu JavaScript Client Component dimuat dan di-hydrate: React memasang event handler ke HTML yang sudah ada, sehingga halaman menjadi interaktif.
Client Component juga dirender di server untuk HTML awal. Bedanya, kodenya juga dikirim ke browser dan dijalankan di sana.
Meneruskan props dari server ke client
Server Component boleh merender Client Component dan memberinya props. Syaratnya: props harus bisa diserialisasi (diubah menjadi data yang bisa dikirim lewat jaringan): string, number, boolean, null, array, object biasa, Date, dan Promise. Fungsi tidak bisa dikirim. Kami mencobanya dan build gagal dengan pesan "Event handlers cannot be passed to Client Component props."
Composition pattern: Server Component sebagai children
Client Component tidak boleh meng-import Server Component (yang di-import akan ikut menjadi kode client). Tapi Client Component boleh menerima Server Component lewat children atau prop lain. Server Component itu dirender di server, lalu hasilnya "diselipkan" ke dalam Client Component.
Contoh nyata: komponen Laci (accordion) butuh state buka/tutup (client), tapi isinya data dari database (server). Solusinya: <Laci><StatistikDariDB /></Laci> ditulis di halaman server.
Context provider
React Context tidak jalan di Server Component. Buat provider sebagai Client Component yang menerima children, lalu pakai di layout server. Letakkan provider sedalam mungkin (bungkus children, bukan seluruh <html>). Inilah cara memasang QueryClientProvider dari TanStack Query di Next.js.
Menjaga rahasia tetap di server
Environment variable tanpa awalan NEXT_PUBLIC_ tidak pernah masuk bundle client (di client nilainya diganti string kosong). Untuk memastikan sebuah file tidak pernah di-import dari client, tambahkan import 'server-only' di atasnya; kalau ada Client Component yang meng-import-nya, build langsung error.
Contoh kode
Tombol interaktif sebagai Client Component kecil:
// components/TombolSuka.jsx
'use client' // batas: file ini dan import-nya dikirim ke browser
import { useState } from 'react'
export default function TombolSuka({ awal }) {
const [suka, setSuka] = useState(awal)
return (
<button onClick={() => setSuka(suka + 1)} className="rounded border px-3 py-1">
❤️ {suka}
</button>
)
}
Halaman server yang mengambil data lalu meneruskan props:
// app/blog/[slug]/page.jsx (bagian yang relevan)
import { notFound } from 'next/navigation'
import { getPost } from '@/lib/posts'
import TombolSuka from '@/components/TombolSuka'
export default async function PostPage({ params }) {
const { slug } = await params
const post = await getPost(slug) // ← jalan di server, kode query tidak dikirim ke browser
if (!post) notFound()
return (
<article>
<h1 className="text-3xl font-bold">{post.judul}</h1>
<p>{post.isi}</p>
<TombolSuka awal={0} /> {/* hanya tombol ini yang jadi JavaScript di browser */}
</article>
)
}
Composition pattern dengan children:
// components/Laci.jsx
'use client'
import { useState } from 'react'
export default function Laci({ judul, children }) {
const [buka, setBuka] = useState(false)
return (
<div className="rounded border p-3">
<button onClick={() => setBuka(!buka)}>{buka ? '▼' : '▶'} {judul}</button>
{buka && <div className="mt-2">{children}</div>}
</div>
)
}
// app/(marketing)/tentang/page.jsx, Server Component
import Laci from '@/components/Laci'
import { getPosts } from '@/lib/posts'
async function JumlahArtikel() {
const posts = await getPosts() // query di server
return <p>Kami sudah menulis {posts.length} artikel.</p>
}
export default function TentangPage() {
return (
<main>
<h1 className="text-2xl font-bold">Tentang kami</h1>
<Laci judul="Statistik">
<JumlahArtikel /> {/* dirender di server, diselipkan ke Laci (client) */}
</Laci>
</main>
)
}
// → klik "▶ Statistik" → "Kami sudah menulis 3 artikel."
Provider untuk TanStack Query (dari materi sebelumnya) di Next.js:
// app/providers.jsx
'use client'
import { useState } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
export default function Providers({ children }) {
// useState agar QueryClient dibuat sekali per browser, bukan tiap render
const [queryClient] = useState(() => new QueryClient())
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}
// app/layout.jsx, tetap Server Component
import Providers from './providers'
import './globals.css'
export default function RootLayout({ children }) {
return (
<html lang="id">
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}
Mengunci file agar hanya bisa dipakai di server:
// lib/rahasia.js
import 'server-only' // build error kalau file ini di-import dari Client Component
export async function getSaldo() {
const res = await fetch('https://api.bank-contoh.id/saldo', {
headers: { Authorization: `Bearer ${process.env.BANK_API_KEY}` }, // tanpa NEXT_PUBLIC_
})
return res.json()
}
Kesalahan umum
- Memakai
useState/onClicktanpa'use client'. Build gagal dengan pesan "You're importing a module that depends on useState into a React Server Component module. This API is only available in Client Components." Tambahkan directive di file komponen interaktifnya. - Menaruh
'use client'di halaman paling atas. Seluruh halaman dan import-nya jadi JavaScript client, keuntungan server hilang. Pindahkan ke komponen kecil yang interaktif saja. - Mengirim fungsi sebagai prop dari server ke client. Gagal: "Event handlers cannot be passed to Client Component props." Definisikan handler di dalam Client Component, atau gunakan Server Action (
'use server'). - Meng-import Server Component dari Client Component. Komponen itu ikut berubah menjadi kode client (dan bisa error kalau memakai database). Kirim lewat
childrenatau prop. - Membaca
process.env.RAHASIAdi Client Component. Nilainya kosong di browser. Variabel yang memang boleh publik harus berawalanNEXT_PUBLIC_; rahasia hanya dibaca di server.
Latihan
- Mudah, Buat Client Component
Penghitungdengan tombol tambah dan kurang, lalu tampilkan di halaman serverapp/page.jsxyang juga menampilkan judul statis. - Sedang, Buat Client Component
Tabyang menerima dua prop berisi JSX (kiridankanan) dan menampilkan salah satunya sesuai tab aktif. Dari halaman server, kirim dua Server Component async (DaftarKopi,DaftarTeh) yang masing-masing mengembalikan daftar. - Menantang, Buat
ThemeProvider(Client Component dengan Context) yang menyimpan tema'terang'/'gelap', pasang di root layout, lalu buatTombolTema(client) yang mengganti tema danKartuProfilyang tetap Server Component.
Lihat solusi
1.
// components/Penghitung.jsx
'use client'
import { useState } from 'react'
export default function Penghitung() {
const [n, setN] = useState(0)
return (
<div className="flex items-center gap-3">
<button onClick={() => setN(n - 1)}>−</button>
<span>{n}</span>
<button onClick={() => setN(n + 1)}>+</button>
</div>
)
}
// app/page.jsx, Server Component
import Penghitung from '@/components/Penghitung'
export default function HomePage() {
return (
<main className="p-6">
<h1 className="text-2xl font-bold">Penghitung Kopi</h1>
<Penghitung />
</main>
)
}
2.
// components/Tab.jsx
'use client'
import { useState } from 'react'
export default function Tab({ kiri, kanan }) {
const [aktif, setAktif] = useState('kiri')
return (
<div>
<button onClick={() => setAktif('kiri')}>Kopi</button>
<button onClick={() => setAktif('kanan')}>Teh</button>
<div className="mt-3">{aktif === 'kiri' ? kiri : kanan}</div>
</div>
)
}
// app/minuman/page.jsx, Server Component
import Tab from '@/components/Tab'
async function DaftarKopi() {
return <ul><li>Gayo</li><li>Toraja</li></ul>
}
async function DaftarTeh() {
return <ul><li>Teh Tarik</li><li>Teh Melati</li></ul>
}
export default function MinumanPage() {
return <Tab kiri={<DaftarKopi />} kanan={<DaftarTeh />} />
}
// → kedua daftar dirender di server; Tab hanya memilih mana yang ditampilkan
3.
// app/theme-provider.jsx
'use client'
import { createContext, useContext, useState } from 'react'
const ThemeContext = createContext(null)
export function useTema() {
return useContext(ThemeContext)
}
export default function ThemeProvider({ children }) {
const [tema, setTema] = useState('terang')
return (
<ThemeContext.Provider value={{ tema, setTema }}>
<div className={tema === 'gelap' ? 'bg-gray-900 text-white' : 'bg-white text-black'}>
{children}
</div>
</ThemeContext.Provider>
)
}
// components/TombolTema.jsx
'use client'
import { useTema } from '@/app/theme-provider'
export default function TombolTema() {
const { tema, setTema } = useTema()
return (
<button onClick={() => setTema(tema === 'terang' ? 'gelap' : 'terang')}>
Tema: {tema}
</button>
)
}
// components/KartuProfil.jsx, Server Component (tanpa 'use client')
export default function KartuProfil() {
return <div className="rounded border p-4">Barista: Dimas</div>
}
// app/layout.jsx
import ThemeProvider from './theme-provider'
import './globals.css'
export default function RootLayout({ children }) {
return (
<html lang="id">
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
)
}
// app/page.jsx
import TombolTema from '@/components/TombolTema'
import KartuProfil from '@/components/KartuProfil'
export default function HomePage() {
return (
<main className="space-y-4 p-6">
<TombolTema />
<KartuProfil />
</main>
)
}
Ringkasan
- Di App Router semua komponen adalah Server Component kecuali file diawali
'use client'. - Server Component: ambil data, pakai rahasia, kirim lebih sedikit JavaScript. Client Component: state, event, API browser.
'use client'menandai batas; letakkan serendah mungkin di pohon komponen.- Props dari server ke client harus bisa diserialisasi; fungsi tidak bisa dikirim.
- Selipkan Server Component ke Client Component lewat
children/props; Context dipasang lewat provider client;server-onlymengunci file agar tidak bocor ke client.