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.
| Place | Runs | Typical use |
|---|---|---|
Server Component, layout, |
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 |
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) |
|
On the server, per request / at build time |
Existing Pages Router applications |
Source: Fetching Data
Source: Client-side data fetching
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.
// 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.
| FastAPI | NestJS | Django (REST framework) | |
|---|---|---|---|
Typical development address |
|
|
|
List response |
The model you return, usually a JSON array |
The array the controller returns |
An array, or with page-number pagination the envelope |
Field naming |
Usually |
Whatever the DTO says, often |
Usually |
Typed client |
Generate types from |
Generate types from the Swagger document |
Generate types from the OpenAPI schema of the API |
Gotcha |
Validation errors are |
Default port clashes with |
A trailing slash is often required; a redirect on |
Source: Backend for Frontend
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.
// 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.
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 };
}
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>;
}
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
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>
);
}
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.
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:
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.
| Situation | Previous model (default) | Cache Components (cacheComponents: true) |
|---|---|---|
No request-time API, no |
Runs once at |
Counts as dynamic: it must be inside |
After |
Runs on every request |
Runs on every request, inside the |
|
Stored in the Data Cache and reused |
Not the mechanism of this model: use |
|
Not available |
Cached; part of the prerendered shell when it needs no request data |
Source: fetch: options.cache, Caching (previous model) and Caching
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:
import { getReviews } from "@/lib/bookshelf";
export default async function Page() {
const reviews = await getReviews(1); // uncached fetch outside <Suspense>
return <p>{reviews.length}</p>;
}
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
Source: blocking-prerender-dynamic
The fix is one of the three bullets. This page is the shell-plus-hole version: cache what is stable, stream what is not.
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>
);
}
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.
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.
Source: Fetching Data: streaming and loading.js
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.
The waterfall
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.
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.
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>;
}
Source: Fetching Data: with Suspense
| 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()).
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>;
}
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;
}
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
Source: Fetching Data: preloading data
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.
| Mechanism | Scope | Applies to | Lasts |
|---|---|---|---|
|
One server render pass |
|
Until the render ends |
|
One request |
Any function, such as an ORM or database call, or a |
Until the request ends; no sharing between requests |
|
Across requests, per cache key |
Any async function or component that does not read request data |
|
Data Cache with |
Across requests and deployments |
|
Until revalidated |
Source: fetch: memoization and React: cache
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.
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 />
</>
);
}
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).
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>;
}
Source: fetch: memoization
The limits of memoization are the source of most surprises.
-
It is per request. Neither
fetchmemoization norReact.cacheshares anything between two visitors. Persistent reuse is caching, a different feature. -
Arguments are compared by identity.
React.cachecompares its arguments withObject.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.
fetchis memoized forGETrequests with the same URL and options only; aPOST, a different header or a different URL string is a new request. -
It can be turned off. Passing an
AbortControllersignal tofetchopts 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
fetchcalls 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.
// 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"] } });
Source: fetch: memoization and fetch: options.next.revalidate
The extended fetch options in one table
| Option | Previous model | Cache Components |
|---|---|---|
(no |
Not cached; fetched once at build time if reachable before any request-time API |
Not cached; dynamic, so it must stream inside |
|
Fetched on every request, even on a route without request-time APIs |
Same as no option |
|
Looked up in the persistent cache by URL, method, headers and body; only |
Prefer |
|
Cache lifetime in seconds; the lowest value on a route wins |
Prefer |
|
Tags for |
Prefer |
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.
Source: fetch, serverComponentsHmrCache and logging
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.
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>
);
}
"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>;
}
<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.
-
awaitin the Server Component when the data is needed to render the server part, anduse()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.jsor 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.
| Pattern | SWR | TanStack Query | Data is available |
|---|---|---|---|
Inline loading states |
|
|
After hydration, from a browser request |
Suspense loading states |
|
|
After hydration, from a browser request |
Provided by the server |
|
|
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.
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);
}
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.
"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.
// 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>;
}
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.
"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.
// 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.
"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
// 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.
// 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.
// 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>
);
}
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.
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>;
}
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>;
}
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>
Source: getServerSideProps and getStaticProps
How the legacy functions map to the App Router, and the behaviours that differ:
| Pages Router | What it does | App Router replacement |
|---|---|---|
|
Runs per request, only exported from a page, returns JSON props that are serialised into the HTML |
An |
|
Runs at build time (and with |
An |
|
Lists the dynamic paths to prerender, with a fallback mode |
|
|
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 |
Source: Migrating to the App Router
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.
| The books' approach | Problem | Current approach |
|---|---|---|
|
Pages Router only; one extra dependency for something the platform has |
An |
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, |
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 |
|
An |
A Server Component, |
Fetch the same data in |
Duplicate requests |
Seed the client library from the server (SWR |
Apollo Client set up in |
Specific to the Pages Router and to Apollo 3 |
|
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 |
Source: Migrating to the App Router and Data security
Pitfalls
| 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 |
Awaiting independent requests one after the other |
Time to first byte is the sum of the requests |
Start early and |
Reading request data in a layout that has a |
Navigation blocks instead of showing the fallback |
Put a |
Importing a data module in a Client Component |
Build error (with |
|
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 |
"Attempted to call … from the server but … is on the client" |
Keep shared helpers in a module without |
Fresh object arguments to a |
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 |
Creating a promise in a Client Component render and passing it to |
It never settles; the component suspends forever |
Create the promise in a Server Component, or use a Suspense-compatible library |
A shared |
One visitor’s data reaches another |
Create a client per server render |
|
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 |
|
Source: Fetching Data, Data security and blocking-prerender-dynamic
What Changed Recently
| Version | Change |
|---|---|
13.0 (2022) |
The App Router and the extended |
14 |
Server Actions stable; the |
15.0 (2024-10-21) |
|
16.0 (2025-10-21) |
Synchronous access to |
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 |
Source: Next.js 15, Next.js 16, Upgrading to version 15, Upgrading to version 16, Next.js 16.3 and fetch
Where the Books Are Outdated
| In the books | Today |
|---|---|
Data is fetched in |
App Router pages are |
Node.js has no |
|
Synchronous |
Promises since 15; synchronous access is removed in 16. |
Apollo wired up in |
|
An API route in |
A Route Handler ( |
|
Not valid practice: an async callback returns a promise, not a cleanup function; use an inner function with an abort signal, or a library. |