Next.js App Router sudah memungkinkan Server Component melakukan await pada fetch secara langsung, jadi wajar jika muncul pertanyaan mengapa perlu menambahkan TanStack Query sama sekali. Jawaban jujurnya adalah fetch saja hanya memberi data pada saat pemuatan pertama dan tidak lebih dari itu, tidak ada refetch di latar belakang, tidak ada invalidasi cache setelah mutasi, tidak ada deduplikasi request antar komponen yang berdekatan, dan tidak ada penanganan retry atau data basi secara otomatis di sisi client. Begitu sebuah halaman memiliki bagian yang interaktif, misalnya filter, form yang menyimpan lalu memuat ulang daftar, atau badge status yang harus berubah tanpa reload penuh, fetch biasa tidak lagi cukup dan anda akan berakhir menulis ulang logika cache yang sebenarnya sudah dipecahkan oleh TanStack Query sejak lama.
TanStack Query tetap memegang seluruh siklus hidup di sisi client tersebut, dan App Router memberikan cara yang bersih untuk mengisi cache-nya dari server sehingga tampilan pertama tidak pernah berupa spinner loading. Artikel ini membahas alur prefetch, dehydrate, dan hydrate secara lengkap untuk App Router, kapan pendekatan ini layak dipakai dibanding fetch biasa, dan kesalahan yang diam-diam membuat sebuah halaman mengambil data yang sama dua kali. Semua yang dibahas di sini mengasumsikan TanStack Query v5, versi yang pertama kali mendukung streaming query yang masih pending melalui boundary Suspense, bukan hanya mendukung prefetch yang sepenuhnya di-await.
Apa yang sebenarnya dilakukan prefetch, dehydrate, dan hydrate
Server rendering dengan TanStack Query adalah proses serah terima tiga langkah antara server dan browser. Setiap langkah memiliki satu tugas, dan melewatkan salah satunya akan memutus keseluruhan rangkaian. Cara yang lebih mudah untuk memahaminya adalah menganggapnya bukan sebagai fitur khusus App Router, melainkan sekadar meneruskan cache yang sudah dipanaskan sebagai sebuah prop, sama seperti meneruskan nilai hasil komputasi server lainnya ke Client Component.
- Prefetch: Server Component membuat QueryClient dan memanggil prefetchQuery, yang menjalankan fungsi query di server dan menyimpan hasilnya di cache dalam memori milik client tersebut.
- Dehydrate: cache yang sudah terisi diserialisasi menjadi objek biasa yang aman untuk JSON, lalu disematkan ke dalam markup hasil render server dan dikirim bersama HTML awal.
- Hydrate: di sisi client, HydrationBoundary membaca state terserialisasi tersebut dan memuatnya langsung ke cache QueryClient milik browser, sebelum komponen apapun sempat memanggil useQuery.
Menyiapkan QueryClient per request
QueryClient tidak boleh dijadikan singleton pada level module di server, karena setiap request akan berbagi cache yang sama untuk pengguna yang berbeda. Sebagai gantinya, bungkus factory client tersebut dengan fungsi cache dari React sehingga cakupannya terbatas pada satu request di server, dan dibuat sekali secara lazy di browser. Langkah ini juga menetapkan staleTime yang tidak nol, yang lebih penting dari kelihatannya, tanpa itu, query yang baru dihydrate langsung dianggap basi begitu tiba di browser, dan useQuery langsung menembakkan ulang request di latar belakang, sehingga tujuan prefetch menjadi sia-sia. Factory yang sama sebaiknya diimpor oleh setiap route yang membutuhkan prefetch, bukan setiap route mendefinisikan opsi QueryClient sendiri-sendiri, sehingga staleTime dan perilaku retry tetap konsisten di seluruh aplikasi.
// lib/get-query-client.ts
import { QueryClient, defaultShouldDehydrateQuery } from "@tanstack/react-query"
import { cache } from "react"
// React's cache() scopes this to a single request on the server,
// and creates a fresh browser QueryClient the first time it runs on the client.
export const getQueryClient = cache(() => new QueryClient({
defaultOptions: {
queries: {
// Prefetched data stays "fresh" for 60s so the client
// does not immediately refetch on mount.
staleTime: 60 * 1000,
},
dehydrate: {
// Also dehydrate queries that are still pending, so we
// can stream them and resolve on the client (see section 4).
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) || query.state.status === "pending",
},
},
}))Tetapkan staleTime sesuai dengan seberapa sering data yang mendasarinya benar-benar berubah. Katalog produk mungkin masih wajar dengan lima menit, sementara halaman status pesanan mungkin membutuhkan sepuluh detik. Tidak ada angka yang benar secara universal.
Prefetch di Server Component dan hydrate di Client Component
Server Component melakukan prefetch dan membungkus komponen turunannya dengan HydrationBoundary, meneruskan state cache yang sudah didehydrate. Client Component di dalamnya memanggil useQuery dengan queryKey dan queryFn yang sama persis. Jika key tersebut cocok dengan yang diprefetch, useQuery membaca langsung dari cache yang sudah dihydrate dan langsung dirender tanpa request jaringan maupun status loading pada tampilan pertama. Perlu diperhatikan bahwa queryFn itu sendiri tetap harus ada di Client Component walaupun tidak akan dijalankan pada pemuatan pertama, karena TanStack Query membutuhkannya untuk refetch berikutnya, baik dipicu oleh window refocus, invalidate manual, maupun habisnya jendela waktu staleTime.
// app/[locale]/(pages)/orders/page.tsx — Server Component
import { HydrationBoundary, dehydrate } from "@tanstack/react-query"
import { getQueryClient } from "@/lib/get-query-client"
import { OrderList } from "./order-list"
import { getOrders } from "@/lib/orders"
export default async function OrdersPage() {
const queryClient = getQueryClient()
await queryClient.prefetchQuery({
queryKey: ["orders"],
queryFn: getOrders,
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<OrderList />
</HydrationBoundary>
)
}
// app/[locale]/(pages)/orders/order-list.tsx — Client Component
"use client"
import { useQuery } from "@tanstack/react-query"
import { getOrders } from "@/lib/orders"
export function OrderList() {
// Same queryKey and queryFn as the server prefetch — this
// reads the hydrated cache instead of firing a new request.
const { data: orders } = useQuery({
queryKey: ["orders"],
queryFn: getOrders,
})
return (
<ul>
{orders?.map((order) => <li key={order.id}>{order.reference}</li>)}
</ul>
)
}Array queryKey di Client Component harus cocok persis dengan key yang diprefetch, termasuk urutan argumen dan tipe nilainya. Key seperti ["orders", 1] di server dan ["orders", "1"] di client adalah dua entri cache yang berbeda, dan client akan diam-diam mengirim fetch baru sepenuhnya alih-alih membaca data yang sudah dihydrate.
Melakukan streaming pada query yang lambat, bukan menunggunya
Menunggu setiap panggilan prefetchQuery dengan await akan memblokir seluruh route sampai query paling lambat selesai, yang menghilangkan salah satu keunggulan terbaik App Router, yaitu streaming. Sebagai gantinya, cukup await query yang menentukan konten di atas lipatan layar, dan jalankan query yang lambat tanpa menunggunya. Konfigurasi dehydrate pada TanStack Query bisa diatur agar juga menyerialisasi query yang masih pending, sehingga promise yang sedang berjalan tersebut ikut di-streaming ke client. Client Component yang menggunakan useSuspenseQuery pada key yang sama kemudian cukup melanjutkan request yang sudah dimulai tersebut di dalam boundary Suspense, bukan memulai request baru. Ini adalah mekanisme yang sama dengan yang digunakan API use milik React sendiri untuk streaming sebuah promise sebagai prop, TanStack Query hanya membungkusnya dengan caching, retry, dan sisa siklus hidup query di atasnya.
// Streaming variant — do NOT await the slow query on the server.
export default async function DashboardPage() {
const queryClient = getQueryClient()
// Fast query: await it, it is ready before the HTML streams.
await queryClient.prefetchQuery({
queryKey: ["summary"],
queryFn: getSummary,
})
// Slow query: fire it but do not await. The pending promise is
// dehydrated and streamed to the client, which resumes it.
queryClient.prefetchQuery({
queryKey: ["annual-report"],
queryFn: getAnnualReport,
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Summary />
<Suspense fallback={<ReportSkeleton />}>
<AnnualReport />
</Suspense>
</HydrationBoundary>
)
}
// Client Component — suspends on the still-resolving stream
"use client"
function AnnualReport() {
const { data } = useSuspenseQuery({
queryKey: ["annual-report"],
queryFn: getAnnualReport,
})
return <Report data={data} />
}TanStack Query dibanding fetch biasa
Menggunakan TanStack Query pada setiap Server Component adalah pendekatan yang berlebihan. Gunakan fetch biasa atau panggilan database langsung ketika data hanya dibaca sekali per request dan tidak pernah disentuh oleh interaktivitas di sisi client. Gunakan TanStack Query ketika Client Component perlu melakukan refetch, polling, invalidasi setelah mutasi, atau berbagi query yang sama antar komponen yang tidak memiliki parent bersama untuk meneruskan props. Sinyal yang baik adalah apakah anda pernah perlu memanggil router.refresh hanya untuk menampilkan data baru, jika jawabannya ya, refresh tersebut sebenarnya adalah versi manual yang lebih buruk dari apa yang sudah dilakukan invalidateQueries.
| Situasi | Fetch biasa / Server Component async | TanStack Query dengan prefetch |
|---|---|---|
| Konten statis yang dirender sekali, tanpa interaksi client | Lebih sederhana, lebih sedikit dependency | Overhead yang tidak perlu |
| Data yang harus diinvalidasi oleh mutasi di halaman tersebut | Butuh refresh router manual atau reload penuh | invalidateQueries memperbarui cache secara instan |
| Data yang sama dibutuhkan di beberapa Client Component yang tidak berkaitan | Prop drilling atau panggilan fetch yang duplikat | Satu entri cache, dibagikan otomatis berdasarkan queryKey |
Aturan praktis yang bermanfaat: jika halaman tidak pernah perlu berkomunikasi lagi dengan server setelah render awal, kemungkinan besar anda tidak membutuhkan TanStack Query sama sekali untuk data tersebut.
Menghindari jebakan double-fetch
Bug paling umum pada setup ini adalah halaman yang mengambil data yang sama dua kali, sekali di server saat prefetch, dan sekali lagi di client tepat saat komponen dimount. Biasanya penyebabnya salah satu dari empat hal berikut, dan keempatnya mudah diperbaiki begitu anda tahu apa yang harus dicari.
- queryKey yang tidak cocok antara prefetch di server dan pemanggilan useQuery di client, sehingga cache menganggapnya sebagai dua entri yang tidak berkaitan.
- staleTime yang dibiarkan pada nilai default nol, sehingga data yang sudah dihydrate langsung dianggap basi dan diambil ulang saat dimount.
- Membuat instance QueryClient baru di dalam tree Client Component alih-alih mengandalkan satu instance yang sudah dihydrate dari QueryClientProvider.
- Lupa menambahkan HydrationBoundary sama sekali, sehingga state yang sudah didehydrate dari server tidak pernah mencapai cache di browser.
Buka tab Network saat menguji halaman yang menggunakan prefetch. Jika anda melihat request yang sama tertembak dua kali dalam satu detik setelah halaman dimuat, hampir selalu penyebabnya adalah queryKey yang tidak cocok atau staleTime default nol, periksa kedua hal ini terlebih dahulu sebelum yang lain.

