Skip to content

Fetching in Server Components

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.

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.

app/products/page.tsx
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.

lib/db.ts
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 }[],
},
}
app/users/page.tsx
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} />
}

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.

app/dashboard/page.tsx
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.

lib/user.ts
import { cache } from 'react'
import { db } from '@/lib/db'
// Called by many components in one render — the query runs once per request
export 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:

  1. 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.
  2. It does not apply inside Route Handlers, because a Route Handler is not part of the React component tree. The bundled docs for fetch say 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:

lib/counter.ts
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 }
})
app/memo-demo/page.tsx
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>
)
}
app/api/memo-demo/route.ts
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=fv739g

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

app/comments/comments.tsx
'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>
)
}
app/comments/page.tsx
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.

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.

app/search/search-box.tsx
'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)"]
Sequential waterfall vs. parallel fetching

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.

What you getBenefitCost
Fetching directly in the componentNo client round-trip, no loading state to manageEvery Server Component that needs data must itself be async
Promise.all for independent requestsNo waterfall, both requests start immediatelyRequires noticing the requests don’t depend on each other
React.cache() for non-fetch readsOne query per request no matter how many components call itOnly 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 arrivesNeeds a <Suspense> boundary, and the Promise must be created in a Server Component, not a Client Component
  • “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 way Object.is does. Two calls with structurally identical but freshly-created objects are two different references and never dedupe.
  • “A Client Component can just await the data itself, same as a Server Component.” It cannot — a Client Component’s render function cannot be async. It either receives already-resolved data as a prop, or unwraps a Promise with use().

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-fetch call (an ORM query, an SDK call) is wrapped in cache(), 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 await data directly in its body? That does not compile — data must arrive as a prop or through use().
  • Does a component that needs parallel data actually use Promise.all, or does it silently await one 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.

How does an async Server Component get its data?
Why wrap two independent fetches in Promise.all?
What does React's cache() give a database query?
A Client Component needs server data. What should it do?
Does the second call reuse the first one's cached result?
const getConfig = cache(async (opts: { locale: string }) => {
  return db.config.forLocale(opts.locale)
})

await getConfig({ locale: "en" })
await getConfig({ locale: "en" })
A page elsewhere in the same app already called getUser('1') in its render. Does hitting this Route Handler reuse that result?
// app/api/memo-demo/route.ts
import { getUser } from '@/lib/counter'

export async function GET() {
  const user = await getUser('1')
  return Response.json(user)
}