📡 Chapter 11: Data Fetching
Getting data into your components with
await, running requests in parallel, and knowing when the client should fetch instead.
📖 Plain English Explanation
In client-side React, fetching data is a small ritual: declare state, declare a loading flag, declare an error flag, fire a useEffect, handle the race conditions, clean up on unmount. Twenty lines to display a list.
In a Server Component it's one line:
const posts = await db.post.findMany()
That's the headline. The rest of this chapter is about the things that actually go wrong: requests that run one after another when they should run together, duplicate queries you didn't notice, and knowing when data genuinely belongs on the client.
🎣 Fetching in Server Components
Any Server Component can be async:
// app/posts/page.tsx
export default async function Page() {
const res = await fetch('https://api.example.com/posts')
const posts = await res.json()
return (
<ul>
{posts.map((post: Post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
// app/posts/page.js
export default async function Page() {
const res = await fetch('https://api.example.com/posts')
const posts = await res.json()
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
Or skip HTTP entirely and query your database:
// app/posts/page.tsx
import { db } from '@/lib/db'
export default async function Page() {
const posts = await db.post.findMany({
where: { published: true },
orderBy: { createdAt: 'desc' },
take: 20,
})
return <PostList posts={posts} />
}
// app/posts/page.js
import { db } from '@/lib/db'
export default async function Page() {
const posts = await db.post.findMany({
where: { published: true },
orderBy: { createdAt: 'desc' },
take: 20,
})
return <PostList posts={posts} />
}
If your database is on the server, don't build an API route to talk to it. app/api/posts/route.ts that only wraps a Prisma query, called by a page on the same server, is a network round-trip you're paying for nothing. Route Handlers are for external consumers (Chapter 13).
Error handling
Add a try/catch when you have a real fallback, otherwise let error.tsx handle it:
// app/dashboard/page.tsx
export default async function Page() {
const stats = await getStats() // throws → error.tsx catches it
// Only catch when you can degrade gracefully
let weather = null
try {
weather = await getWeather()
} catch {
// widget is optional — page still works without it
}
return (
<>
<Stats data={stats} />
{weather ? <Weather data={weather} /> : <p>Weather unavailable</p>}
</>
)
}
🐌 Sequential vs parallel — the one that costs you
This is where most Next.js performance problems live.
The waterfall
// ❌ 900ms
export default async function Page() {
const user = await getUser() // 300ms
const posts = await getPosts() // 300ms — waits for user, needlessly
const stats = await getStats() // 300ms — waits for posts, needlessly
return <Dashboard user={user} posts={posts} stats={stats} />
}
Each await blocks the next line. Three independent requests take three times as long as one.
Promise.all
// ✅ 300ms
export default async function Page() {
const [user, posts, stats] = await Promise.all([
getUser(),
getPosts(),
getStats(),
])
return <Dashboard user={user} posts={posts} stats={stats} />
}
// ✅ 300ms
export default async function Page() {
const [user, posts, stats] = await Promise.all([
getUser(),
getPosts(),
getStats(),
])
return <Dashboard user={user} posts={posts} stats={stats} />
}
All three start immediately; you wait for the slowest.
Promise.allSettled when one may fail
Promise.all rejects as soon as any promise rejects. If a failure of one shouldn't kill the page:
export default async function Page() {
const [userResult, feedResult] = await Promise.allSettled([
getUser(),
getSocialFeed(), // third-party, flaky
])
const user = userResult.status === 'fulfilled' ? userResult.value : null
const feed = feedResult.status === 'fulfilled' ? feedResult.value : []
if (!user) notFound()
return <Profile user={user} feed={feed} />
}
When sequential is correct
Sometimes the second request genuinely needs the first:
export default async function Page(props: PageProps<'/users/[id]'>) {
const { id } = await props.params
const user = await getUser(id) // must come first
const posts = await getPostsByAuthor(user.authorId) // depends on user
return <Profile user={user} posts={posts} />
}
Nothing wrong with that. The problem is accidental sequencing — awaits that look dependent but aren't.
Preloading to break a dependency
If the dependency is partial, start the independent work early:
// ❌ sequential
const user = await getUser(id) // 300ms
const posts = await getPostsByAuthor(user.authorId) // 300ms
const trending = await getTrending() // 300ms — independent!
// total: 900ms
// ✅ overlap the independent one
const trendingPromise = getTrending() // starts now, no await
const user = await getUser(id)
const posts = await getPostsByAuthor(user.authorId)
const trending = await trendingPromise // already done
// total: 600ms
Kicking off a promise without awaiting it starts the work immediately. Await it later when you need the value.
Or use Suspense, which parallelizes for free
Chapter 8's pattern applies here too:
export default function Page() {
return (
<>
<Suspense fallback={<Skeleton />}><UserPanel /></Suspense>
<Suspense fallback={<Skeleton />}><PostsPanel /></Suspense>
<Suspense fallback={<Skeleton />}><StatsPanel /></Suspense>
</>
)
}
async function UserPanel() {
const user = await getUser() // all three start at once
return <Card user={user} />
}
Each component fetches independently, and the user sees each panel the moment it's ready.
♻️ Request deduplication
Say three components all need the current user:
// app/layout.tsx → getUser()
// app/page.tsx → getUser()
// app/Sidebar.tsx → getUser()
Three database queries per request? No — if you set it up right.
fetch dedupes automatically
Next.js extends fetch so that identical requests (same URL, same options) within a single render pass hit the network once:
// lib/api.ts
export async function getUser() {
const res = await fetch('https://api.example.com/me')
return res.json()
}
Called from ten components → one network request. Nothing to configure.
cache() for everything else
Database clients, file reads, and any non-fetch source need React's cache():
// lib/user.ts
import { cache } from 'react'
import { db } from '@/lib/db'
import 'server-only'
export const getUser = cache(async (id: string) => {
console.log('DB query for', id) // logs once per request, not per call
return db.user.findUnique({ where: { id } })
})
// lib/user.js
import { cache } from 'react'
import { db } from '@/lib/db'
import 'server-only'
export const getUser = cache(async (id) => {
console.log('DB query for', id)
return db.user.findUnique({ where: { id } })
})
cache() memoizes by arguments for the duration of one request. getUser('123') called five times runs once; getUser('456') is a separate entry.
This is what makes the Chapter 3 advice — "fetch in the layout and the page" — actually free.
cache()is per-request memoization, not a persistent cache. For caching across requests, seeuse cachein Chapter 14.
🧱 The Data Access Layer pattern
Rather than scattering queries across components, centralize them:
// lib/dal.ts
import 'server-only'
import { cache } from 'react'
import { cookies } from 'next/headers'
import { db } from '@/lib/db'
export const verifySession = cache(async () => {
const token = (await cookies()).get('session')?.value
if (!token) return null
return decrypt(token)
})
export const getCurrentUser = cache(async () => {
const session = await verifySession()
if (!session) return null
return db.user.findUnique({
where: { id: session.userId },
select: { id: true, name: true, email: true, avatarUrl: true },
// ↑ never select passwordHash
})
})
export const getUserPosts = cache(async () => {
const session = await verifySession()
if (!session) return []
// Authorization lives WITH the query — can't be bypassed
return db.post.findMany({ where: { authorId: session.userId } })
})
// lib/dal.js
import 'server-only'
import { cache } from 'react'
import { cookies } from 'next/headers'
import { db } from '@/lib/db'
export const verifySession = cache(async () => {
const token = (await cookies()).get('session')?.value
if (!token) return null
return decrypt(token)
})
export const getCurrentUser = cache(async () => {
const session = await verifySession()
if (!session) return null
return db.user.findUnique({
where: { id: session.userId },
select: { id: true, name: true, email: true, avatarUrl: true },
})
})
Four wins:
- Deduplicated —
cache()on every function - Secure —
server-onlyblocks client imports - Authorized at the source — the session check can't be forgotten by a caller
- Minimal — explicit
selectkeepspasswordHashout of every result
Chapter 18 builds on this heavily.
🌍 fetch options in Next.js 16
Next.js extends the standard fetch with caching controls:
// Always fresh — never cached
await fetch(url, { cache: 'no-store' })
// Cache and revalidate after 60 seconds
await fetch(url, { next: { revalidate: 60 } })
// Cache and tag for on-demand invalidation
await fetch(url, { next: { tags: ['posts'] } })
// Explicit long-lived cache
await fetch(url, { cache: 'force-cache' })
Chapters 14 and 15 cover caching and revalidation properly. For now, the important default:
In Next.js 15 and 16,
fetchis NOT cached by default. Requests areno-storeunless you opt in. This reversed the Next.js 13/14 behavior, where everything was cached and people were constantly confused by stale data.
If a tutorial tells you to write cache: 'no-store' to "turn off caching", it's from Next.js 13/14. That's now the default.
🖱️ Fetching on the client
Server-side fetching handles most cases. Client fetching is right when:
- Data changes while the page is open (live prices, notifications, presence)
- The user triggers it (search-as-you-type, infinite scroll)
- It's user-specific and shouldn't be in a shared cache
- You're polling
With SWR
// app/components/LiveCount.tsx
'use client'
import useSWR from 'swr'
const fetcher = (url: string) => fetch(url).then((r) => r.json())
export function LiveCount({ initialCount }: { initialCount: number }) {
const { data, error, isLoading } = useSWR('/api/count', fetcher, {
refreshInterval: 5000,
fallbackData: { count: initialCount }, // seeded from the server render
})
if (error) return <span>—</span>
return <span>{data.count} online</span>
}
// app/components/LiveCount.js
'use client'
import useSWR from 'swr'
const fetcher = (url) => fetch(url).then((r) => r.json())
export function LiveCount({ initialCount }) {
const { data, error } = useSWR('/api/count', fetcher, {
refreshInterval: 5000,
fallbackData: { count: initialCount },
})
if (error) return <span>—</span>
return <span>{data.count} online</span>
}
The fallbackData trick is worth internalizing: server-render the first value, then let the client keep it fresh. The user never sees a loading state.
// app/page.tsx — Server Component
import { LiveCount } from './LiveCount'
import { getCount } from '@/lib/stats'
export default async function Page() {
const count = await getCount()
return <LiveCount initialCount={count} />
}
Search as you type
// app/search/SearchBox.tsx
'use client'
import { useState, useDeferredValue } from 'react'
import useSWR from 'swr'
export function SearchBox() {
const [query, setQuery] = useState('')
const deferred = useDeferredValue(query)
const { data, isLoading } = useSWR(
deferred ? `/api/search?q=${encodeURIComponent(deferred)}` : null,
(url) => fetch(url).then((r) => r.json())
)
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
{isLoading && <Spinner />}
<ul>{data?.results.map((r: Result) => <li key={r.id}>{r.title}</li>)}</ul>
</div>
)
}
Passing null as the SWR key skips the request entirely when the query is empty.
Server-side alternative: URL state
Before reaching for client fetching, ask whether the query belongs in the URL. Search results driven by searchParams are shareable, bookmarkable, and back-button friendly — and stay on the server:
// app/search/page.tsx
export default async function Page(props: PageProps<'/search'>) {
const { q } = await props.searchParams
const results = q ? await search(String(q)) : []
return (
<>
<SearchForm defaultValue={q} /> {/* client, just the input */}
<ResultList results={results} /> {/* server */}
</>
)
}
This is usually the better design for anything a user might want to share.
🔄 Streaming a promise to the client
Covered in Chapter 10, worth repeating here because it's a data-fetching pattern:
// app/page.tsx — Server Component
import { Suspense } from 'react'
import { Comments } from './Comments'
export default function Page() {
const promise = getComments() // starts on the server, not awaited
return (
<>
<Article />
<Suspense fallback={<CommentsSkeleton />}>
<Comments promise={promise} />
</Suspense>
</>
)
}
// app/Comments.tsx
'use client'
import { use } from 'react'
export function Comments({ promise }: { promise: Promise<Comment[]> }) {
const comments = use(promise)
return <ul>{comments.map((c) => <li key={c.id}>{c.body}</li>)}</ul>
}
The fetch starts during the server render — no waiting for hydration — but the article doesn't block on it.
⚠️ Common Pitfalls
1. The accidental waterfall
The default mistake. Every time you write two awaits in a row, ask: does the second actually need the first?
// ❌
const a = await getA()
const b = await getB()
// ✅
const [a, b] = await Promise.all([getA(), getB()])
2. Fetching your own API from a Server Component
// ❌ pointless round-trip
export default async function Page() {
const res = await fetch('http://localhost:3000/api/posts')
const posts = await res.json()
}
// ✅
export default async function Page() {
const posts = await db.post.findMany()
}
You're already on the server. Skip the HTTP layer.
3. Forgetting cache() on non-fetch sources
fetch dedupes automatically. Prisma, Drizzle, fs, and Redis do not. Wrap them.
4. Fetching in a useEffect when the server could do it
// ❌ loading spinner, no SEO, extra round-trip
'use client'
useEffect(() => { fetch('/api/posts').then(...) }, [])
// ✅
export default async function Page() {
const posts = await db.post.findMany()
}
5. SELECT *
// ❌ passwordHash, internalNotes, everything
const user = await db.user.findUnique({ where: { id } })
<Profile user={user} />
// ✅
const user = await db.user.findUnique({
where: { id },
select: { id: true, name: true, avatarUrl: true },
})
Anything passed to a Client Component is visible in the RSC payload. Select what you need.
6. A waterfall hidden in a component tree
async function Page() {
const user = await getUser() // 200ms
return <Posts userId={user.id} />
}
async function Posts({ userId }) {
const posts = await getPosts(userId) // 200ms, starts after the above
return <Comments postIds={posts.map(p => p.id)} />
}
async function Comments({ postIds }) {
const comments = await getComments(postIds) // 200ms
}
// 600ms — and no Suspense boundary means the user sees nothing until the end
Each level waits for its parent. Fix: flatten the fetches into the page with Promise.all where possible, or add Suspense boundaries so at least the shell streams.
7. Assuming fetch is still cached by default
It isn't, since Next.js 15. If you want caching, opt in.
🎯 When & Why to Use
Server Component fetch when:
✅ Data is needed for the initial render
✅ It should be indexable by search engines
✅ It requires secrets or direct database access
✅ It's the same for everyone, or per-user but not live
→ the default for ~90% of data
Client fetch (SWR / TanStack Query) when:
✅ Data changes while the page is open
✅ The user triggers it repeatedly (typeahead, infinite scroll)
✅ You're polling or using websockets
✅ It's optimistic or offline-first
URL state (searchParams) when:
✅ The result should be shareable and bookmarkable
✅ Filters, sorting, pagination
→ often better than client fetching for search UIs
🏋️ Mini Practice Problems
Problem 1: Fix the waterfall
Given these durations, rewrite for the shortest total time. Say what the time was and what it becomes.
export default async function Page({ params }) {
const { id } = await params
const user = await getUser(id) // 200ms
const settings = await getSettings(id) // 150ms
const posts = await getPosts(user.authorId) // 300ms
const trending = await getTrending() // 250ms
return <View {...{ user, settings, posts, trending }} />
}
Problem 2: How many queries?
// lib/data.ts
export async function getUser(id) {
return db.user.findUnique({ where: { id } })
}
getUser('1') is called in the layout, the page, and two components. How many database queries run? Fix it so only one does.
Problem 3: Choose the strategy
Server fetch, client fetch, or URL state?
- A. A blog post's content
- B. A live stock ticker
- C. Product search results the user should be able to share
- D. Unread notification count, updating every 30s
- E. The logged-in user's name in the header
- F. Autocomplete suggestions while typing
Problem 4: Spot the leak
export default async function Page() {
const user = await db.user.findUnique({ where: { id: '1' } })
return <ProfileEditor user={user} /> // 'use client'
}
What ends up visible in the browser, and how do you check? Rewrite it safely.
💼 Interview Notes
Common Questions
Q: How do you fetch data in the App Router?
Make the Server Component async and await directly — a fetch call, a database query, whatever. No getServerSideProps, no useEffect. The result is rendered on the server and streamed as HTML.
Q: How do you avoid request waterfalls?
Use Promise.all for independent requests in the same component. Start non-dependent promises early without awaiting them. Split slow sections into separate Suspense-wrapped components, which fetch concurrently.
Q: How does Next.js deduplicate requests?
Identical fetch calls within a single render pass are memoized automatically. For database clients and other non-fetch sources, wrap the function in React's cache() to get the same per-request memoization.
Q: Is fetch cached by default?
No, not since Next.js 15. Requests are no-store by default; you opt into caching with cache: 'force-cache', next: { revalidate }, or use cache.
Q: When should you fetch on the client? When data changes while the page is open, when the user triggers it repeatedly, when it's polled, or when it's genuinely user-interaction driven. Everything else belongs on the server.
Q: What is a Data Access Layer and why use one?
A server-only module holding all your data functions, each wrapped in cache() and each performing its own authorization check. It deduplicates queries, prevents client imports, and makes it impossible for a caller to forget the permission check.
Q: What's the risk of passing a database record straight to a Client Component?
Everything in it is serialized into the RSC payload and visible in DevTools — including password hashes and internal fields. Always select explicitly and pass only the fields the UI needs.
🏢 Asked at Companies
- Vercel: "This dashboard takes 2 seconds. Here are five queries. Make it fast."
- Airbnb: "Search results need to be shareable and fast. Server or client fetching? Defend it."
- Stripe: "How do you guarantee an authorization check runs before every query in a large codebase?"
- Netflix: "Explain request memoization versus a persistent data cache. When does each apply?"
📊 Visual Memory Aid
SEQUENTIAL vs PARALLEL
❌ await a; await b; await c
[══a══][══b══][══c══] 900ms
✅ await Promise.all([a, b, c])
[══a══]
[══b══] 300ms
[══c══]
✅ Suspense boundaries
[══a══] → renders at 300ms
[════b════] → renders at 500ms
[══c══] → renders at 300ms
shell renders at 0ms
DEDUPLICATION
fetch(url) → automatic, per render pass
db.query() → needs cache()
fs.readFile() → needs cache()
import { cache } from 'react'
export const getUser = cache(async (id) => db.user.find(id))
│
└─► called 5×, queried 1×, per request
WHERE TO FETCH
┌──────────────────────────────────────────────┐
│ Needed for initial render? → SERVER │
│ Should be indexable? → SERVER │
│ Needs a secret / DB? → SERVER │
│ Should be shareable via URL? → searchParams│
│ Changes while page is open? → CLIENT │
│ Triggered repeatedly by user? → CLIENT │
└──────────────────────────────────────────────┘
🎯 Key Takeaways
awaitin a Server Component is the whole API. NogetServerSideProps, nouseEffect— query the database directly and skip the API layer entirely.- Waterfalls are the default failure mode. Every consecutive
awaitdeserves the question "does this need the previous one?" — if not,Promise.all. fetchdedupes automatically; everything else needscache(). This is what makes fetching the same data in a layout and a page cost one query.fetchis not cached by default in Next.js 15/16. Opt in explicitly.- A
server-onlyData Access Layer withcache(), explicitselect, and inline authorization solves deduplication, security, and over-fetching in one pattern.
Next Chapter: Server Actions & Mutations →
Practice: Build a dashboard with four independent data sources, first as sequential awaits. Measure it. Then convert to Promise.all, measure again. Then split into Suspense boundaries and watch the panels stream in independently.