Dev Logs
/Next.js/ Chapter 11: Data Fetching
Chapters
  • 01Chapter 1: Introduction & Setup
  • 02Chapter 2: Project Structure & Configuration
  • 03Chapter 3: Layouts & Pages
  • 04Chapter 4: Linking & Navigation
  • 05Chapter 5: Dynamic Routes & Params
  • 06Chapter 6: Route Groups & Organization
  • 07Chapter 7: Parallel & Intercepting Routes
  • 08Chapter 8: Loading, Suspense & Streaming
  • 09Chapter 9: Error Handling
  • 10Chapter 10: Server & Client Components
  • 11Chapter 11: Data Fetching
    • Plain English Explanation
    • Fetching in Server Components
    • Error handling
    • Sequential vs parallel — the one that costs you
    • The waterfall
    • Promise.all
    • Promise.allSettled when one may fail
    • When sequential is correct
    • Preloading to break a dependency
    • Or use Suspense, which parallelizes for free
    • Request deduplication
    • fetch dedupes automatically
    • cache() for everything else
    • The Data Access Layer pattern
    • fetch options in Next.js 16
    • Fetching on the client
    • With SWR
    • Search as you type
    • Server-side alternative: URL state
    • Streaming a promise to the client
    • Common Pitfalls
    • . The accidental waterfall
    • . Fetching your own API from a Server Component
    • . Forgetting cache() on non-fetch sources
    • . Fetching in a useEffect when the server could do it
    • . SELECT *
    • . A waterfall hidden in a component tree
    • . Assuming fetch is still cached by default
    • When & Why to Use
    • Mini Practice Problems
    • Problem 1: Fix the waterfall
    • Problem 2: How many queries?
    • Problem 3: Choose the strategy
    • Problem 4: Spot the leak
    • Interview Notes
    • Common Questions
    • Asked at Companies
    • Visual Memory Aid
    • Key Takeaways
  • 12Chapter 12: Server Actions & Mutations
  • 13Chapter 13: Route Handlers
  • 14Chapter 14: Caching & use cache
  • 15Chapter 15: Revalidation & ISR
  • 16Chapter 16: Cache Components & Partial Prerendering
  • 17Chapter 17: Proxy (formerly Middleware)
  • 18Chapter 18: Authentication & Authorization
  • 19Chapter 19: Metadata, SEO & OG Images
  • 20Chapter 20: Images & Fonts
  • 21Chapter 21: Styling
  • 22Chapter 22: Performance Optimization
  • 23Chapter 23: Testing
  • 24Chapter 24: Deployment & Self-Hosting
  • 25Chapter 25: Upgrading to Next.js 16
  • 26Chapter 26: Capstone Project
  • 27Chapter 27: React Performance Profiling
All chapters

📡 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:

tsx
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:

tsx
// 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>
  )
}
jsx
// 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:

tsx
// 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} />
}
jsx
// 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:

tsx
// 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

tsx
// ❌ 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

tsx
// ✅ 300ms
export default async function Page() {
  const [user, posts, stats] = await Promise.all([
    getUser(),
    getPosts(),
    getStats(),
  ])

  return <Dashboard user={user} posts={posts} stats={stats} />
}
jsx
// ✅ 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:

tsx
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:

tsx
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:

tsx
// ❌ 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:

tsx
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:

tsx
// 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:

tsx
// 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():

ts
// 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 } })
})
js
// 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, see use cache in Chapter 14.

🧱 The Data Access Layer pattern

Rather than scattering queries across components, centralize them:

ts
// 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 } })
})
js
// 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-only blocks client imports
  • Authorized at the source — the session check can't be forgotten by a caller
  • Minimal — explicit select keeps passwordHash out of every result

Chapter 18 builds on this heavily.

🌍 fetch options in Next.js 16

Next.js extends the standard fetch with caching controls:

tsx
// 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, fetch is NOT cached by default. Requests are no-store unless 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

tsx
// 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>
}
jsx
// 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.

tsx
// 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

tsx
// 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:

tsx
// 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:

tsx
// 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>
    </>
  )
}
tsx
// 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?

tsx
// ❌
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

tsx
// ❌ 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

tsx
// ❌ 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 *

tsx
// ❌ 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

tsx
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.

tsx
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?

ts
// 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

tsx
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

  1. await in a Server Component is the whole API. No getServerSideProps, no useEffect — query the database directly and skip the API layer entirely.
  2. Waterfalls are the default failure mode. Every consecutive await deserves the question "does this need the previous one?" — if not, Promise.all.
  3. fetch dedupes automatically; everything else needs cache(). This is what makes fetching the same data in a layout and a page cost one query.
  4. fetch is not cached by default in Next.js 15/16. Opt in explicitly.
  5. A server-only Data Access Layer with cache(), explicit select, 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.


PreviousChapter 10: Server & Client ComponentsNextChapter 12: Server Actions & Mutations

Open source, free forever. Built by iammhador.

Contribute on GitHub