Next.js · Rendering & data
Data fetching patterns
⚡ TL;DR — In the App Router you fetch in the Server Component with
async/await— nouseEffect, no client waterfall — and the two decisions that matter are where (server by default, client only when you must) and how to parallelise so independent requests don't wait on each other.
🧠 Mental model
Data fetching moved into the component tree, on the server. A Server Component is an async function; you await your data right where you render it. No loading flags, no client round-trip, no exposing your API key.
The framing that separates seniors: the danger is the waterfall. Because you can await inline, it's easy to accidentally serialise independent requests — each one waiting for the previous — turning three 100ms fetches into 300ms. The job is to await sequentially only when there's a true dependency, and parallelise everything else.
❌ Waterfall (serial) ✅ Parallel
await user() ─┐ const [u, p] = await Promise.all([
await posts() ─┤ 300ms user(), posts() // both start at once
await tags() ─┘ , tags(),
]); // ~100ms
⚙️ How it actually works
You fetch three ways, in order of preference:
- Server Component
await— the default. Runs on the server, data goes straight into the render, secrets stay safe. - Server Action — for mutations and on-demand reads triggered by the client.
- Client fetch (
useEffect, SWR, React Query) — only for data that's user-specific and interaction-driven, like live search or infinite scroll after hydration.
Next extends the native fetch with caching controls (cache, next.revalidate, next.tags). Multiple identical fetch calls in one render pass are deduped automatically by React request memoization — so you can call your getUser() helper in the layout and the page without a double request. For non-fetch data sources (a DB client, an SDK), wrap the function in React's cache() to get the same per-request dedupe.
To parallelise while still streaming, don't await at the top — pass the promise down or wrap the slow part in <Suspense> so the rest of the page renders immediately.
💻 Code
// ❌ Sequential waterfall — tags() waits for posts() waits for user()
export default async function Page() {
const user = await getUser();
const posts = await getPosts(); // could have started already
const tags = await getTags(); // could have started already
return <Feed user={user} posts={posts} tags={tags} />;
}
// ✅ Parallel — independent requests fire together
export default async function Page() {
const [user, posts, tags] = await Promise.all([getUser(), getPosts(), getTags()]);
return <Feed user={user} posts={posts} tags={tags} />;
}
// ✅ Stream the slow part: start the fetch, don't await it here, let Suspense wait
import { Suspense } from 'react';
export default function Page() {
const slow = getSlowReport(); // NOT awaited — kicks off immediately
return (
<>
<FastHeader /> {/* renders now */}
<Suspense fallback={<Skeleton />}>
<Report data={slow} /> {/* awaits the promise inside, streams in */}
</Suspense>
</>
);
}
// Dedupe a non-fetch source (DB/ORM) per request
import { cache } from 'react';
export const getUser = cache(async (id: string) => db.user.find(id));
⚖️ Trade-offs
- Server fetching is the default and it's the right one — colocated, secure, cacheable, no client waterfall. Reach for client fetching only when data depends on post-hydration interaction.
Promise.allvs streaming is a real choice.Promise.allblocks the whole render until all resolve (good when the page is useless without all of it). Streaming with<Suspense>shows the fast parts immediately and fills slow parts in (better perceived performance, but more moving parts and layout shift risk).- When NOT to fetch on the server: rapidly-changing, per-interaction client state (typeahead, optimistic lists) belongs in a client component with SWR/React Query, which give you client caching, revalidation, and mutation ergonomics the server model doesn't.
💣 Gotchas interviewers probe
- Accidental waterfalls. The most probed issue. Sequential
awaits of independent data is the classic performance bug —Promise.allor streaming fixes it. fetchis no longer cached by default in Next 15. In Next 14 it was; now you opt in withcache: 'force-cache'ornext.revalidate. Assuming caching is a real trap.- Request memoization only covers
fetch. For an ORM/DB call, you need React'scache()to dedupe — otherwise the same query runs once per component that calls it. - You can't fetch server data with
useEffectand keep secrets safe — that runs in the browser. Server data belongs in Server Components. awaitat the top of a page blocks streaming — the whole route waits. If you want the shell to paint first, move theawaitbehind a<Suspense>boundary.searchParamsmakes a page dynamic. Reading it opts the route out of static rendering — expected, but surprises people chasing a static build.
🎯 Say this in the interview
"In the App Router I fetch in the Server Component with
async/await— data goes straight into the render, nouseEffect, no client waterfall, and secrets stay on the server. The thing I actively watch for is the accidental waterfall: because awaiting inline is so easy, independent requests get serialised. So I usePromise.allfor independent data, or I don't await at the top at all and wrap the slow part in<Suspense>so the fast parts stream first. React dedupes identicalfetchcalls per request automatically, and for a DB or SDK call I wrap it in React'scache()to get the same dedupe. Client fetching with something like React Query I reserve for genuinely interaction-driven data. One currency note: in Next 15fetchisn't cached by default anymore, so I opt in explicitly."
🔗 Go deeper
- Next.js — Data Fetching — patterns and the extended
fetch. - Next.js — Sequential vs Parallel fetching — avoiding waterfalls.
- React —
cache— per-request dedupe for non-fetch sources.