Next.js App Router: Server Components dan Data Fetching
Next.js App Router adalah pendekatan routing berbasis folder app yang menggabungkan React Server Components, nested layout, streaming UI, metadata, route handler, dan pola data fetching yang dekat dengan kebutuhan aplikasi produksi. Bagi developer React, App Router memperkenalkan cara berpikir baru: tidak semua komponen perlu dikirim ke browser.
Tutorial ini membahas App Router dari sudut pandang praktik. Kita akan melihat kapan memakai Server Component, kapan memakai Client Component, bagaimana mengambil data, dan bagaimana memisahkan logika agar aplikasi tetap aman serta mudah dirawat.
Tujuan Belajar
Setelah membaca tutorial ini, kamu diharapkan mampu:
- Menjelaskan fungsi folder
appdan file konvensional sepertipage.tsx,layout.tsx, danroute.ts. - Membedakan Server Components dan Client Components.
- Menulis data fetching di Server Component tanpa membocorkan secret ke client.
- Membuat Client Component hanya untuk bagian yang interaktif.
- Menyusun route handler untuk kebutuhan API sederhana.
- Menambahkan metadata halaman agar SEO dasar lebih rapi.
- Menghindari kesalahan umum pada boundary server-client.
Struktur Dasar App Router
Di App Router, route dibuat berdasarkan folder. File page.tsx menjadi isi halaman, sedangkan layout.tsx menjadi wrapper yang dapat dipakai bersama oleh halaman di bawah folder yang sama.
app/
layout.tsx
page.tsx
tutorials/
page.tsx
[slug]/
page.tsx
api/
health/
route.tsRoute yang terbentuk:
| File | URL |
|---|---|
app/page.tsx | / |
app/tutorials/page.tsx | /tutorials |
app/tutorials/[slug]/page.tsx | /tutorials/react-hooks |
app/api/health/route.ts | /api/health |
Konvensi ini membuat struktur route terlihat langsung dari struktur folder. Ketika aplikasi membesar, hal ini membantu developer baru memahami peta halaman tanpa harus membaca konfigurasi routing terpisah.
Server Component sebagai Default
Di App Router, komponen dalam app secara default adalah Server Component. Artinya, komponen dapat mengambil data dari database, membaca file, atau memakai secret di server tanpa otomatis dikirim sebagai JavaScript ke browser.
import { prisma } from "@/lib/prisma";
export default async function TutorialsPage() {
const tutorials = await prisma.tutorial.findMany({
where: { published: true },
orderBy: { publishedAt: "desc" },
});
return (
<main>
<h1>Tutorial</h1>
<ul>
{tutorials.map((tutorial) => (
<li key={tutorial.id}>{tutorial.title}</li>
))}
</ul>
</main>
);
}Pola ini cocok untuk halaman yang sebagian besar berisi konten atau data awal. Karena query berjalan di server, koneksi database tidak perlu berada di browser.
Kapan Memakai Client Component?
Client Component dibutuhkan ketika komponen memakai state interaktif, event handler, lifecycle hook, atau browser API seperti window, localStorage, dan navigator.
"use client";
import { useState } from "react";
export function LikeButton({ initialCount }: { initialCount: number }) {
const [count, setCount] = useState(initialCount);
return (
<button onClick={() => setCount((current) => current + 1)}>
Suka ({count})
</button>
);
}Jangan menambahkan "use client" ke seluruh halaman hanya karena ada satu tombol interaktif. Lebih baik buat komponen kecil yang khusus menangani interaksi.
import { LikeButton } from "./like-button";
export default async function ArticlePage() {
const article = await getArticle();
return (
<article>
<h1>{article.title}</h1>
<p>{article.content}</p>
<LikeButton initialCount={article.likeCount} />
</article>
);
}Dengan pola ini, halaman tetap bisa memanfaatkan Server Component, sedangkan JavaScript client hanya dikirim untuk tombol yang benar-benar membutuhkan interaksi.
Boundary Server dan Client
Boundary server-client adalah salah satu bagian paling penting. Server Component boleh mengimpor Client Component, tetapi Client Component tidak boleh mengimpor modul server seperti Prisma, fs, atau secret environment.
Contoh yang salah:
"use client";
import { prisma } from "@/lib/prisma";
export function BadClientComponent() {
// Jangan query database dari Client Component.
return null;
}Solusinya: ambil data di Server Component, lalu kirim data plain object ke Client Component.
export default async function Page() {
const user = await prisma.user.findUnique({
where: { email: "student@example.com" },
select: { id: true, name: true },
});
return <UserCard user={user} />;
}Pastikan props yang dikirim ke Client Component berupa data yang dapat diserialisasi, seperti string, number, boolean, array, object biasa, atau null. Jangan mengirim class instance, function, atau object kompleks yang memiliki method.
Data Fetching dengan fetch
Jika mengambil data dari API, fetch dapat dipakai langsung di Server Component.
type Product = {
id: string;
name: string;
price: number;
};
async function getProducts(): Promise<Product[]> {
const response = await fetch("https://example.com/api/products");
if (!response.ok) {
throw new Error("Gagal mengambil produk");
}
return response.json();
}
export default async function ProductsPage() {
const products = await getProducts();
return (
<main>
<h1>Produk</h1>
{products.map((product) => (
<p key={product.id}>{product.name}</p>
))}
</main>
);
}Untuk data yang berubah cepat, pikirkan strategi caching dan revalidasi. Untuk data user-specific, jangan menyimpan hasil fetch publik tanpa mempertimbangkan akses dan privasi.
Route Handler untuk API
Route handler ditempatkan di file route.ts. Ini berguna untuk endpoint API, webhook, health check, atau proxy kecil.
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({
status: "ok",
timestamp: new Date().toISOString(),
});
}Untuk method POST, validasi input sebelum memproses data.
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
const body = await request.json();
if (typeof body.email !== "string" || !body.email.includes("@")) {
return NextResponse.json(
{ error: "Email tidak valid" },
{ status: 400 }
);
}
return NextResponse.json({ message: "Data diterima" });
}Route handler tetap berjalan di server, tetapi bukan alasan untuk melewati validasi. Semua input dari client harus dianggap belum dipercaya.
Metadata Halaman
Metadata membantu browser, mesin pencari, dan preview social media memahami halaman.
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Tutorial Next.js App Router",
description: "Belajar App Router, Server Components, dan data fetching.",
alternates: {
canonical: "/tutorials/nextjs-app-router",
},
};
export default function Page() {
return <main>Isi halaman</main>;
}Untuk halaman dinamis, metadata dapat dibuat berdasarkan params.
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const article = await getArticle(slug);
return {
title: article.title,
description: article.excerpt,
};
}Perhatikan bahwa pada versi Next.js modern, beberapa nilai route seperti params dapat diperlakukan sebagai promise di App Router. Ikuti pola yang digunakan oleh project agar konsisten.
Error, Loading, dan Empty State
App Router mendukung file konvensional seperti loading.tsx dan error.tsx. Namun, untuk banyak kasus, kamu tetap perlu memikirkan state data secara eksplisit.
export default async function CoursesPage() {
const courses = await getPublishedCourses();
if (courses.length === 0) {
return (
<main>
<h1>Kursus</h1>
<p>Belum ada kursus yang dipublikasikan.</p>
</main>
);
}
return (
<main>
<h1>Kursus</h1>
{courses.map((course) => (
<article key={course.id}>{course.title}</article>
))}
</main>
);
}Empty state yang jelas membuat pengguna memahami kondisi aplikasi tanpa menebak apakah halaman rusak atau datanya memang belum ada.
Kesalahan Umum
| Kesalahan | Dampak | Perbaikan |
|---|---|---|
Semua halaman diberi "use client" | Bundle JavaScript membesar | Jadikan hanya komponen interaktif sebagai Client Component. |
| Mengimpor Prisma di Client Component | Error build atau risiko boundary | Query data di Server Component atau route handler. |
| Mengirim object kompleks ke Client Component | Error serialisasi | Kirim plain object yang sudah dipilih field-nya. |
| Tidak membuat empty state | Pengguna mengira halaman rusak | Tampilkan pesan saat data kosong. |
| Metadata dibiarkan generik | Preview dan SEO kurang informatif | Buat metadata per halaman. |
Praktik: Halaman Tutorial Sederhana
Contoh berikut memperlihatkan halaman list tutorial yang mengambil data dari server lalu memanggil card client kecil untuk bookmark.
// app/tutorials/page.tsx
import { prisma } from "@/lib/prisma";
import { TutorialBookmarkButton } from "./tutorial-bookmark-button";
export default async function TutorialsPage() {
const tutorials = await prisma.tutorial.findMany({
where: { published: true },
select: {
id: true,
slug: true,
title: true,
excerpt: true,
},
orderBy: { publishedAt: "desc" },
});
return (
<main>
<h1>Tutorial</h1>
<div>
{tutorials.map((tutorial) => (
<article key={tutorial.id}>
<h2>{tutorial.title}</h2>
<p>{tutorial.excerpt}</p>
<TutorialBookmarkButton tutorialId={tutorial.id} />
</article>
))}
</div>
</main>
);
}// app/tutorials/tutorial-bookmark-button.tsx
"use client";
import { useState } from "react";
export function TutorialBookmarkButton({ tutorialId }: { tutorialId: string }) {
const [saved, setSaved] = useState(false);
return (
<button
type="button"
onClick={() => setSaved((current) => !current)}
aria-pressed={saved}
>
{saved ? "Tersimpan" : "Simpan"} {tutorialId}
</button>
);
}Contoh ini menjaga query database tetap di server, sementara interaksi bookmark ada di client.
Coba Sendiri
Buat halaman app/articles/page.tsx dengan kriteria berikut:
- Mengambil daftar artikel di Server Component.
- Menampilkan empty state jika artikel kosong.
- Memakai Client Component kecil untuk tombol bookmark.
- Metadata halaman berisi
title,description, dancanonical. - Tidak ada import database di Client Component.
Setelah selesai, cek ulang apakah data yang dikirim ke Client Component hanya field yang dibutuhkan.
Cek Pemahaman
Kuis
Kapan sebuah komponen perlu diberi directive use client?
Kuis
Apa risiko mengirim class instance atau function dari Server Component ke Client Component?
Ringkasan
App Router mendorong kita memisahkan pekerjaan server dan client dengan lebih jelas. Gunakan Server Component untuk data, keamanan, dan HTML awal yang ringan. Gunakan Client Component hanya untuk interaksi. Susun route handler dengan validasi input, buat metadata yang relevan, dan selalu perhatikan boundary data yang dikirim ke browser.