Data Fetching

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Next.js 16.3.8 (the latest release on 2026-10-03, when the facts and examples below were checked; the official documentation reports @doc-version 16.3.8), React 19.3.0, Node.js 20.9.0 or later and TypeScript 5.1 or later. Data fetching in the App Router is plain async/await inside Server Components, so most of this page is about the App Router; the Pages Router has its own data functions (getServerSideProps, getStaticProps), which are covered in a section of their own as the legacy path. Caching changes what a fetch does, so every example says which model it uses: the previous model (the default, in which fetch responses are not cached unless you ask) or the opt-in Cache Components model (cacheComponents: true, with the 'use cache' directive). Every example was built with next build, started with next start and exercised with curl or a headless Chrome on Next.js 16.3.8 and Node.js 24.16 on macOS, in a scratch copy of the Bookshelf — the catalogue of books, authors and reviews that this section grows — talking to a stand-in for the Bookshelf API that answers after 300 ms and counts the calls it receives, so the timings and call counts quoted below are measurements, not estimates. Each example is followed by the official page it derives from.

The page answers four questions in order: where does the fetch run (server, client, or both), when does it run (at build time, at request time, or in the browser), how many times does it run (memoization, caching, preloading) and how do the requests relate (sequential or parallel). It does not re-teach React’s use API or Suspense; read React Data Fetching for the client-side fundamentals and Streaming and Suspense for the delivery mechanism. The caching pages of this section (caching, and the Server Actions and Route Handlers pages that follow it) are only mentioned here in prose.

The Big Picture: Where, When and How Often

The most important change from the Pages Router is that there is no special function to fetch data. A Server Component is an async function, it await`s whatever it needs, and Next.js renders the result. The same is true of layouts and of `generateMetadata. Data fetching happens in one of five places.

Table 1. Where data can be fetched in Next.js 16.3
Place Runs Typical use

Server Component, layout, generateMetadata (App Router)

On the server: at build time for prerendered routes, at request time for dynamic ones

Everything that is part of the initial HTML: lists, details, SEO data. The default choice.

Client Component with use() and a promise from a Server Component

The request starts on the server; the Client Component reads the result

Data that a Client Component needs once, streamed in after the shell

Client Component with SWR, TanStack Query or a plain effect

In the browser, after hydration (the first render can be seeded from the server)

Data that changes while the page is open: polling, search-as-you-type, focus revalidation

Route Handler or Server Action

On the server, per call

A BFF endpoint for browser code, and mutations (not reads for rendering)

getServerSideProps / getStaticProps (Pages Router)

On the server, per request / at build time

Existing Pages Router applications

Source: Fetching Data

flowchart TD start["I need data on this page"] --> q1{"Is it part of the initial HTML?"} q1 -- "Yes" --> sc["Server Component: await it in the component that needs it"] q1 -- "No, it changes while the page is open" --> q2{"Can it start on the server?"} q2 -- "Yes, read once" --> useapi["Server Component starts the promise, Client Component reads it with use()"] q2 -- "Yes, and it must stay fresh in the browser" --> lib["Seed SWR or TanStack Query from the server"] q2 -- "No, it depends on browser input" --> client["Client library or effect calling a Route Handler"] sc --> q3{"Slow or uncached?"} q3 -- "Yes" --> susp["Wrap it in Suspense so the shell streams first"] q3 -- "No" --> done["Done"]
Figure 1. Choosing where to fetch

The Bookshelf Scenario and the Test Rig

The examples fetch from a Bookshelf API that this site already builds three times: with FastAPI, with NestJS and with Django. In the scratch app that API is a small Node.js process that serves /books, /books/<id>, /books/<id>/reviews, /authors/<id> and a /graphql endpoint, waits 300 ms before answering and keeps a counter per route (GET /__hits). It returns the shape that the NestJS comparison page uses, extended with an author and a price. Swapping it for a real service changes one environment variable, BOOKSHELF_API_URL.

The types and the single module that knows the API address (lib/types.ts and lib/bookshelf.ts)
// lib/types.ts
export type Book = { id: number; title: string; authorId: number; priceCents: number };
export type Author = { id: number; name: string };
export type Review = { id: number; bookId: number; rating: number; text: string };

// lib/bookshelf.ts
import "server-only";
import type { Author, Book, Review } from "@/lib/types";

// Server-only configuration: never prefix with NEXT_PUBLIC_ and never log it.
const API_URL = process.env.BOOKSHELF_API_URL ?? "http://localhost:3611";

async function get<T>(path: string): Promise<T> {
  const res = await fetch(`${API_URL}${path}`);
  if (!res.ok) throw new Error(`Bookshelf API ${path} answered ${res.status}`);
  return (await res.json()) as T;
}

// Encode every value that goes into the upstream URL (never interpolate raw input).
export const getBooks = (q = "") => get<Book[]>(`/books?q=${encodeURIComponent(q)}`);
export const getBook = (id: number) => get<Book>(`/books/${id}`);
export const getAuthor = (id: number) => get<Author>(`/authors/${id}`);
export const getReviews = (bookId: number) => get<Review[]>(`/books/${bookId}/reviews`);

Three details of this module recur on the rest of the page. import "server-only" makes the module fail the build if a Client Component ever imports it (shown below). process.env.BOOKSHELF_API_URL is read on the server, so the address can be an internal one such as http://api:8000 inside a Docker network, and the browser never learns it. Every dynamic value is passed through encodeURIComponent or is a number; the books this section consulted build upstream URLs by interpolating whatever the visitor typed, which lets a crafted value rewrite the path and query of the upstream request.

Calling the FastAPI, NestJS and Django Bookshelf APIs

From the Next.js server the three Bookshelf back ends look the same: an HTTP service that returns JSON. They differ in small ways that belong in the data module and nowhere else.

Table 2. What differs between the three Bookshelf back ends when Next.js calls them
FastAPI NestJS Django (REST framework)

Typical development address

http://localhost:8000 (Uvicorn)

http://localhost:3000

http://localhost:8000 (runserver)

List response

The model you return, usually a JSON array

The array the controller returns

An array, or with page-number pagination the envelope count, next, previous, results

Field naming

Usually snake_case (Pydantic)

Whatever the DTO says, often camelCase

Usually snake_case

Typed client

Generate types from /openapi.json

Generate types from the Swagger document

Generate types from the OpenAPI schema of the API

Gotcha

Validation errors are 422 with a detail list

Default port clashes with next dev: start one of them with another port

A trailing slash is often required; a redirect on POST loses the body

Because the shape differs, convert it once, at the edge of the app, and let the rest of the code use one Book type. The function below turns a Django REST framework page into the Book[] that the pages use; it is a pure function, so it is also the part to unit-test.

Mapping a Django REST framework page into the app’s own type (lib/bookshelf-django.ts)
// A pure mapping layer: the rest of the app only ever sees `Book`, whichever backend answers.
import type { Book } from "@/lib/types";

type DrfPage<T> = { count: number; next: string | null; previous: string | null; results: T[] };
type DjangoBook = { id: number; title: string; author_id: number; price_cents: number };

export function fromDjangoPage(page: DrfPage<DjangoBook>): Book[] {
  return page.results.map((b) => ({
    id: b.id,
    title: b.title,
    authorId: b.author_id,
    priceCents: b.price_cents,
  }));
}

Source: Fetching Data

Called with a one-element page it printed [{"id":1,"title":"Dune","authorId":1,"priceCents":999}]. Two rules keep the integration safe. Calls from the Next.js server to the API need no CORS configuration, because CORS is a browser mechanism; only direct calls from browser code do (see CORS). And the API must authorise every request itself: anyone who can reach its port can bypass the Next.js layer, as NestJS vs Next.js explains.

Credentials are forwarded from the server. The helper below reads a cookie and sends it as a bearer token, with a timeout so a hung upstream cannot hold the render forever. A wrong or missing token gives a signed-out page instead of a crash.

Forwarding a credential to the API from the server (lib/me.ts)
import "server-only";
import { cookies } from "next/headers";

// Forward the caller's credential to the API from the server; the browser never sees the upstream URL.
export async function getMe() {
  const token = (await cookies()).get("access_token")?.value;
  if (!token) return null;
  const res = await fetch(`${process.env.BOOKSHELF_API_URL ?? "http://localhost:3611"}/me`, {
    headers: { Authorization: `Bearer ${token}` },
    signal: AbortSignal.timeout(3000), // never wait forever for a slow upstream
  });
  if (res.status === 401) return null;
  if (!res.ok) throw new Error(`Bookshelf API /me answered ${res.status}`);
  return (await res.json()) as { name: string };
}
A page that uses it (app/df/me/page.tsx)
import { getMe } from "@/lib/me";

export default async function Page() {
  const me = await getMe(); // cookies() makes this route dynamic: rendered per request
  return <p>{me ? `Hello, ${me.name}` : "Signed out"}</p>;
}
Observed with curl against the production build
curl -s localhost:3601/df/me                                      # Signed out
curl -s -H "Cookie: access_token=demo-token" localhost:3601/df/me # Hello, Reader
curl -s -H "Cookie: access_token=wrong" localhost:3601/df/me      # Signed out

Source: cookies; the cookie name and the token are placeholders of the scratch rig. How the token is issued and stored is the subject of OAuth for browser-based apps and of the authentication page of this section (not written yet at the time of writing).

Fetching in Server Components

A Server Component is fetched into by making it async. Anything that does asynchronous I/O works: the extended fetch, an ORM, a database client, the file system or another SDK.

With the fetch API

A Server Component that fetches (app/df/books/page.tsx)
import { connection } from "next/server";
import { getBooks } from "@/lib/bookshelf";

export default async function BooksPage({ searchParams }: PageProps<"/df/books">) {
  await connection(); // render per request; fetch alone is not cached but would be prerendered
  const { q } = await searchParams;
  const books = await getBooks(typeof q === "string" ? q : "");
  return (
    <ul>
      {books.map((b) => (
        <li key={b.id}>{b.title} ({(b.priceCents / 100).toFixed(2)})</li>
      ))}
    </ul>
  );
}
Observed
curl -s "localhost:3601/df/books?q=sol"   # <ul><li>Solaris (7.99)</li></ul>

await connection() is there on purpose, and it is the first pitfall of this page. In the previous model a fetch without a cache option is not cached, but a route that uses no request-time API is still prerendered at next build, so the fetch runs once during the build and its result is baked into the HTML. Reading searchParams, cookies(), headers() or calling connection() before the fetch makes the route dynamic, so the fetch runs per request. Without that line the same page would have printed the build-time catalogue to every visitor, and a build with the API down would have failed. Three requests to /df/books made three calls to the API: the response is not cached.

With an ORM or a database client

Server Components run only on the server, so a query and its credentials never reach the client bundle. Drizzle, Prisma, Kysely or a raw driver are all used the same way as fetch: await them. The scratch app stands in for a database with an in-memory table; the shape of the code is what matters.

A database-style data function (lib/books-db.ts)
import "server-only";
import { cache } from "react";

// Stand-in for an ORM or database client (Drizzle, Prisma, pg ...).
const rows = [
  { id: 1, title: "Dune", priceCents: 999 },
  { id: 2, title: "Neuromancer", priceCents: 899 },
];

// cache() deduplicates calls with the same arguments during ONE render pass (one request).
export const getBookRow = cache(async (id: number) => {
  console.log(`DB query for book ${id}`); // visible once per request, not once per caller
  return rows.find((r) => r.id === id) ?? null;
});

React.cache is explained in the memoization section below. Authentication and authorisation still apply: the query runs on the server, but nothing about a Server Component checks who is asking. The data security guide recommends a Data Access Layer, a server-only module that every Server Component, Route Handler and Server Action goes through, which checks the session, returns only the fields the UI needs and is the one place that talks to the database. The Data Access Layer also answers a worry that the consulted books raise, that a front-end framework should not touch the database: the recommendation is not to avoid server-side data access but to confine it to one audited module, or to a separate back end such as the Bookshelf APIs above.

Keeping server code out of the client: server-only and tainting

Server and Client Components run in separate module graphs, but a Client Component can still import a module, and an accidental import of a data module would drag server code towards the browser. import "server-only" turns that accident into a build error. With the data module above imported from a file that starts with 'use client', the build fails:

Importing a server-only module from a Client Component
Error: 'server-only' cannot be imported from a Client Component module
Import traces:
  Client Component Browser:
    ./lib/bookshelf.ts [Client Component Browser]
    ./app/df/bad/page.tsx [Client Component Browser]

The same guide describes an opt-in extra layer, tainting (experimental_taintObjectReference and experimental_taintUniqueValue with experimental.taint: true), which makes React refuse to serialise a marked object or value to the client. Treat it as a second line behind filtering in the Data Access Layer, not as a replacement. Also remember what crosses the boundary: props that a Server Component passes to a Client Component, and the props that getServerSideProps returns in the Pages Router, are serialised into the HTML and visible to anyone.

When the fetch runs: build time, request time and Cache Components

The same await fetch(…​) runs at different moments depending on the route and on the caching model.

Table 3. When a fetch in a Server Component runs
Situation Previous model (default) Cache Components (cacheComponents: true)

No request-time API, no cache option

Runs once at next build; the result is in the static HTML

Counts as dynamic: it must be inside <Suspense> or the build fails

After cookies(), headers(), searchParams, params of an unknown value or connection()

Runs on every request

Runs on every request, inside the <Suspense> boundary that streams it

cache: 'force-cache' or next: { revalidate }

Stored in the Data Cache and reused

Not the mechanism of this model: use 'use cache' on the data function

'use cache' function

Not available

Cached; part of the prerendered shell when it needs no request data

Under Cache Components an uncached fetch outside a Suspense boundary is an error at build time rather than a silent slowdown. A page like this:

Under Cache Components, uncached data outside Suspense fails the build
import { getReviews } from "@/lib/bookshelf";

export default async function Page() {
  const reviews = await getReviews(1); // uncached fetch outside <Suspense>
  return <p>{reviews.length}</p>;
}
The build output with cacheComponents enabled
Error: Route "/df/cc-bad": 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"`
  - [block] Set `export const instant = false` to allow a blocking route

The fix is one of the three bullets. This page is the shell-plus-hole version: cache what is stable, stream what is not.

The same data fetched under Cache Components (app/df/cc/page.tsx, cacheComponents: true)
import { Suspense } from "react";
import { cacheLife } from "next/cache";
import { getBooks, getReviews } from "@/lib/bookshelf";

// Cached: becomes part of the prerendered static shell and is revalidated in the background.
async function Catalogue() {
  "use cache";
  cacheLife("minutes");
  const books = await getBooks();
  return <ul>{books.map((b) => <li key={b.id}>{b.title}</li>)}</ul>;
}

// Not cached: an uncached fetch is "dynamic" under Cache Components and must be inside <Suspense>.
async function LatestReviews() {
  const reviews = await getReviews(1);
  return <p>{reviews.length} review(s) right now</p>;
}

export default function Page() {
  return (
    <main>
      <h1>Bookshelf</h1>
      <Catalogue />
      <Suspense fallback={<p>Loading reviews...</p>}>
        <LatestReviews />
      </Suspense>
    </main>
  );
}
Observed under Cache Components
Route (app)      Revalidate  Expire
├ ƒ /api/books
└ ◐ /df/cc               1m      1h

◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content

The static shell (the heading and the catalogue) arrived in 30 ms, the reviews streamed in 300 ms later, and over two requests the API received two GET /books/1/reviews calls and no GET /books call: the catalogue came from the cache, the reviews were fetched per request. This is partial prerendering at work; the mechanics are on Partial Prerendering and Cache Components and the cache keys, profiles and invalidation are on the caching page of this section.

Source: use cache and cacheLife

Errors, missing data and loading states

A rejected promise in a Server Component is caught by the nearest error.js boundary; notFound() renders the nearest not-found.js; and loading.js wraps the page in a Suspense boundary so a navigation shows a fallback at once. Throw for non-ok responses instead of rendering garbage, as lib/bookshelf.ts does, and translate a 404 from the API into notFound() where the page can tell the difference. Two behaviours deserve care. A layout that reads uncached or runtime data does not fall back to a loading.js of the same segment; it blocks navigation until it has rendered, so put the Suspense boundary closest to the slow data. And notFound() thrown below a Suspense boundary arrives after the response status was sent, so the status stays 200; the details are on Streaming and Suspense.

Sequential Versus Parallel Fetching

Two requests are sequential when the second cannot start until the first has finished, and parallel when both are in flight at the same time. Some requests really depend on each other: you need the book before you know the author’s ID. The mistake is to make independent requests wait for each other by writing them as consecutive `await`s.

gantt title One page, three requests of 300 ms each, three ways of fetching them dateFormat X axisFormat %L ms section Waterfall (three awaits) getBook :w1, 0, 300 getAuthor :w2, 300, 600 getReviews :w3, 600, 900 section Parallel (start early, await late) getBook :p1, 0, 300 getReviews :p3, 0, 300 getAuthor :p2, 300, 600 section Streamed (shell first, two Suspense holes) getBook, flush shell :s1, 0, 300 getAuthor :s2, 300, 600 getReviews :s3, 300, 600
Figure 2. Waterfall versus parallel versus streamed fetching

The waterfall

Three awaits in a row (app/df/waterfall/[id]/page.tsx)
import { connection } from "next/server";
import { getAuthor, getBook, getReviews } from "@/lib/bookshelf";

export default async function Page({ params }: PageProps<"/df/waterfall/[id]">) {
  await connection();
  const { id } = await params;
  const book = await getBook(Number(id)); // 300 ms
  const author = await getAuthor(book.authorId); // +300 ms: genuinely depends on book
  const reviews = await getReviews(book.id); // +300 ms: does NOT depend on author, an avoidable wait
  return <h1>{book.title} by {author.name} ({reviews.length} reviews)</h1>;
}

The reviews do not need the author, yet they wait for it. Measured: 953 ms for the whole response, and the response cannot start before that, because the page awaits everything before it returns anything.

Parallel: start early, await late

Call the functions first, without await, so every request is in flight, then await the promises where their values are needed. Promise.all is the shorter form when you need all of them at the same point.

Start the independent request first, await it last (app/df/parallel/[id]/page.tsx)
import { connection } from "next/server";
import { getAuthor, getBook, getReviews } from "@/lib/bookshelf";

export default async function Page({ params }: PageProps<"/df/parallel/[id]">) {
  await connection();
  const { id } = await params;
  // Start every request that does not depend on another one, then await late.
  const bookPromise = getBook(Number(id));
  const reviewsPromise = getReviews(Number(id));
  const book = await bookPromise;
  const author = await getAuthor(book.authorId); // runs while reviews are already in flight
  const reviews = await reviewsPromise;
  return <h1>{book.title} by {author.name} ({reviews.length} reviews)</h1>;
}

Measured: 611 ms, two thirds of the waterfall; the only wait left is the real dependency. Two things to know. Promise.all rejects as soon as one promise rejects, which fails the whole page; use Promise.allSettled when a failed part may be omitted. And layouts and pages of one route already render in parallel, so a layout and a page that each fetch do not wait for each other, unlike two awaits inside one component.

Streaming instead of waiting

Parallelism shortens the total; streaming lets the browser see something earlier. Await only what the first paint needs, and put the rest behind <Suspense>. Each boundary is a component that fetches for itself.

The shell waits for the book only (app/df/streamed/[id]/page.tsx)
import { Suspense } from "react";
import { connection } from "next/server";
import { getAuthor, getBook, getReviews } from "@/lib/bookshelf";

export default async function Page({ params }: PageProps<"/df/streamed/[id]">) {
  await connection();
  const { id } = await params;
  const book = await getBook(Number(id)); // the only thing the shell waits for
  return (
    <main>
      <h1>{book.title}</h1>
      <Suspense fallback={<p>Loading author...</p>}>
        <Author authorId={book.authorId} />
      </Suspense>
      <Suspense fallback={<p>Loading reviews...</p>}>
        <Reviews bookId={book.id} />
      </Suspense>
    </main>
  );
}

async function Author({ authorId }: { authorId: number }) {
  const author = await getAuthor(authorId);
  return <p>by {author.name}</p>;
}

async function Reviews({ bookId }: { bookId: number }) {
  const reviews = await getReviews(bookId);
  return <p>{reviews.length} review(s)</p>;
}
Table 4. Measured with curl on the production build (300 ms per upstream call)
Page First byte Complete

Waterfall

953 ms

953 ms

Parallel (start early, await late)

611 ms

611 ms

Streamed (shell, then two parallel holes)

309 ms

613 ms

The streamed page has the same total as the parallel one, but the heading is in the browser after 309 ms. Both holes start only after the shell’s data is in, because they are children of a component that awaited the book; that is the price of keeping the code local. When the book request itself is the bottleneck, start it earlier or cache it. Bots and crawlers are an exception to streaming: Next.js waits for the data and sends the whole page to them, so search-engine output is not affected by where you put the boundaries.

Source: Streaming

Preloading

A request starts late when its component only renders after other blocking work, even though its input has been known for a while. Preloading means starting the request early without waiting for it, then reading the same request in the component that needs it. It needs one thing: the data function must deduplicate identical calls, so the second call joins the first. A fetch does that by itself (memoization, next section); a database function needs React.cache; under Cache Components a 'use cache' function does (or 'use cache: private' when it reads cookies() or headers()).

A component and its preload function, side by side (app/df/preload/[id]/item.tsx)
import { getBook } from "@/lib/bookshelf";

export const preload = (id: number) => {
  void getBook(id); // start the request, do not await it
};

export default async function Item({ id }: { id: number }) {
  const book = await getBook(id); // identical fetch call: reuses the request preload() started
  return <h1>{book.title}</h1>;
}
Calling preload before another blocking request (app/df/preload/[id]/page.tsx)
import { connection } from "next/server";
import Item, { preload } from "./item";

async function checkIsAvailable(id: number) {
  await new Promise((r) => setTimeout(r, 300)); // another blocking dependency (stock service)
  return id < 100;
}

export default async function Page({ params, searchParams }: PageProps<"/df/preload/[id]">) {
  await connection();
  const [{ id }, { early }] = await Promise.all([params, searchParams]);
  const n = Number(id);
  if (early === "1") preload(n); // the scratch page toggles this to compare the two timings
  const isAvailable = await checkIsAvailable(n);
  return isAvailable ? <Item id={n} /> : null;
}
Measured (stock check 300 ms, book request 300 ms)
curl -s -o /dev/null -w "%{time_total}\n" localhost:3601/df/preload/1         # 0.61 s: check, then book
curl -s -o /dev/null -w "%{time_total}\n" "localhost:3601/df/preload/1?early=1" # 0.31 s: both at once

Keep the preload function next to the component that consumes the data, so removing the component shows what else to remove. In production, matching calls to a private cache function can reuse one result within a request, which lets a component use a request started during preloading without storing the result across requests.

Request Memoization and React.cache

Because every component may fetch its own data, the same data is often requested more than once in one render: the page and generateMetadata both want the book, a header and a sidebar both want the current user. Passing the data down as props to avoid that is awkward. Instead, make the repeated call cheap.

Table 5. The deduplication mechanisms
Mechanism Scope Applies to Lasts

fetch memoization

One server render pass

GET requests with the same URL and options, in components, layouts, pages, generateMetadata, generateStaticParams and generateViewport

Until the render ends

React.cache

One request

Any function, such as an ORM or database call, or a fetch you want to key yourself

Until the request ends; no sharing between requests

'use cache' (Cache Components)

Across requests, per cache key

Any async function or component that does not read request data

cacheLife profile

Data Cache with force-cache (previous model)

Across requests and deployments

fetch with cache: 'force-cache' or next.revalidate

Until revalidated

The scratch page below renders four components that each ask for the same book, two through fetch and two through the React.cache function from the database section.

Four callers, one result each (app/df/memo/page.tsx)
import { connection } from "next/server";
import { getBook } from "@/lib/bookshelf";
import { getBookRow } from "@/lib/books-db";

async function Title() {
  return <h1>{(await getBook(1)).title}</h1>;
}
async function Price() {
  return <p>{(await getBook(1)).priceCents} cents</p>;
}
async function RowA() {
  return <p>{(await getBookRow(1))?.title}</p>;
}
async function RowB() {
  return <p>{(await getBookRow(1))?.priceCents}</p>;
}

export default async function Page() {
  await connection();
  return (
    <>
      <Title />
      <Price />
      <RowA />
      <RowB />
    </>
  );
}
Observed after one request
API hit counter:  {"GET /books/1":1}
Server log:       DB query for book 1

Two components called the API function and one request reached the API; two components called the cached function and the "query" ran once. The same holds for generateMetadata: a generateMetadata and a page that both call getBook for the same book made a single upstream call (the page title was filled in from the shared result).

generateMetadata and the page share one request (app/df/meta/[id]/page.tsx)
import type { Metadata } from "next";
import { connection } from "next/server";
import { getBook } from "@/lib/bookshelf";

export async function generateMetadata({ params }: PageProps<"/df/meta/[id]">): Promise<Metadata> {
  const { id } = await params;
  const book = await getBook(Number(id)); // same URL and options as the page below: one request
  return { title: book.title };
}

export default async function Page({ params }: PageProps<"/df/meta/[id]">) {
  await connection();
  const { id } = await params;
  const book = await getBook(Number(id));
  return <h1>{book.title}</h1>;
}

The limits of memoization are the source of most surprises.

  • It is per request. Neither fetch memoization nor React.cache shares anything between two visitors. Persistent reuse is caching, a different feature.

  • Arguments are compared by identity. React.cache compares its arguments with Object.is, so a fresh object literal on every call is a different key each time and defeats it; pass primitives (an ID) instead.

  • It needs the same call. fetch is memoized for GET requests with the same URL and options only; a POST, a different header or a different URL string is a new request.

  • It can be turned off. Passing an AbortController signal to fetch opts that call out. Two identical calls with a signal reached the API twice ({"GET /books/1":2}), while the same two calls without one reached it once.

  • It does not exist in Route Handlers. They are not part of the React component tree, so two fetch calls in a handler are two requests.

  • It is not the Data Cache. In the previous model fetch(url, { next: { revalidate: 60, tags: ["books"] } }) stores the response across requests: three requests to the page that used it made one API call ({"GET /books":1}), while three requests to the page without the option made three.

Opting a fetch out of memoization, and opting it into the previous model’s Data Cache
// app/df/optout/page.tsx: two identical fetches, memoization disabled by the signal
const { signal } = new AbortController();
const [a, b] = await Promise.all([fetch(url, { signal }), fetch(url, { signal })]);

// app/df/cached/page.tsx (previous model): persistent Data Cache for this request
const res = await fetch("http://localhost:3611/books", { next: { revalidate: 60, tags: ["books"] } });

The extended fetch options in one table

Table 6. fetch options added by Next.js, by caching model
Option Previous model Cache Components

(no cache option)

Not cached; fetched once at build time if reachable before any request-time API

Not cached; dynamic, so it must stream inside <Suspense>

cache: 'no-store'

Fetched on every request, even on a route without request-time APIs

Same as no option

cache: 'force-cache'

Looked up in the persistent cache by URL, method, headers and body; only 200 responses are stored

Prefer 'use cache' on the function

next: { revalidate: false | 0 | number }

Cache lifetime in seconds; the lowest value on a route wins

Prefer cacheLife

next: { tags: […​] }

Tags for revalidateTag; at most 128 tags of 256 characters

Prefer cacheTag

In development there are two extras. Server Components cache fetch responses across hot reloads (experimental.serverComponentsHmrCache, on by default), so uncached requests do not show fresh data between HMR refreshes until you navigate or reload; and a hard refresh sends cache-control: no-cache, which makes the dev server ignore the cache options and fetch from the source. logging.fetches.fullUrl prints each fetch with its full URL in the terminal, which is the quickest way to see which calls a page makes.

Passing Promises to Client Components with use()

Sometimes a Client Component needs data that a Server Component can start but that the page should not wait for. The Server Component creates the promise without await and passes it as a prop; the Client Component reads it with React’s use() inside a <Suspense> boundary. The request begins on the server, as early as possible, and the result streams into the page.

Start the promise on the server, do not await it (app/df/use/page.tsx)
import { Suspense } from "react";
import { connection } from "next/server";
import { getBook, getReviews } from "@/lib/bookshelf";
import Reviews from "./reviews";

export default async function Page() {
  await connection();
  const book = await getBook(1); // awaited: the title is in the first chunk
  const reviews = getReviews(book.id); // NOT awaited: a promise crosses the boundary
  return (
    <main>
      <h1>{book.title}</h1>
      <Suspense fallback={<p>Loading reviews...</p>}>
        <Reviews reviews={reviews} />
      </Suspense>
    </main>
  );
}
Read it in the Client Component (app/df/use/reviews.tsx)
"use client";
import { use } from "react";
import type { Review } from "@/lib/types";

export default function Reviews({ reviews }: { reviews: Promise<Review[]> }) {
  const all = use(reviews); // suspends until the server promise resolves
  return <p>{all.length} review(s): {all.map((r) => r.text).join(", ")}</p>;
}
Observed over HTTP: the fallback first, then the streamed result
<main><h1>Dune</h1><!--$?--><template id="B:0"></template><p>Loading reviews...</p><!--/$--></main>
...
<div hidden id="S:0"><p>1 review(s): Spice.</p></div>

Rules and limits worth knowing:

  • The promise must come from a Server Component (or from a cache or library that is Suspense-compatible). A promise created while a Client Component renders is a new promise on every render, so it never settles from React’s point of view.

  • A promise from a Server Component is serialised across the boundary; the value it resolves to must itself be serialisable, which excludes functions, class instances and the like.

  • await in the Server Component when the data is needed to render the server part, and use() in the Client Component when only the client part needs it; React documents the trade-off under "resolve a promise in a Server or Client Component".

  • To share one promise with many Client Components, provide it through a context provider rather than passing it down as a prop; the SPA guide has the pattern.

  • A rejected promise is an error: put an error.js or an error boundary around it.

  • Under Cache Components the Server Component that starts the promise runs inside the shell or a Suspense hole like any other, so the same rules about request-time data apply.

Client-Side Fetching

Many pages need no client-side data library at all: the initial data comes from Server Components, and anything that changes is a Server Action followed by a refresh. Reach for a client library when Client Components need a shared browser cache with focus revalidation, polling, request deduplication or optimistic updates. The official client-side guide offers three patterns.

Table 7. Client-side fetching patterns
Pattern SWR TanStack Query Data is available

Inline loading states

useSWR

useQuery

After hydration, from a browser request

Suspense loading states

useSWR with suspense: true

useSuspenseQuery

After hydration, from a browser request

Provided by the server

<SWRConfig fallback>

<HydrationBoundary>

In the initial render or streamed

The browser needs something to call. Either a Route Handler of your own (a thin BFF endpoint that validates input and hides the upstream URL) or, for a public API, the API itself, which then has to allow your origin with CORS. Prefer the Route Handler, and remember that a NEXT_PUBLIC_ variable is inlined into the client bundle, so it is never a place for a secret.

A BFF endpoint for the browser to call (app/api/books/route.ts)
import { type NextRequest } from "next/server";
import { z } from "zod";
import { getBooks } from "@/lib/bookshelf";

const Query = z.object({ q: z.string().max(50).default("") });

// A thin BFF endpoint for client-side fetching: validates input, hides the upstream URL.
export async function GET(request: NextRequest) {
  const parsed = Query.safeParse({ q: request.nextUrl.searchParams.get("q") ?? undefined });
  if (!parsed.success) return Response.json({ error: "invalid query" }, { status: 400 });
  const books = await getBooks(parsed.data.q);
  return Response.json(books);
}
Observed
curl -s "localhost:3601/api/books?q=sol"                    # [{"id":3,"title":"Solaris","authorId":3,"priceCents":799}]
curl -s -w "%{http_code}" "localhost:3601/api/books?q=<60 x>"  # {"error":"invalid query"}400

Source: Backend for Frontend; Route Handlers are the subject of the route-handlers-and-backend-for-frontend page of this section, not written yet at the time of writing.

SWR

Install it with npm install swr. The first component fetches entirely in the browser, with a null key to pause the request until the user has typed something.

SWR with inline loading states (app/df/swr/book-search.tsx)
"use client";
import { useState } from "react";
import useSWR from "swr";
import type { Book } from "@/lib/types";

async function fetcher(url: string): Promise<Book[]> {
  const res = await fetch(url);
  if (!res.ok) throw new Error("Failed to fetch books");
  return res.json();
}

export function BookSearch() {
  const [q, setQ] = useState("");
  // A null key pauses the request until the user has typed something.
  const { data = [], error, isLoading } = useSWR(
    q ? `/api/books?q=${encodeURIComponent(q)}` : null,
    fetcher,
  );
  return (
    <div>
      <input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search books" />
      {error && <p>Failed to load books.</p>}
      {isLoading && <p>Loading...</p>}
      <ul>{data.map((b) => <li key={b.id}>{b.title}</li>)}</ul>
    </div>
  );
}

In the browser, typing sol sent one GET /api/books?q=sol request and showed Solaris; before typing, no request was sent. The second pattern seeds SWR from the server: the Server Component starts the promise without awaiting it and hands it over as the fallback for a key, and the Client Component reads the same key.

Seeding SWR from a Server Component (key helper, page and client list)
// app/df/swr/books-key.ts  (no "use client": both sides import it)
export const booksKey = (q: string) => `/api/books?q=${encodeURIComponent(q)}`;

// app/df/swr/page.tsx
import { Suspense } from "react";
import { SWRConfig } from "swr";
import { connection } from "next/server";
import { getBooks } from "@/lib/bookshelf";
import { BookList } from "./book-list";
import { booksKey } from "./books-key";

export default async function Page() {
  await connection();
  return (
    <main>
      <Suspense fallback={<p>Loading books...</p>}>
        <SWRConfig value={{ fallback: { [booksKey("")]: getBooks("") } }}>
          <BookList q="" />
        </SWRConfig>
      </Suspense>
    </main>
  );
}

// app/df/swr/book-list.tsx
"use client";
import useSWR from "swr";
import type { Book } from "@/lib/types";
import { booksKey } from "./books-key";

async function fetcher(url: string): Promise<Book[]> {
  const res = await fetch(url);
  if (!res.ok) throw new Error("Failed to fetch books");
  return res.json();
}

// Same key as the server-provided fallback, so the first render needs no browser request.
export function BookList({ q }: { q: string }) {
  const { data } = useSWR(booksKey(q), fetcher, { suspense: true });
  return <ul>{data.map((b) => <li key={b.id}>{b.title}</li>)}</ul>;
}
Observed
HTTP: the streamed HTML contains <li>Dune</li><li>Neuromancer</li><li>Solaris</li> inside the Suspense hole
Browser: the page renders the three books, then SWR revalidates once: GET /api/books?q=

Two lessons from running it. The key helper must live in a module without 'use client': first version of the page imported booksKey from the Client Component file and failed at request time with "Attempted to call booksKey() from the server but booksKey is on the client". And SWR revalidated once in the browser after mounting, by default: data that comes from a fallback is revalidated like any other cached data, so tune revalidateOnMount and revalidateIfStale (or accept the one request) when that cost matters. The SWR docs require version 2.3.0 or later with React 19 for the unawaited-promise fallback; the page was run with 2.5.1.

TanStack Query

Install it with npm install @tanstack/react-query. It needs a provider that creates one QueryClient per server render and reuses one in the browser, so requests never share a cache on the server.

The provider and the layout that renders it (app/df/query/providers.tsx and layout.tsx)
"use client";
import type { ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

let browserQueryClient: QueryClient | undefined;

function getQueryClient() {
  // A fresh client per server render (requests must not share a cache); one client in the browser.
  if (typeof window === "undefined") return new QueryClient();
  browserQueryClient ??= new QueryClient();
  return browserQueryClient;
}

export function Providers({ children }: { children: ReactNode }) {
  return <QueryClientProvider client={getQueryClient()}>{children}</QueryClientProvider>;
}

// app/df/query/layout.tsx
import { Providers } from "./providers";

export default function Layout({ children }: LayoutProps<"/df/query">) {
  return <Providers>{children}</Providers>;
}

The server prefetches without awaiting and dehydrates the pending query, which streams it to the browser; the Client Component reads the same query key.

Prefetching on the server, reading in the browser
// app/df/query/books-cache.ts
import { queryOptions } from "@tanstack/react-query";
import type { Book } from "@/lib/types";

export const booksCache = {
  key: (q: string) => ["books", q] as const,
  options: (q: string) =>
    queryOptions({
      queryKey: booksCache.key(q),
      queryFn: async (): Promise<Book[]> => {
        const res = await fetch(`/api/books?q=${encodeURIComponent(q)}`);
        if (!res.ok) throw new Error("Failed to fetch books");
        return res.json();
      },
      staleTime: 30_000, // keep server-provided data fresh so the client does not refetch at once
    }),
};

// app/df/query/page.tsx
import { Suspense } from "react";
import { connection } from "next/server";
import { defaultShouldDehydrateQuery, dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query";
import { getBooks } from "@/lib/bookshelf";
import { booksCache } from "./books-cache";
import { BookList } from "./book-list";

export default async function Page() {
  await connection();
  return (
    <Suspense fallback={<p>Loading books...</p>}>
      <BooksData />
    </Suspense>
  );
}

function BooksData() {
  const queryClient = new QueryClient();
  // Not awaited, so rendering is not blocked; the pending query is streamed.
  void queryClient.prefetchQuery({ ...booksCache.options(""), queryFn: () => getBooks("") });
  return (
    <HydrationBoundary
      state={dehydrate(queryClient, {
        shouldDehydrateQuery: (query) =>
          defaultShouldDehydrateQuery(query) || query.state.status === "pending",
      })}
    >
      <BookList q="" />
    </HydrationBoundary>
  );
}

// app/df/query/book-list.tsx
"use client";
import { useSuspenseQuery } from "@tanstack/react-query";
import { booksCache } from "./books-cache";

export function BookList({ q }: { q: string }) {
  const { data } = useSuspenseQuery(booksCache.options(q));
  return <ul>{data.map((b) => <li key={b.id}>{b.title}</li>)}</ul>;
}

Observed: the HTML carried the three books inside the Suspense hole, and in the browser no request to /api/books was made, thanks to the staleTime of 30 seconds. The server overrides queryFn because the client function uses a relative URL that only resolves in the browser. Pending-query dehydration needs TanStack Query 5.40.0 or later; the page was run with 5.104.1.

Three caches that must agree

When a client library and Next.js caching are both in use, up to three layers hold related data: the Next.js server cache, the Next.js client cache of route payloads, and the library cache under its key. Each has its own freshness policy and they do not need matching durations, but the identity (key or tag) and the invalidation after a mutation must be coordinated. After a mutation, invalidate the cached server read that provided the initial data and update or invalidate the library cache: updateTag(tag) when a Server Action must make its update visible at once, revalidateTag(tag, 'max') when stale data is acceptable meanwhile, and revalidateTag(tag, { expire: 0 }) for a webhook that needs immediate expiry. An optimistic update must restore the previous browser value when the write fails. If the server read is not cached there is nothing on the server to invalidate. The mutation side is on the Server Actions page of this section (not written yet at the time of writing).

A plain effect, written correctly

Without a library, a Client Component can fetch in an effect. It is the most error-prone option, and the effect callback itself must not be async (an async callback returns a promise where React expects a cleanup function), every request needs to be cancellable, and errors and non-ok responses need handling. The version below passes all three.

Fetching in an effect with abort and error handling (app/df/effect/book-effect.tsx)
"use client";
import { useEffect, useState } from "react";
import type { Book } from "@/lib/types";

export function BookEffect({ q }: { q: string }) {
  const [books, setBooks] = useState<Book[] | null>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const controller = new AbortController();
    // The effect callback itself is not async: it returns the cleanup function.
    async function load() {
      try {
        const res = await fetch(`/api/books?q=${encodeURIComponent(q)}`, { signal: controller.signal });
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        setBooks(await res.json());
      } catch (e) {
        if (e instanceof DOMException && e.name === "AbortError") return; // superseded or unmounted
        setError(e instanceof Error ? e.message : "unknown error");
      }
    }
    load();
    return () => controller.abort();
  }, [q]);

  if (error) return <p>Error: {error}</p>;
  if (!books) return <p>Loading...</p>;
  return <ul>{books.map((b) => <li key={b.id}>{b.title}</li>)}</ul>;
}

The server-rendered HTML contains only "Loading…​" (effects do not run on the server), and the browser then shows the three books after one request to /api/books. This is the weakest option for a page that has any other choice: the data is not in the HTML, the request starts only after the JavaScript has loaded and hydrated, and nested components that each do this form a waterfall. See Client-Side Rendering for the cost of that waterfall.

GraphQL and Apollo Client

A GraphQL API is an HTTP endpoint that takes a POST with a query. Anything that can POST can use it, so a Server Component can call it with plain fetch. For a normalised cache, typed hooks and a shared store between server and client, Apollo Client has an official App Router integration, @apollo/client-integration-nextjs. The scratch app used @apollo/client 4.3.1 with @apollo/client-integration-nextjs 0.14.5; install them with npm install @apollo/client @apollo/client-integration-nextjs graphql rxjs.

For the GraphQL fundamentals, the cache configuration and the SSR hydration idea behind the integration, this site has Apollo Client configuration; for a back end that serves such an endpoint see Strawberry on FastAPI and GraphQL with Django. This section only shows the Next.js wiring.

Plain fetch from a Server Component

A GraphQL query from the server with no client library
// lib/bookshelf-graphql.ts
import "server-only";

export async function graphql<T>(query: string, variables: Record<string, unknown> = {}): Promise<T> {
  const res = await fetch(process.env.BOOKSHELF_GRAPHQL_URL ?? "http://localhost:3611/graphql", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables }),
  });
  if (!res.ok) throw new Error(`GraphQL endpoint answered ${res.status}`);
  const body = (await res.json()) as { data?: T; errors?: { message: string }[] };
  if (body.errors?.length) throw new Error(body.errors[0].message); // GraphQL errors come with HTTP 200
  return body.data as T;
}

A POST is not memoized and is not cached by default, unlike a GET; opt in with cache: 'force-cache' or 'use cache' if the query is cacheable, or use persisted queries with GET. The check of errors matters because GraphQL reports resolver errors in a 200 response.

Apollo in Server Components

registerApolloClient creates one client per request for every Server Component, which prevents duplicate GraphQL requests inside a render. The URL must be absolute, because relative URLs do not resolve on the server.

One Apollo client per request, and the query it runs (lib/apollo.ts and lib/queries.ts)
// lib/apollo.ts
import { HttpLink } from "@apollo/client";
import { registerApolloClient, ApolloClient, InMemoryCache } from "@apollo/client-integration-nextjs";

// One client per request for every Server Component. Absolute URL: relative URLs cannot be used on the server.
export const { getClient, query, PreloadQuery } = registerApolloClient(() => {
  return new ApolloClient({
    cache: new InMemoryCache(),
    link: new HttpLink({ uri: process.env.BOOKSHELF_GRAPHQL_URL ?? "http://localhost:3611/graphql" }),
  });
});

// lib/queries.ts
import { gql } from "@apollo/client";

export const BOOKS = gql`
  query Books {
    books {
      id
      title
      author {
        id
        name
      }
    }
  }
`;

Apollo in Client Components, seeded by PreloadQuery

For Client Components the integration provides ApolloNextAppProvider, which takes a makeClient function. The scratch app scopes it to the segment that needs it, app/df/apollo/layout.tsx, instead of the root layout.

The provider, the layout, a Client Component and the page
// app/df/apollo/apollo-wrapper.tsx
"use client";
import { HttpLink } from "@apollo/client";
import { ApolloNextAppProvider, ApolloClient, InMemoryCache } from "@apollo/client-integration-nextjs";

function makeClient() {
  // Browser requests go to the BFF or to a CORS-enabled API; SSR needs an absolute URL.
  const link = new HttpLink({ uri: process.env.NEXT_PUBLIC_GRAPHQL_URL ?? "http://localhost:3611/graphql" });
  return new ApolloClient({ cache: new InMemoryCache(), link });
}

export function ApolloWrapper({ children }: React.PropsWithChildren) {
  return <ApolloNextAppProvider makeClient={makeClient}>{children}</ApolloNextAppProvider>;
}

// app/df/apollo/layout.tsx
import { ApolloWrapper } from "./apollo-wrapper";

export default function Layout({ children }: LayoutProps<"/df/apollo">) {
  return <ApolloWrapper>{children}</ApolloWrapper>;
}

// app/df/apollo/client-books.tsx
"use client";
import { useSuspenseQuery } from "@apollo/client/react";
import { BOOKS } from "@/lib/queries";

type Data = { books: { id: string; title: string; author: { id: string; name: string } }[] };

export function ClientBooks() {
  const { data } = useSuspenseQuery<Data>(BOOKS);
  return <ul>{data.books.map((b) => <li key={b.id}>{b.title} - {b.author.name}</li>)}</ul>;
}

// app/df/apollo/page.tsx
import { Suspense } from "react";
import { connection } from "next/server";
import { PreloadQuery, query } from "@/lib/apollo";
import { BOOKS } from "@/lib/queries";
import { ClientBooks } from "./client-books";

export default async function Page() {
  await connection();
  const { data } = await query<{ books: { id: string; title: string }[] }>({ query: BOOKS });
  return (
    <main>
      <p>Server Component sees {data!.books.length} books</p>
      {/* Separate client instance: the data is seeded into the browser cache, not shared with RSC */}
      <PreloadQuery query={BOOKS}>
        <Suspense fallback={<p>Loading...</p>}>
          <ClientBooks />
        </Suspense>
      </PreloadQuery>
    </main>
  );
}
Observed in a headless Chrome on the production build
Server Component sees 3 books
Dune - Frank Herbert
Neuromancer - William Gibson
Solaris - Stanislaw Lem
Browser requests to the API or to /api: none (the client list was streamed from the server's PreloadQuery)

Points to remember, all from the integration’s documentation. Data fetched through PreloadQuery is client data and should not be read in Server Components; the integration deliberately uses a separate client so the two cannot mix, and RSC queries do not update in the browser while SSR/client queries can. Prefer the suspense hooks (useSuspenseQuery, useFragment) so server rendering streams; place Suspense boundaries at meaningful points; and when a query uses @defer, use PreloadQuery with useReadQuery, or the integration’s multipart links, because useSuspenseQuery only suspends until the first part arrives. Pass Next.js-specific fetch options through fetchOptions on the HttpLink or per query through context.fetchOptions (for example next: { revalidate: 60 }); that option does not work with export const dynamic = "force-static".

The Pages Router Data Functions (Legacy)

The Pages Router fetches data in functions that a page module exports, which Next.js runs on the server and whose result it hands to the page as props. They still work in Next.js 16.3 and are the right tool for an existing Pages Router application; for new work the App Router is the recommended path. A project can contain both a pages/ and an app/ directory, as the scratch app does.

getServerSideProps: per request (pages/legacy.tsx)
import type { GetServerSideProps, InferGetServerSidePropsType } from "next";

type Book = { id: number; title: string };

export const getServerSideProps = (async ({ query }) => {
  const q = typeof query.q === "string" ? query.q : "";
  const res = await fetch(`${process.env.BOOKSHELF_API_URL ?? "http://localhost:3611"}/books?q=${encodeURIComponent(q)}`);
  if (!res.ok) throw new Error(`Bookshelf API answered ${res.status}`);
  const books: Book[] = await res.json();
  return { props: { books } }; // props are serialized into the HTML (__NEXT_DATA__): nothing secret here
}) satisfies GetServerSideProps<{ books: Book[] }>;

export default function Legacy({ books }: InferGetServerSidePropsType<typeof getServerSideProps>) {
  return <ul>{books.map((b) => <li key={b.id}>{b.title}</li>)}</ul>;
}
getStaticProps with revalidation: at build time, then incrementally (pages/legacy-static.tsx)
import type { GetStaticProps, InferGetStaticPropsType } from "next";

type Book = { id: number; title: string };

export const getStaticProps = (async () => {
  const res = await fetch(`${process.env.BOOKSHELF_API_URL ?? "http://localhost:3611"}/books`);
  const books: Book[] = await res.json();
  return { props: { books }, revalidate: 60 }; // ISR: regenerate at most once a minute
}) satisfies GetStaticProps<{ books: Book[] }>;

export default function LegacyStatic({ books }: InferGetStaticPropsType<typeof getStaticProps>) {
  return <ul>{books.map((b) => <li key={b.id}>{b.title}</li>)}</ul>;
}
Observed
Route (pages)                Revalidate  Expire
┌ ƒ /legacy
└ ● /legacy-static (429 ms)          1m      1y

curl "localhost:3601/legacy?q=du"   ->  <ul><li>Dune</li></ul>
curl localhost:3601/legacy-static   ->  <ul><li>Dune</li><li>Neuromancer</li><li>Solaris</li></ul>

How the legacy functions map to the App Router, and the behaviours that differ:

Table 8. Pages Router data functions and their App Router counterparts
Pages Router What it does App Router replacement

getServerSideProps

Runs per request, only exported from a page, returns JSON props that are serialised into the HTML

An async Server Component that reads request data (cookies(), headers(), searchParams) or connection(), wrapped in <Suspense>

getStaticProps

Runs at build time (and with revalidate, incrementally); runs on every request in development

An async Server Component without request-time APIs; 'use cache' with cacheLife, or the previous model’s revalidate

getStaticPaths

Lists the dynamic paths to prerender, with a fallback mode

generateStaticParams (see Static Site Generation)

getInitialProps

Runs on server and client; disables automatic static optimisation

Not needed: fetch in components

Client fetching

SWR or an effect in the page

The same, from a Client Component

Pages Router behaviours to remember: getServerSideProps can only be exported from a page, so a component deeper in the tree cannot fetch for itself and data has to be passed down as props; it returns serialisable JSON that anyone can read in the HTML, so no secrets in props; a thrown error renders pages/500; you can set Cache-Control headers (for example public, s-maxage=10, stale-while-revalidate=59) from inside it to cache dynamic responses at a CDN, though getStaticProps with revalidate is usually the better fit; and during client-side navigation Next.js asks the server to run getServerSideProps. You never need an API route to feed getServerSideProps from your own page: call the database, the CMS or the back end directly from the function.

The Books' REST and GraphQL Examples, Rewritten

The two books consulted for this section fetch data with the Pages Router. Their ideas carry over; their code does not. This section restates each idea in current terms, without reproducing any listing.

Table 9. Idea by idea: the books' approach, and what to write in Next.js 16.3
The books' approach Problem Current approach

getServerSideProps calls an HTTP client (Axios) to fetch a list and passes it as props

Pages Router only; one extra dependency for something the platform has

An async Server Component with the built-in fetch (Node.js 18 and later has it; the book’s claim that Node lacks fetch is outdated), as in the books list above

A detail page builds the upstream URL by putting the route segment straight into a template string

An unvalidated value can change the path or query of the upstream request

Convert and validate first (a numeric ID, encodeURIComponent, zod), as lib/bookshelf.ts does; notFound() for unknown values

A Next.js API route forwards whatever it receives to another service

An open proxy: anyone can use your server to reach arbitrary paths of the upstream

A Route Handler with an allow-list of upstream paths and validated input, as app/api/books/route.ts above; see the BFF page of this section

useEffect(async () ⇒ …​) to fetch in a component, with no cancellation

An async effect callback returns a promise instead of a cleanup function, requests are never cancelled, stale responses can overwrite fresh ones, and errors are unhandled

A Server Component, use(), SWR or TanStack Query; or the effect shown in the plain-effect section above

Fetch the same data in getServerSideProps and again on the client

Duplicate requests

Seed the client library from the server (SWR fallback, TanStack HydrationBoundary)

Apollo Client set up in _app with a cache passed through pageProps

Specific to the Pages Router and to Apollo 3

@apollo/client-integration-nextjs with registerApolloClient, ApolloNextAppProvider and PreloadQuery

A recommendation to keep database access out of the front end

Sound in spirit, but over-broad for Server Components

Allow server-side data access, confined to a server-only Data Access Layer that authorises every call; or keep the back end separate, as the Bookshelf does

Pitfalls

Table 10. Common data fetching mistakes
Mistake Symptom Fix

A dynamic page without a request-time API, in the previous model

Every visitor sees the data from build time; the build fails if the API is down

Read searchParams, cookies(), headers() or call connection(); or set cache: 'no-store'

Awaiting independent requests one after the other

Time to first byte is the sum of the requests

Start early and await late, or Promise.all, or stream with Suspense

Reading request data in a layout that has a loading.js sibling

Navigation blocks instead of showing the fallback

Put a <Suspense> boundary around the slow part, or fetch in the page

Importing a data module in a Client Component

Build error (with server-only) or server code in the browser bundle

import "server-only" in every data module

Passing a whole database row to a Client Component

Private fields appear in the HTML and RSC payload

Return only the fields the UI needs from the Data Access Layer

A helper in a 'use client' file called from a Server Component

"Attempted to call …​ from the server but …​ is on the client"

Keep shared helpers in a module without 'use client'

Fresh object arguments to a React.cache function

The function runs again and again

Pass primitives such as an ID

Expecting memoization in a Route Handler

The same request is made twice

Share through a function that you call once, or cache with 'use cache'

Creating a promise in a Client Component render and passing it to use()

It never settles; the component suspends forever

Create the promise in a Server Component, or use a Suspense-compatible library

A shared QueryClient on the server

One visitor’s data reaches another

Create a client per server render

NEXT_PUBLIC_ on an API address or a token

The value is inlined into client JavaScript

Server-only variables; call the API from the server or a BFF

No timeout on upstream calls

A hung API hangs the render

AbortSignal.timeout(ms) and a fallback UI

What Changed Recently

Table 11. Data fetching changes by version
Version Change

13.0 (2022)

The App Router and the extended fetch arrive; fetch is cached by default and Server Components can be async.

14

Server Actions stable; the fetch and GET Route Handler defaults are still "cached".

15.0 (2024-10-21)

fetch requests are no longer cached by default (opt in with cache: 'force-cache', or fetchCache = 'default-cache'); GET Route Handlers are no longer cached by default; params, searchParams, cookies() and headers() become asynchronous.

16.0 (2025-10-21)

Synchronous access to params, searchParams, cookies() and headers() is removed; Cache Components arrives as the opt-in successor of experimental.ppr, dynamicIO and useCache, with 'use cache', cacheLife and cacheTag stable; revalidateTag(tag, profile), updateTag and refresh are new.

16.2 (2026-03-18)

Server Function calls are logged in development.

16.3 (2026-08-03)

Instant Navigations and partial prefetching (opt-in). The 16.3 documentation carries the client-side fetching guides for SWR and TanStack Query, which seed the library from a Server Component, and the preloading section of the Fetching Data page.

Ecosystem

Apollo Client 4 with @apollo/client-integration-nextjs; TanStack Query 5.40.0+ can dehydrate pending queries; SWR 2.3.0+ accepts promises as a fallback with React 19.

Where the Books Are Outdated

Table 12. Data fetching statements in the consulted books, and what holds today
In the books Today

Data is fetched in getServerSideProps, getStaticProps and getStaticPaths only

App Router pages are async Server Components; generateStaticParams replaces getStaticPaths; the legacy functions remain for Pages Router code.

Node.js has no fetch, so an HTTP library is needed on the server

fetch is global in the supported Node.js versions (20.9 and later) and Next.js extends it with caching and revalidation options.

Synchronous params and searchParams

Promises since 15; synchronous access is removed in 16.

Apollo wired up in _app with cache state in pageProps

@apollo/client-integration-nextjs for the App Router.

An API route in pages/api as the way to feed client code

A Route Handler (route.ts) in the App Router; API routes remain for the Pages Router.

useEffect(async () ⇒ …​) as a pattern

Not valid practice: an async callback returns a promise, not a cleanup function; use an inner function with an abort signal, or a library.