Data fetching in Next.js
Server Components collapse the classic "fetch in useEffect, track loading/error state by hand" pattern down to a single await — but that simplicity comes with its own real rules about caching, waterfalls, and where fetches are actually allowed to happen.
4 min read
The classic pattern this replaces
// The pre-Server-Components way — this domain's own useEffect lesson's pattern
"use client";
function ProductPage({ id }) {
const [product, setProduct] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/products/${id}`).then((r) => r.json()).then((data) => {
setProduct(data);
setLoading(false);
});
}, [id]);
if (loading) return <p>Loading...</p>;
return <div>{product.name}</div>;
}// Server Component — the entire useState/useEffect/loading-tracking dance is gone
async function ProductPage({ params }) {
const product = await db.products.findUnique({ where: { id: params.id } });
return <div>{product.name}</div>;
}In a Server Component, data fetching is just await — no useState to hold the result, no useEffect to trigger the fetch, no manually-tracked loading boolean at all. The component simply doesn't render (and produce any HTML) until the await resolves — the previous lesson's loading.tsx file is what Next.js shows automatically during that wait, replacing the manual if (loading) return <p>Loading...</p> entirely.
Fetches in Server Components can run directly against a database — no API route required
// This is a real, legitimate Server Component — no /api/products route needed at all
async function ProductPage({ params }) {
const product = await db.products.findUnique({ where: { id: params.id } });
return <div>{product.name}</div>;
}Because a Server Component genuinely runs on the server, it can call a database client, read a file, or do anything else server-only code could always do — directly, with no HTTP round trip to a separate API endpoint required at all. This is a real architectural shift from classic React (where the browser can only ever reach data through a network request to some API), and it eliminates an entire category of "build an API route just so the frontend can fetch what the server already had direct access to" boilerplate for data that's only ever consumed by this same app's own Server Components.
Automatic request deduplication: fetching the same thing twice costs one round trip
async function getUser(id) {
const res = await fetch(`https://api.example.com/users/${id}`);
return res.json();
}
async function Header({ userId }) {
const user = await getUser(userId); // first call
return <p>{user.name}</p>;
}
async function Sidebar({ userId }) {
const user = await getUser(userId); // SAME url, same request — Next.js deduplicates automatically
return <p>{user.email}</p>;
}If Header and Sidebar both render during the same request and both call fetch() with the identical URL, Next.js automatically deduplicates them into a single actual network request — both components get the result, only one real fetch happens. This is a genuine, real optimization built into the fetch function Next.js provides in Server Components specifically, and it's part of what makes "just fetch what each component needs, directly inside it" a reasonable pattern, rather than something that would otherwise require manually lifting a shared fetch up to a common ancestor purely to avoid duplicate requests.
Waterfalls: the real performance trap sequential awaits create
// Slow — each await blocks the next fetch from even starting
async function Page() {
const user = await getUser(); // waits for this...
const posts = await getPosts(user.id); // ...before this even starts
return <div>...</div>;
}
// Fast — both fetches start immediately, run concurrently
async function Page() {
const userPromise = getUser();
const postsPromise = getPosts(); // starts immediately, doesn't wait for userPromise
const [user, posts] = await Promise.all([userPromise, postsPromise]);
return <div>...</div>;
}When one await genuinely doesn't depend on a previous one's result, writing them sequentially still forces them to run one after another — getPosts in the first example doesn't actually need user at all here, but it still waits for getUser to finish first, purely because of the order they're written in. This is a waterfall, and it's a real, common source of slow Server Components — the fix, Promise.all, is ordinary JavaScript, nothing Next.js-specific, but it's exactly the kind of thing that's easy to miss once await makes sequential code look simple and correct even when it's needlessly slow.
Caching: fetch requests are cached by default, and that default is a real, common gotcha
// Cached by default — subsequent requests may reuse this result, even across different visitors
const res = await fetch("https://api.example.com/data");
// Opt out explicitly for data that must be fresh every request
const res = await fetch("https://api.example.com/data", { cache: "no-store" });Next.js's extended fetch caches responses by default — a real, deliberate choice for performance, but one that surprises people who expect fetch to always hit the network fresh the way it does in a browser or plain Node script. Data that genuinely must be fresh on every request (the previous rendering-strategies lesson's SSR case) needs cache: "no-store" explicitly, or the surrounding route needs to opt into dynamic rendering — silently getting stale cached data because this default wasn't accounted for is a real, common class of Next.js bug specifically because it doesn't look like a bug at all; the code just quietly serves an old response.
Further reading
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. What replaces the classic useState + useEffect + manual loading-state pattern inside a Server Component?
2. What is a 'waterfall,' and why does it happen even though the code looks straightforward?
3. Why can silently getting stale data be a real, common Next.js bug?