#Fetching data in Server Components
AddedIn the App Router, a page is a tree of React components, and by default every one of them is a Server Component: it runs on the server, it can be an async function, and it can await whatever data it needs right where it renders. There's no useEffect, no loading-state boilerplate, and no API endpoint between the component and the data. The component calls fetch, an ORM or a plain function, gets the result, and returns JSX. Only the rendered result travels to the browser, never the code that produced it.
A good mental model: each Server Component is a small request handler for its own slice of the page. In an Express app you'd write one handler that loads everything and then renders a template. Here every component loads what it needs, and React assembles the slices. That's why you'll see fetch calls in leaf components rather than one big loader at the top: the data lives next to the markup that uses it.
Next.js 16 with Cache Components (cacheComponents: true, on by default in new apps) adds one rule on top: at build time Next.js prerenders every route into a static shell, and any read that can't finish during that prerender (an uncached fetch, a database query, a timer, cookies()) has to be explicitly handled. Either wrap it in <Suspense> so it streams in at request time, or cache it with 'use cache' (see the caching model). Leave it bare and the build fails. This chapter uses the Suspense route throughout, because it's about fresh, per-request data.
The examples in this chapter talk to a small fake API (shown at the end of this topic) that waits a fixed time per route, like a real backend would: /products takes 300 ms. Here's a products page that reads it:
API_URL=http://localhost:4704
DB_PASSWORD=hunter2-not-for-browserstype Product = { id: string; name: string; price: number };
export async function ProductList() {
const res = await fetch(`${process.env.API_URL}/products`);
if (!res.ok) throw new Error(`GET /products failed with ${res.status}`);
const products: Product[] = await res.json();
console.log(`[ProductList] rendering ${products.length} products`);
return (
<ul>
{products.map((p) => (
<li key={p.id}>
{p.name}: ${p.price}
</li>
))}
</ul>
);
}import { Suspense } from "react";
import { ProductList } from "./product-list";
export default function ProductsPage() {
return (
<main>
<h1>Products</h1>
<Suspense fallback={<p>Loading products…</p>}>
<ProductList />
</Suspense>
</main>
);
}ProductListis anasyncfunction component. Nothing marks it as a Server Component: in theapp/directory that's the default, so it never ships to the browser and is free to readprocess.env.API_URLand call the API directly.await fetch(...)pauses the component, not the server. React keeps rendering the rest of the tree; only this component is waiting for the 300 ms response.- The
res.okcheck matters:fetchonly rejects on network failure, so a500would otherwise reachres.json()and fail with a confusing parse error. Throwing hands the failure to the nearest error boundary (error.js). ProductsPageitself isn'tasyncand awaits nothing. Its<h1>and the<Suspense>fallback can be rendered at build time, so they become the route's static shell.- The
<Suspense>boundary is what makes the build pass: it tells Next.js "the part inside may wait for request-time data; show the fallback until it's ready". Without it the build stops with the error shown further down.
The build output says exactly that: the route is Partial Prerender (◐), a static shell with a dynamic hole.
Route (app)
┌ ○ /_not-found
├ ◐ /products
└ ◐ /stock
○ (Static) prerendered as static content
◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed contentWith the app running (next build && next start -p 4504), curl -s http://localhost:4504/products shows both halves in one HTML response. First the shell, with a placeholder where the list will go:
<main><h1>Products</h1><!--$?--><template id="B:0"></template><p>Loading products…</p><!--/$--></main>Then, later in the same response, the finished list in a hidden <div> and the script call that swaps it in:
<div hidden id="S:0"><ul><li>Desk lamp<!-- -->: $<!-- -->39</li><li>Notebook<!-- -->: $<!-- -->6</li></ul></div>
…$RC("B:0","S:0")…A small Node script that timestamps each chunk of the response (observe.mjs, also at the end of this topic) shows when each half arrived:
$ node observe.mjs http://localhost:4504/products "Loading products" "Desk lamp"
status 200 after 43ms
chunk 0 + 45ms 1544 bytes Loading products
…chunks 1–7 (by +46ms): the rest of the shell and the bootstrap scripts…
chunk 8 + 350ms 209 bytes Desk lamp
chunk 10 + 351ms 974 bytes Desk lamp
chunk 11 + 364ms 4628 bytes Loading products
done after 365ms- At 45 ms the client already has the
<h1>and "Loading products…": the static shell was rendered bynext build, so the server sends it without waiting for any data. - At the same moment the server starts rendering the dynamic hole:
ProductListruns and calls the API. The fake API loggedGET /productsat that point; duringnext buildit logged nothing at all, because Next.js doesn't send uncached fetches while prerendering. - About 300 ms later the response comes back,
ProductListfinishes, and React streams the rendered<ul>(chunk 8) plus its RSC payload row (chunk 10) into the same, still-open response. - Chunk 11 also mentions "Loading products": it's the RSC payload for hydration, which describes the boundary and its fallback too.
<template id="B:0">marks the boundary,<div hidden id="S:0">carries the finished content, and$RC("B:0","S:0")is React's inline script that replaces the fallback with it. It runs as soon as it arrives, before the page's JavaScript bundle has even loaded. - The
console.logprinted[ProductList] rendering 2 productsin the terminal runningnext start, never in the browser console: this code only exists on the server.
fetch isn't special. Any async I/O works in a Server Component, including a database client. Because the module only runs on the server, credentials and query code never reach the browser. Here a stand-in ORM client reads stock levels:
import "server-only";
// Stand-in for an ORM client (Prisma, Drizzle…): same shape, rows in memory, a real wait.
const stock = [
{ sku: "p1", warehouse: "YUL-EAST-7", qty: 12 },
{ sku: "p2", warehouse: "YYZ-NORTH-2", qty: 0 },
];
export const db = {
stock: {
async findMany() {
// A real client would connect with process.env.DB_PASSWORD here.
console.log("[db] SELECT sku, warehouse, qty FROM stock");
await new Promise((resolve) => setTimeout(resolve, 200));
return stock;
},
},
};import { Suspense } from "react";
import { db } from "@/lib/db";
async function StockTable() {
const rows = await db.stock.findMany();
return (
<table>
<tbody>
{rows.map((r) => (
<tr key={r.sku}>
<td>{r.sku}</td>
<td>{r.qty > 0 ? `${r.qty} in stock` : "Sold out"}</td>
</tr>
))}
</tbody>
</table>
);
}
export default function StockPage() {
return (
<main>
<h1>Stock</h1>
<Suspense fallback={<p>Checking stock…</p>}>
<StockTable />
</Suspense>
</main>
);
}import "server-only"is a tripwire: if a Client Component ever imports this module, the build fails (the real error is in the Data Access Layer topic). Next.js handles the package itself, so it works without installing it.db.stock.findMany()waits 200 ms on a timer, which Cache Components treats like any uncached I/O: it can't finish during the prerender, soStockTableneeds the<Suspense>around it, exactly like thefetchversion.- Each row has a
warehousefield, butStockTableonly rendersskuand a stock label. Server Components send their output, not their inputs:curl -s http://localhost:4504/stock | grep -c YUL-EAST-7printed0. - The code didn't leak either: searching the browser bundles with
grep -rl 'YUL-EAST-7\|hunter2\|SELECT sku' .next/staticfound nothing. The module is only in.next/server, andprocess.env.DB_PASSWORDstays a runtime lookup there (the secret itself is in no build file at all). - In the observer, "Checking stock" arrived at +45 ms and "Sold out" at +245 ms: the same shell-then-hole pattern, with the query's 200 ms in between.
<Suspense>Delete the boundary and render <ProductList /> directly in the page. With Cache Components on, next build refuses (this is the real output from that change, built in a copy of the app):
import { ProductList } from "./product-list";
export default function ProductsPage() {
return (
<main>
<h1>Products</h1>
<ProductList />
</main>
);
}Error: Route "/products": Next.js encountered uncached or runtime data during prerendering.
`fetch(...)`, `cookies()`, `headers()`, `params`, `searchParams`, or `connection()` accessed outside of `<Suspense>` prevents the route from being prerendered, blocking the page load and leading to a slower user experience.
Ways to fix this:
- [stream] Provide a placeholder with `<Suspense fallback={...}>` around the data access
- [cache] For uncached data (`fetch`, database calls): cache the access with `"use cache"` (does not apply to `connection()`)
- [block] Set `export const instant = false` to allow a blocking route
Learn more: https://nextjs.org/docs/messages/blocking-prerender-dynamic
at main (<anonymous>)
at body (<anonymous>)
at html (<anonymous>)
…
Export encountered an error on /products/page: /products, exiting the build.- While prerendering
/products, Next.js reached thefetchinProductListand found no<Suspense>boundary above it before the root (the stack showsmain,body,html). - Without a boundary there is no fallback to put in the shell, so the whole page would have to wait for the API on every request. Cache Components treats that as a mistake by default rather than silently making the route slow.
- The three fixes are the three choices you always have: stream it (
<Suspense>), cache it ('use cache', so the result can be part of the shell), or explicitly accept a blocking route (instant = false). - The same error appeared for a component that only awaited a
setTimeout: the check isn't aboutfetchspecifically, it's about any async work that can't complete during the prerender.
- Can: be
async; readprocess.envsecrets; use Node APIs (fs,crypto); call databases, ORMs and internal services; import heavy libraries that then cost the browser nothing. - Can't: use state or effects (
useState,useEffect), event handlers (onClick), or browser APIs (window,localStorage). Those need a Client Component (see the 'use client' boundary). - Client Components can't be
async. React's documentation states thatasync/awaitisn't supported in Client Components; read a promise withuse()instead (shown in Fetching on the client).
- Pages Router (Next 12 and earlier, still common): components couldn't fetch on the server. A page exported
getServerSideProps(every request) orgetStaticProps(build time), the whole page waited for it, and the result arrived as props. See getServerSideProps. - App Router, Next 13–14: the same
await fetch()code as above, butfetchwas cached by default (force-cache), so many pages silently became static and served stale data until you added{ cache: 'no-store' }orrevalidate. This surprised so many teams that 15 reversed it. - Next 15:
fetchis not cached by default (the 15 upgrade guide: "fetch requests are no longer cached by default"); opt in withcache: 'force-cache'. - Next 16 with Cache Components: still uncached by default, but now the build makes you decide: Suspense or
'use cache'.force-cache,revalidateand route segment config are the previous model, covered in the previous model.
Every measurement in this chapter used these two scripts. backend.mjs is the fake API: each route waits a fixed time, and every hit is logged with its offset from the first request of a burst, so you can see which requests start together and which wait for each other.
// A fake API for this chapter's examples: every route waits on purpose and logs each hit.
import { createServer } from "node:http";
const routes = {
"/products": [300, [{ id: "p1", name: "Desk lamp", price: 39 }, { id: "p2", name: "Notebook", price: 6 }]],
"/artists/nina": [300, { id: "a1", name: "Nina Simone" }],
"/artists/nina/albums": [500, [{ id: "al1", title: "Pastel Blues" }, { id: "al2", title: "Wild Is the Wind" }]],
"/artists/nina/shows": [400, [{ id: "s1", city: "Montreux" }]],
"/me": [300, { id: "u1", name: "Rohit", plan: "pro" }],
};
const hits = {};
let epoch = 0;
let last = 0;
createServer(async (req, res) => {
const { pathname } = new URL(req.url, "http://localhost");
if (pathname === "/hits") return res.end(JSON.stringify(hits));
const now = Date.now();
if (now - last > 1000) epoch = now; // a quiet second starts a new "page load"
last = now;
hits[pathname] = (hits[pathname] ?? 0) + 1;
console.log(`+${String(now - epoch).padStart(4)}ms ${req.method} ${pathname} (hit ${hits[pathname]})`);
const [ms, body] = routes[pathname] ?? [0, { error: "not found" }];
await new Promise((r) => setTimeout(r, ms));
res.writeHead(routes[pathname] ? 200 : 404, { "content-type": "application/json", connection: "close" });
res.end(JSON.stringify(body));
}).listen(4704, () => console.log("fake API on http://localhost:4704"));routesmaps each path to a delay and a JSON body: the artist takes 300 ms, the albums 500 ms, the shows 400 ms, so overlaps are easy to read off the log.- The
epochresets after a quiet second, so each page load's log starts at+0msand later lines show how long after the first request they arrived. hitscounts requests per path; it's how the memoization topic proves how many times a request really reached the backend.connection: "close"is there because of something the first measurements showed: with keep-alive, some requests fromnext startreached this server about 200 ms after the component calledfetch(the albums request after/mearrived at +500 ms instead of +300 ms). With a fresh connection per request the delay disappeared (+302 ms) and the timings became repeatable. It's a localhost connection-reuse effect, not something Next.js does to your code.
observe.mjs reads a response as a stream and prints when each chunk arrived and which of the given strings it contained. It asks for no compression, because gzip buffers chunks and would hide the streaming (the bundled Streaming guide uses the same trick).
// node observe.mjs <url> [text…]: prints when each chunk of the response arrived and which texts it contained.
const [url, ...marks] = process.argv.slice(2);
const start = performance.now();
const res = await fetch(url, { headers: { "accept-encoding": "identity" } });
console.log(`status ${res.status} after ${Math.round(performance.now() - start)}ms`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let i = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value, { stream: true });
const found = marks.filter((m) => text.includes(m));
console.log(`chunk ${i++} +${String(Math.round(performance.now() - start)).padStart(4)}ms ${String(value.length).padStart(6)} bytes ${found.join(", ")}`);
}
console.log(`done after ${Math.round(performance.now() - start)}ms`);fetchresolves as soon as the status line and headers arrive, so "status 200 after 43ms" is the time to first byte, not the time to the full page.res.body.getReader()hands over the body chunk by chunk as the server flushes it; eachread()resolves when the next chunk arrives.- Each line prints the chunk's arrival time, its size and which marker strings it contains, which is how you can tell "fallback sent" from "content arrived".
- In the quoted outputs, chunks that contain none of the markers (scripts, the bootstrap) are summarised to keep them short; the numbers on the lines shown are exactly what the script printed.