Fetching in Server Components
The idea in one sentence
Section titled “The idea in one sentence”A Server Component is an async function, so it can await a fetch or a database call directly in the component body — there is no useEffect, no loading state, and no client-to-API round trip.
Fetch directly in an async component
Section titled “Fetch directly in an async component”In the App Router, the component runs on the server. You fetch where you render, and the data never touches the client bundle until the HTML is sent.
function ProductList({ products }: { products: { id: string; name: string }[] }) { return ( <ul> {products.map((p) => ( <li key={p.id}>{p.name}</li> ))} </ul> )}
export default async function ProductsPage() { // Runs on the server — the API key and response stay server-side const res = await fetch('https://api.example.com/products') const products = await res.json() return <ProductList products={products} />}Because the code runs on the server, you can also talk to your database or ORM directly — no API route in between.
export const db = { user: { findMany: async () => [] as { id: string; name: string }[], findUnique: async (_args: { where: { id: string } }) => null as { id: string; name: string } | null, }, post: { findMany: async () => [] as { id: string; title: string }[], },}import { db } from '@/lib/db'
function UserList({ users }: { users: { id: string; name: string }[] }) { return ( <ul> {users.map((u) => ( <li key={u.id}>{u.name}</li> ))} </ul> )}
export default async function UsersPage() { // Query the database straight from the Server Component const users = await db.user.findMany() return <UserList users={users} />}Sequential vs. parallel: avoid waterfalls
Section titled “Sequential vs. parallel: avoid waterfalls”If you await one request and then await the next, the second cannot start until the first resolves — a waterfall. When requests do not depend on each other, start them together with Promise.all.
function Profile({ user, posts,}: { user: { id: string; name: string } | null posts: { id: string; title: string }[]}) { return ( <div> <p>{user?.name}</p> <ul> {posts.map((p) => ( <li key={p.id}>{p.title}</li> ))} </ul> </div> )}
async function getUser(id: string) { const res = await fetch(`https://api.example.com/users/${id}`) return res.json()}
async function getPosts(id: string) { const res = await fetch(`https://api.example.com/users/${id}/posts`) return res.json()}
export default async function Dashboard() { // Parallel: both requests fire at once, no waterfall const [user, posts] = await Promise.all([getUser('1'), getPosts('1')]) return <Profile user={user} posts={posts} />}Dedupe with Request Memoization and React.cache()
Section titled “Dedupe with Request Memoization and React.cache()”Within a single render pass, Next.js memoizes identical fetch calls automatically — call the same URL in three components and the network sees one request. For non-fetch work like a database query, wrap it in React’s cache() to get the same per-request deduplication.
import { cache } from 'react'import { db } from '@/lib/db'
// Called by many components in one render — the query runs once per requestexport const getUser = cache(async (id: string) => { return db.user.findUnique({ where: { id } })})The scope is one render pass — nothing more
Section titled “The scope is one render pass — nothing more”Two things about Request Memoization surprise people who learned it as “Next.js caches fetch.” Neither is true:
- It does not persist across requests. It is scoped to a single render pass and is thrown away the moment that render finishes — persisting data across requests is the Data Cache’s job (next lesson), not this one.
- It does not apply inside Route Handlers, because a Route Handler is not part of the React component tree. The bundled docs for
fetchsay this directly: “Memoization does not apply in Route Handlers, since they are not part of the React component tree.”
To prove both, wrap a fake ORM call in cache() and log every real execution:
import { cache } from 'react'
export const getUser = cache(async (id: string) => { const token = Math.random().toString(36).slice(2, 8) console.log(`[getUser] executing for id=${id} — fresh call, token=${token}`) return { id, token }})import { getUser } from '@/lib/counter'
export const dynamic = 'force-dynamic'
async function Widget({ label }: { label: string }) { const user = await getUser('1') return ( <p> {label}: token {user.token} </p> )}
export default async function MemoDemoPage() { // Two separate Server Components calling the same cache()-wrapped // function with the same argument, in the same render pass. return ( <div> <Widget label="A" /> <Widget label="B" /> </div> )}import { getUser } from '@/lib/counter'
export const dynamic = 'force-dynamic'
export async function GET() { const user = await getUser('1') return Response.json(user)}Built with next build && next start and hit three times — the page twice, then the Route Handler once — the server logs exactly one execution per request, even though the page renders two Widgets that both call getUser('1'):
[getUser] executing for id=1 — fresh call, token=jhr7n2[getUser] executing for id=1 — fresh call, token=dacca3[getUser] executing for id=1 — fresh call, token=fv739gThe first request’s two Widgets share one execution (jhr7n2) — that is the dedupe. The second request to the same page gets a brand-new token (dacca3) — the scope from the first render is gone. The Route Handler gets its own token too (fv739g) — it never shared anything with the page’s render, exactly as the docs say.
Passing a Promise to use() in a Client Component
Section titled “Passing a Promise to use() in a Client Component”A Server Component can start a fetch and hand the unresolved Promise straight to a Client Component, instead of awaiting it first. The Client Component unwraps it with React’s use(), and a wrapping <Suspense> shows a fallback until it resolves. This streams the static shell immediately and lets the slow data arrive later, without a client-side useEffect fetch.
'use client'import { use } from 'react'
export function Comments({ commentsPromise,}: { commentsPromise: Promise<{ id: string; text: string }[]>}) { // Unwraps the Promise on the client — suspends until it resolves const comments = use(commentsPromise) return ( <ul> {comments.map((c) => ( <li key={c.id}>{c.text}</li> ))} </ul> )}import { Suspense } from 'react'import { Comments } from './comments'
async function getComments() { const res = await fetch('https://api.example.com/comments') return res.json()}
export default function CommentsPage() { // Don't await — pass the Promise straight down, unresolved const commentsPromise = getComments() return ( <Suspense fallback={<p>Loading comments…</p>}> <Comments commentsPromise={commentsPromise} /> </Suspense> )}This is a different tool from a client-side data library like SWR or TanStack Query: use(promise) unwraps a value the server already started fetching, once, for this render. SWR/TanStack instead run entirely in the browser and add their own client-side cache, revalidation, and refetch-on-focus behavior — reach for them when a Client Component needs to fetch on its own, independent of a Server Component’s render (search-as-you-type, polling, mutating and refetching). They are not taught in this course.
Where NOT to fetch
Section titled “Where NOT to fetch”A Client Component cannot await in its body and must not touch the database directly. It reaches data through a Route Handler (or a data library), which runs on the server on its behalf.
'use client'import { useState } from 'react'
export function SearchBox() { const [results, setResults] = useState([]) async function onSearch(q: string) { // Client components fetch through a Route Handler, never the DB const res = await fetch(`/api/search?q=${q}`) setResults(await res.json()) } return <input onInput={(e) => onSearch(e.currentTarget.value)} />}flowchart LR A["Request"] --> B["await getUser()"] B --> C["await getPosts()"] C --> D["Render (late)"] A --> E["Promise.all"] E --> F["getUser() + getPosts() together"] F --> G["Render (sooner)"]
Mental Model
Section titled “Mental Model”Think of an async Server Component as a function that runs once per request, on the server, with no separate “data layer” in between — the await is the data layer. Request Memoization is that function’s short-term memory: everything it (and everything it calls) awaits during that one execution is remembered for the rest of the execution, then forgotten. A Route Handler is a completely different function invocation with its own short-term memory — it never inherits the page’s.
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
| Fetching directly in the component | No client round-trip, no loading state to manage | Every Server Component that needs data must itself be async |
Promise.all for independent requests | No waterfall, both requests start immediately | Requires noticing the requests don’t depend on each other |
React.cache() for non-fetch reads | One query per request no matter how many components call it | Only dedupes calls with reference-equal or primitive arguments — an object literal built fresh each call defeats it |
Passing a Promise to use() | Streams the static shell before slow data arrives | Needs a <Suspense> boundary, and the Promise must be created in a Server Component, not a Client Component |
Common Misconceptions
Section titled “Common Misconceptions”- “Request Memoization caches my data across requests.” It does not — it lives and dies inside one render pass. Persisting data across requests is the Data Cache’s job, covered next.
- “If I call my cached function from a Route Handler, it reuses the page’s cached result.” It does not — a Route Handler is not part of the React component tree, so it never joins a Server Component render’s memoization scope.
- “
React.cache()dedupes by deep-equal arguments.” It compares arguments the wayObject.isdoes. Two calls with structurally identical but freshly-created objects are two different references and never dedupe. - “A Client Component can just
awaitthe data itself, same as a Server Component.” It cannot — a Client Component’s render function cannot beasync. It either receives already-resolved data as a prop, or unwraps a Promise withuse().
Reading AI-generated code
Section titled “Reading AI-generated code”When a generated snippet fetches or caches data in a Server Component, check for these before trusting it:
- Does every fetch that needs deduping run inside the render tree — not inside a Route Handler it also calls into?
- If a non-
fetchcall (an ORM query, an SDK call) is wrapped incache(), are its arguments primitives or the same object reference on every call — not a fresh object literal built at each call site? - Is a Client Component trying to
awaitdata directly in its body? That does not compile — data must arrive as a prop or throughuse(). - Does a component that needs parallel data actually use
Promise.all, or does it silentlyawaitone call after another, creating a waterfall? - If a Promise is passed to a Client Component for
use(), is there a<Suspense>boundary around it? Without one, the whole tree blocks instead of streaming.
🐛 Spot the bug
import { cache } from 'react'import { db } from '@/lib/db'
const getConfig = cache(async (opts: { locale: string }) => {return db.config.forLocale(opts.locale)})
export async function Header() {const config = await getConfig({ locale: 'en' })return <nav>{config.brand}</nav>}
export async function Footer() {const config = await getConfig({ locale: 'en' })return <footer>{config.brand}</footer>}Show the answer
Header and Footer both call getConfig with an object literal that looks identical — { locale: 'en' } — but each call site creates a brand-new object. cache() keys its memoization the way Object.is compares values, so two different object references never dedupe, even with the same shape and contents: getConfig runs twice in this one render instead of once. Proven in the probe — two calls with a fresh { locale: 'en' } object logged two separate executions, while two calls with the primitive string 'en' logged exactly one. The fix is to key the cached function by a primitive (getConfig(locale: string)) or to build the options object once and pass the same reference to both call sites.