Server Fetching

Fetching Inside Server Components

Make the component async and await the request in its body. No hook, no data wrapper, no exported configuration function.

A page that fetches from the Bookshelf API (app/books/page.js)JavaScript
const API = process.env.BOOKSHELF_API ?? "http://localhost:4310/api/v1";
export default async function BooksPage() {
  const res = await fetch(`${API}/books?limit=3`);
  if (!res.ok) throw new Error(`Bookshelf API answered ${res.status}`);
  const { data: books } = await res.json();
  return <main><h1>Books</h1>
    <ul>{books.map((b) => <li key={b.id}>{b.title} ({b.year})</li>)}</ul></main>;
}

Set logging: { fetches: { fullUrl: true } } in next.config.mjs and next dev reports every request a render made, nested under its page:

Output of 26
 GET /books 200 in 1852ms (next.js: 1302ms, application-code: 551ms)
 │ GET http://localhost:4310/api/v1/books?limit=3 200 in 466ms (cache skip)
 │ │ Cache skipped reason: (auto no cache)

"Cache skipped reason: (auto no cache)" is dynamic-by-default in the log. Before Next.js 15 10,514 that same fetch was cached indefinitely without anyone asking for it, and stale production data was the result.

Because an uncached request blocks the response, a route that fetches needs an instant loading state: adding loading.js beside the page wraps it in a <Suspense> boundary (loading.js). One trap belongs here. That boundary covers page.js and its children only, so a layout awaiting uncached data sits above it and blocks navigation; fetch in the page, or give the layout's slow part its own <Suspense>.

When the fetch fails

fetch rejects only on a network error. A 4xx or 5xx answer resolves with res.ok === false — which is why the listing checks it, and why a missing check turns "the API is down" into "cannot read properties of undefined". The throw reaches the nearest error.js (Error Boundaries):

Output of 26
⨯ Error: Bookshelf /broken -> 503
    at bookshelf (app\lib\bookshelf.js:12:22)
    at async Broken (app\broken\page.js:4:16)  digest: '3807101926'
 GET /broken 500 in 348ms

In production error.message becomes a generic string and only error.digest survives — the same hash, which is how you match a user's screenshot to a server log line. Never rely on the message reaching the browser. A page that throws while next build prerenders it fails the build outright.