Fetching data in Server Components
Load data in Next.js Server Components: fetch another service's API, query a database directly, skip your own Route Handlers, and keep it in one module.
- 01Fetch on the serverCall fetch or a database from a Server Component, and say when Next.js runs it: for each request inside Suspense, or once with "use cache".
- 02Skip the extra requestExplain why a Server Component shouldn't fetch your own Route Handlers, and call the same function directly instead.
- 03Give data code one homePut data-loading code in a server-only module that returns only what pages show, and pass values to SQL as parameters.
The tea shop asks its supplier, on the server
The tea shop buys its teas from a supplier. The supplier has an HTTP API: addresses on its own server that answer a request with data. One of them, /stock, says how many cups of each tea are left. To show the stock, the shop's page has to ask that address. In Next.js, the page asks on the server, while it renders, with fetch, the same function browsers have (fetch teaches it). Server Components in Next.js showed that a Server Component can be async and wait for data. Here the data comes from another server.
This example is a real Next.js 16.4.3 project, built with next build and run with next start. The supplier isn't real. So that the recording doesn't depend on another company's server, the recorder started a small stand-in for the supplier's API on the same computer, and gave the app its address in the environment variable SUPPLIER_API_URL, the way a host gives a deployed app its settings (Server-only code and environment variables teaches them). The stand-in answers GET /stock with three teas, after 200 ms.
app/page.tsximport { Suspense } from "react"; type Stock = { id: string; name: string; stock: number }; async function StockList() { const response = await fetch(`${process.env.SUPPLIER_API_URL}/stock`); if (!response.ok) throw new Error(`The supplier answered ${response.status}`); const teas: Stock[] = await response.json(); return ( <ul> {teas.map((tea) => ( <li key={tea.id}> {tea.name}: {tea.stock > 0 ? `${tea.stock} in stock` : "Sold out"} </li> ))} </ul> );} export default function Home() { return ( <main> <h1>Tea shop</h1> <Suspense fallback={<p>Asking the supplier…</p>}> <StockList /> </Suspense> </main> );}- Green tea: 12 in stock
- Mint tea: Sold out
- Lemon tea: 5 in stock
The page as the browser shows it when its HTML arrives, drawn with plain styles. Its JavaScript doesn't run here.
In app/page.tsx, StockList, on lines 5 to 18, is an async Server Component. Line 6 calls fetch with the supplier's full address: the value of process.env.SUPPLIER_API_URL, then /stock. Line 7 throws an error if the supplier answered with an error status, and line 8 reads the answer's JSON into an array of teas. Lines 11 to 15 render one list item for each, like Mint tea: Sold out. Line 24 puts StockList inside Suspense, with Asking the supplier… to show while it waits.
Open the Terminal tab: ◐ /, prerendered as static HTML with dynamic server-streamed content. Then open the Network tab. The HTML starts with the heading and Asking the supplier…, and further down holds the list, which arrived once the supplier had answered. The second response, a second visit, got the same list.
Now open the Steps tab. The recorder also opened the shop in real Chrome, and kept every request the page made. There is one: GET /, the page itself. The browser never asked the supplier. The server did, and sent the result. The recording also searched the JavaScript files the page loads for SUPPLIER_API_URL and for the error message on line 7: neither is there.
/. The browser asks only the shop's server. StockList asks the supplier for /stock on the server, and the stock arrives in the page's HTML.A customer asks at the counter how much mint tea is left. The assistant goes to the back room, phones the supplier, and comes back with the answer on a note. The customer gets the answer. The supplier's number stays pinned up in the back room, and the customer never has to call anyone.
- In real life: A customer asking at the counter how much mint tea is left
- In Next.js: The browser asking for
/ - In real life: The assistant who phones the supplier from the back room
- In Next.js: A Server Component calling
fetch - In real life: The supplier's phone number, pinned up in the back room
- In Next.js:
SUPPLIER_API_URL, an environment variable on the server - In real life: The answer, written on a note and handed over the counter
- In Next.js: The HTML, with the stock in it
In Next.js, a page loads its data where it renders: on the server. A Server Component awaits fetch, a database query, or your own function, and the browser gets what it rendered, not the request. With Cache Components on, keeping data between requests is opt-in: uncached data is read when a request comes in, with Suspense to show something meanwhile, and "use cache" keeps a result.
fetch in a Server Component
On the server, Node.js provides fetch, and Next.js adds caching options to it (its API reference lists them). You call it the way you would in the browser, with three differences:
- It needs a full address. In the browser,
fetch("/stock")means/stockon the page's own site. On the server, there's no page to start from, so a path alone isn't an address. Write the whole address, usually built from an environment variable, as line 6 does. - An error status isn't an error. A
404or a500is still an answer, and its promise resolves.fetchrejects only when there's no answer to read: the network fails, the address isn't a valid URL, or the request is aborted. Checkresponse.ok, as line 7 does. The error it throws shows the nearesterror.tsx(Error handling). - The request stays on the server. The address, and any key in the request's headers, never reach the browser, unless a component renders them or passes them to a Client Component.
When does the server run the fetch? With Cache Components on, as in every new project, keeping a result between requests is opt-in: Next.js does it only for code marked "use cache". This fetch has no cache settings, so it's uncached. Next.js prerenders every part of a page it can during the build, and leaves an uncached fetch for request time. (Within one render, identical GET fetches are still sent once and their answer shared. That's called memoization, and Parallel and sequential fetching shows it.) So the page needs one of two things, which Server Components in Next.js showed with a timer: Suspense, which says what to show while the uncached part runs, or "use cache", which keeps the answer. Here, the choice also decides when the supplier is asked. Each button is a version of the page, built with real Next.js, and each recording kept every request the supplier's stand-in received. Before you click, guess how many times each version asks the supplier.
app/page.tsxNext.jsapp/page.tsximport { Suspense } from "react"; type Stock = { id: string; name: string; stock: number }; async function StockList() { const response = await fetch(`${process.env.SUPPLIER_API_URL}/stock`); if (!response.ok) throw new Error(`The supplier answered ${response.status}`); const teas: Stock[] = await response.json(); return ( <ul> {teas.map((tea) => ( <li key={tea.id}> {tea.name}: {tea.stock > 0 ? `${tea.stock} in stock` : "Sold out"} </li> ))} </ul> );} export default function Home() { return ( <main> <h1>Tea shop</h1> <Suspense fallback={<p>Asking the supplier…</p>}> <StockList /> </Suspense> </main> );}$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (3/3) Finalizing page optimization ... Route (app)┌ ◐ /└ ○ /_not-found ○ (Static) prerendered as static content◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed contentThe fetch on line 6 isn't cached, and the Suspense on lines 24 to 26 says what to show while it waits. The build prints ◐ / and never asks the supplier. Each of the three visits asks it once.
Each row is one version: how often the supplier's stand-in was asked during the build, and during the visits after it.
| The version | Asked during next build | Asked during the visits |
|---|---|---|
In Suspense | Never. | 3 times: once for each of the 3 visits. |
With "use cache" | Once. | Never: all 3 visits got the copy the build kept. |
No Suspense, no cache | Never: the build stopped first. | No visits: a build that fails has nothing to start. |
Suspense, never during the build and once for each visit. With "use cache", once during the build, and never for the visits.Uncached, every visitor gets fresh stock, and every visit costs one request to the supplier. Suspense doesn't decide that: it only gives the page something to show while the fetch runs. With "use cache", the build asks once, and every visitor gets the same list. The 15m in the build's Revalidate column means Next.js may ask again after 15 minutes, the default lifetime. Which one fits depends on how fresh the data must be. Without either, the build stops, and its error lists fixes, among them [stream] and [cache]. The Rendering and caching stage teaches "use cache", its lifetimes, and how to refresh a cache.
A Client Component can show the same data without asking for it: the page starts the fetch and passes the promise down, as Composing Server and Client Components showed. Fetching from the browser itself, for data only the browser knows, has a lesson of its own: Fetching in the browser.
Querying a database directly
The shop's own teas, with their names and prices, live in its database. A Server Component can query it directly: import the database's client, the library that talks to it, and run a query, with no API in between. A client-rendered React app can't do this in the browser: a database needs a password, and code in the browser can't keep one, so you'd build an API in front of it.
This example's database is PGlite: Postgres, a popular open-source database, compiled to WebAssembly so it runs inside the app, in memory. The recording needs no database server, and the SQL is real Postgres SQL. The next lesson, Connecting a database, sets up a database properly, with an ORM (object-relational mapper): a library that turns code you write into database queries.
app/page.tsximport { Suspense } from "react";import { connection } from "next/server";import db from "@/lib/db"; type Tea = { id: string; name: string; price: number }; async function TeaList() { await connection(); const { rows } = await db.query<Tea>("SELECT id, name, price FROM teas ORDER BY name"); return ( <ul> {rows.map((tea) => ( <li key={tea.id}> {tea.name}: ${tea.price} </li> ))} </ul> );} export default function Home() { return ( <main> <h1>Tea shop</h1> <Suspense fallback={<p>Loading teas…</p>}> <TeaList /> </Suspense> </main> );}- Green tea: $4
- Lemon tea: $3
- Mint tea: $3
The page as the browser shows it when its HTML arrives, drawn with plain styles. Its JavaScript doesn't run here.
Click lib/db.ts. Line 4 creates the database, and lines 6 to 9 make a teas table and add three teas, each with a price and a cost: what the shop pays for it. In app/page.tsx, line 3 imports the database, and line 9 runs one SQL query: the id, name and price of every tea, sorted by name. query gives back the rows as objects, and lines 12 to 16 render them. In next.config.ts, line 7 is the one line create-next-app doesn't write: serverExternalPackages tells Next.js to load PGlite from node_modules as it is, instead of bundling it with your code. A few packages need that, and Next.js already lists popular database clients itself, like pg and @prisma/client.
Line 8 is there because of PGlite: await connection(), from next/server, waits for a visitor's request, so the query after it runs at request time, never during the build. The Terminal tab shows ◐ /, and the Network tab shows the list streamed in after Loading teas…. A query to a database server doesn't need the line: it waits for the network, so it suspends, and Next.js leaves it for request time on its own, inside the Suspense. PGlite runs inside the app, and its code reads the clock with Date.now() while it works. Next.js stops a prerender that reads the clock, because the time changes from render to render. Here is the same page without line 8:
app/page.tsximport { Suspense } from "react";import db from "@/lib/db"; type Tea = { id: string; name: string; price: number }; async function TeaList() { const { rows } = await db.query<Tea>("SELECT id, name, price FROM teas ORDER BY name"); return ( <ul> {rows.map((tea) => ( <li key={tea.id}> {tea.name}: ${tea.price} </li> ))} </ul> );} export default function Home() { return ( <main> <h1>Tea shop</h1> <Suspense fallback={<p>Loading teas…</p>}> <TeaList /> </Suspense> </main> );}$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...Error: Route "/": Next.js encountered the unstable value `Date.now()` while prerendering. This value can change between renders, so it must be either prerendered or computed later. Ways to fix this: - [dynamic] Render at request time by adding a dynamic data access (e.g. `await connection()`) before this call - [cache] Prerender and cache the value with `"use cache"` - [client] Render the value on the client with `"use client"` - [measure] If the value is for telemetry, use a timing API such as `performance.now()` Learn more: https://nextjs.org/docs/messages/blocking-prerender-current-time at ignore-listed framesTo get a more detailed stack trace and pinpoint the issue, try one of the following: - Start the app in development mode by running `next dev`, then open "/" in your browser to investigate the error. - Rerun the production build with `next build --debug-prerender` to generate better stack traces.Error occurred prerendering page "/". Read more: https://nextjs.org/docs/messages/prerender-errorExport encountered an error on /page: /, exiting the build.⨯ Next.js build worker exited with code: 1 and signal: nullThe build stopped: Route "/": Next.js encountered the unstable value Date.now() while prerendering. The page's own code never calls Date.now(); PGlite's does. The error's first fix, [dynamic], is the line the working page has: await connection() before the call.
What reached the browser? The rendered names and prices. Not the SQL, and not the cost column, which the query never asked for. The recording searched the page's JavaScript files for SELECT id, name, price and for PGlite, and found neither.
When a query uses something a visitor sent, like a search, never paste it into the SQL text. Write a placeholder, $1, and pass the value on its own: db.query("SELECT … WHERE id = $1", [id]). The database then treats it as a value, whatever it contains. Pasted into the text, a visitor's value can change the query itself. That's called SQL injection.
Why not fetch your own Route Handlers
A Route Handler is a route.ts file in the app folder. Instead of a page, it answers HTTP requests with what its function returns, like JSON. File-system routing showed that it makes a public address, and the Route Handlers lesson, later in this stage, teaches it. The shop has one: app/api/teas/route.ts answers GET /api/teas with the teas as JSON, for the shop's phone app. It gets them from getTeas(), a function in lib/teas.ts.
Client-rendered React apps build a strong habit: data comes from the API, so the page fetches /api/teas. In a Server Component, that's a mistake. Next.js's docs give two reasons, and the recordings show both.
During the build, nothing answers
This version caches the menu with "use cache", so Next.js runs getMenu during next build. Line 5 fetches http://localhost:3000/api/teas, the address where the app runs on your computer.
app/page.tsximport type { Tea } from "@/lib/teas"; async function getMenu() { "use cache"; const response = await fetch("http://localhost:3000/api/teas"); return (await response.json()) as Tea[];} export default async function Home() { const teas = await getMenu(); return ( <main> <h1>Tea shop</h1> <ul> {teas.map((tea) => ( <li key={tea.id}> {tea.name}: ${tea.price} </li> ))} </ul> </main> );}$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...⨯ TypeError: fetch failed at async f (app/page.tsx:5:20) 3 | async function getMenu() { 4 | "use cache";> 5 | const response = await fetch("http://localhost:3000/api/teas"); | ^ 6 | return (await response.json()) as Tea[]; 7 | } 8 | { digest: '3466566397', [cause]: AggregateError: at ignore-listed frames { code: 'ECONNREFUSED', [errors]: [ Error: connect ECONNREFUSED ::1:3000 at ignore-listed frames { errno: -61, code: 'ECONNREFUSED', syscall: 'connect', address: '::1', port: 3000 }, Error: connect ECONNREFUSED 127.0.0.1:3000 at ignore-listed frames { errno: -61, code: 'ECONNREFUSED', syscall: 'connect', address: '127.0.0.1', port: 3000 } ] }}Error occurred prerendering page "/". Read more: https://nextjs.org/docs/messages/prerender-errorTypeError: fetch failed at async f (app/page.tsx:5:20) 3 | async function getMenu() { 4 | "use cache";> 5 | const response = await fetch("http://localhost:3000/api/teas"); | ^ 6 | return (await response.json()) as Tea[]; 7 | } 8 | { digest: '3466566397', [cause]: AggregateError: at ignore-listed frames { code: 'ECONNREFUSED', [errors]: [ Error: connect ECONNREFUSED ::1:3000 at ignore-listed frames { errno: -61, code: 'ECONNREFUSED', syscall: 'connect', address: '::1', port: 3000 }, Error: connect ECONNREFUSED 127.0.0.1:3000 at ignore-listed frames { errno: -61, code: 'ECONNREFUSED', syscall: 'connect', address: '127.0.0.1', port: 3000 } ] }}Export encountered an error on /page: /, exiting the build.⨯ Next.js build worker exited with code: 1 and signal: nullThe build stopped with TypeError: fetch failed, caused by connect ECONNREFUSED 127.0.0.1:3000: the connection was refused, because nothing was listening. next build runs before next start. While the app is being built, it isn't running, so it can't answer its own requests. A deployed app has a similar problem: the build runs before the new version is online, so its public address answers with nothing, or with the old version.
For each request, a second request
This version doesn't cache. TeaList, inside Suspense, fetches the Route Handler for each request, with the address built from the request's host header on line 6. Line 4 of the Route Handler prints a line in the server's terminal each time it runs.
app/page.tsximport { Suspense } from "react";import { headers } from "next/headers";import type { Tea } from "@/lib/teas"; async function TeaList() { const host = (await headers()).get("host"); const response = await fetch(`http://${host}/api/teas`); const teas: Tea[] = await response.json(); return ( <ul> {teas.map((tea) => ( <li key={tea.id}> {tea.name}: ${tea.price} </li> ))} </ul> );} export default function Home() { return ( <main> <h1>Tea shop</h1> <Suspense fallback={<p>Loading teas…</p>}> <TeaList /> </Suspense> </main> );}$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...Route Handler: GET /api/teasRoute Handler: GET /api/teas✓ Generating static pages (4/4) Finalizing page optimization ... Route (app)┌ ◐ /├ ○ /_not-found└ ƒ /api/teas ○ (Static) prerendered as static content◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed contentƒ (Dynamic) server-rendered on demand $ next start▲ Next.js 16.4.3- Local: http://127.0.0.1:3000- Network: http://127.0.0.1:3000✓ ReadyRoute Handler: GET /api/teasRoute Handler: GET /api/teasIt works: both visits show the teas. But look at what next start printed, at the end of the Terminal tab: Route Handler: GET /api/teas, twice, once for each visit. For every visit, the server sent an HTTP request to itself, routed it, ran the Route Handler, turned the teas into JSON, and read the JSON back in TeaList. The browser never asked for /api/teas. The server did.
Call the function instead
The Route Handler gets its teas from getTeas(). The page can call the same function:
app/page.tsximport { Suspense } from "react";import { getTeas } from "@/lib/teas"; async function TeaList() { const teas = await getTeas(); return ( <ul> {teas.map((tea) => ( <li key={tea.id}> {tea.name}: ${tea.price} </li> ))} </ul> );} export default function Home() { return ( <main> <h1>Tea shop</h1> <Suspense fallback={<p>Loading teas…</p>}> <TeaList /> </Suspense> </main> );}$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...Route Handler: GET /api/teasRoute Handler: GET /api/teas✓ Generating static pages (4/4) Finalizing page optimization ... Route (app)┌ ◐ /├ ○ /_not-found└ ƒ /api/teas ○ (Static) prerendered as static content◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed contentƒ (Dynamic) server-rendered on demand $ next start▲ Next.js 16.4.3- Local: http://127.0.0.1:3000- Network: http://127.0.0.1:3000✓ ReadyRoute Handler: GET /api/teasLine 5 of app/page.tsx calls getTeas() and gets the rows as objects. Now next start printed Route Handler: GET /api/teas once, for the recording's third request, which asked for /api/teas itself, as the phone app would. The two visits to / ran no Route Handler. In the Network tab, that third response is the JSON: the same three teas. The Route Handler still serves the phone app. The page just doesn't need it.
Step through one visit to each version. Before you start, guess after which step each visitor sees the teas.
Fetch /api/teasafter step 1A blank page
React isn't running yet: clicks do nothing.
The page isn't complete yet.
The browser asks the server for the page at /.
With the extra request, the list arrives after step 7. Called directly, after step 4. The three extra steps all happen on the server, so the visitor never sees them, only the wait. Each row compares the two ways the page can get its teas.
| What we compare | Fetching /api/teas | Calling getTeas() |
|---|---|---|
During next build | No server is running, so the request fails, and a cached page can't be built. | Nothing to send: the function runs inside the build. |
| For each visit | A second HTTP request, from the server to itself, and the Route Handler runs. | One call to getTeas(), inside the same server. |
| How the teas travel | As JSON, turned into text and back. | As objects, with nothing to convert. |
| What it needs to know | The app's own address, from the host header or a setting. | Nothing but the import. |
Route Handlers are still the right tool for callers that can't import your functions: the phone app, another company's server, or a Client Component that fetches in the browser.
Where data-loading code should live
Here is lib/teas.ts from the last two examples on its own. It's a file of the recorded project, so it runs only there, on the server, and it's the only file that reads the teas.
import "server-only";
import { connection } from "next/server";
import db from "./db";
export type Tea = { id: string; name: string; price: number };
// Every read of the teas goes through here: the columns pages show, never the cost.
export async function getTeas() {
await connection();
const { rows } = await db.query<Tea>("SELECT id, name, price FROM teas ORDER BY name");
return rows;
}
Line 1, import "server-only", marks it as code for the server: if a Client Component ever imports it, the build stops and names the file. Server-only code and environment variables showed that error. Line 8 exports getTeas. Line 9 waits for a request, and line 10 asks for three columns, never cost, so no page can pass the cost to a Client Component by mistake. Pages, layouts and Route Handlers call getTeas(), and none of them holds SQL.
getTeas() from lib/teas.ts, the only file that queries the teas. Its first line, import "server-only", stops the build if a Client Component imports it.A module like this gives the data code three things:
- One place to change. Change the query, and every page gets the change.
- One place to guard. The
server-onlyline, the columns, and later the check that the visitor may see the data at all. Next.js's data security guide calls a module like this a Data Access Layer, and puts those checks in it. Authorization and the data access layer builds one. - One place for secrets. The guide also recommends that only the data module reads
process.env, where the secret keys are.
The same guide describes three ways to organize data code, and recommends choosing one per project. Each row is one of them:
| The approach | When it fits | What it looks like |
|---|---|---|
| Fetch your existing API | A large app whose data already lives behind an HTTP API, often run by another team. | A Server Component calls fetch, as the tea shop does with its supplier. |
| A data module (the docs call it a Data Access Layer) | New projects. | One server-only module that reads the data, checks who may see it, and returns only what pages show. |
| Queries inside components | Prototypes and learning. | Each component runs its own SQL. Quick to write, and easy to send a field you meant to keep. |
The tea shop uses a data module for all of it: lib/teas.ts for its database, and a lib/supplier.ts with a getStock() function for the supplier's API, holding the overview's fetch. Components call those functions and render what they return.
Planning the tea shop's data code
Each piece of the shop's data code has a place. Sort them:
- The SQL that reads the teas
import "server-only"- Choosing which columns pages may see
- The fetch to the supplier's API, with its address from
process.env - Rendering the list of teas
<Suspense fallback={<p>Loading teas…</p>}>- Sending the teas as JSON to the shop's phone app
- Answering a Client Component that fetches in the browser
Sort each piece: does it belong in the data module, in a Server Component, or in a Route Handler?
The rule behind the sorter: data code lives in server-only modules, components call it and render the result, and Route Handlers exist for the callers that can't import your functions.
Common mix-ups
- "
fetchruns in the browser, so data loads after the page." No. In a Server Component,fetchruns on the server, and the data arrives with the page. In the recorded visit, Chrome's only request was the page. - "A Server Component should get its data from my own API routes." No. During the build nothing answers them, and for each request they cost a second request. Call the function the Route Handler calls.
- "Next.js keeps what
fetchreturns for the next visitor." Not since Next.js 15. The uncached fetch asked the supplier once for every visit; only"use cache"kept the answer. In Next.js 13 and 14, the App Router's server fetches usedforce-cacheby default, and the Caching before Cache Components lesson covers that older model. - "
fetch("/api/teas")works on the server, like in the browser." No. On the server there's no page to start from: a path alone fails withFailed to parse URL. The server needs a full address. - "Querying a database in a component sends the query to the browser." No. The code and the SQL stay on the server. Only what the component renders, and the props it passes to Client Components, reach the browser.
- "Every database query needs
await connection()first." No. A query to a database server waits for the network, so it suspends, andSuspensestreams it in at request time. The examples' PGlite needs the line because it reads the clock while it runs.
Practice exercises
These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.
This is app/page.tsx from the overview. The app is built, and then three visitors open /. How many requests does the supplier get in total?
import { Suspense } from "react";
type Stock = { id: string; name: string; stock: number };
async function StockList() {
const response = await fetch(`${process.env.SUPPLIER_API_URL}/stock`);
if (!response.ok) throw new Error(`The supplier answered ${response.status}`);
const teas: Stock[] = await response.json();
return (
<ul>
{teas.map((tea) => (
<li key={tea.id}>
{tea.name}: {tea.stock > 0 ? `${tea.stock} in stock` : "Sold out"}
</li>
))}
</ul>
);
}
export default function Home() {
return (
<main>
<h1>Tea shop</h1>
<Suspense fallback={<p>Asking the supplier…</p>}>
<StockList />
</Suspense>
</main>
);
}
3. The build asks it nothing: the fetch isn't cached, so Next.js leaves the list for each request. Each of the three visits (two requests and the visit in Chrome) asked once. The recording of the stand-in lists three requests for /stock, all after the build.
This page's build stops while it prerenders /. What's wrong with line 5?
type Stock = { id: string; name: string; stock: number };
async function getStock() {
"use cache";
const response = await fetch("/stock");
return (await response.json()) as Stock[];
}
export default async function Home() {
const teas = await getStock();
return (
<main>
<h1>Tea shop</h1>
<ul>
{teas.map((tea) => (
<li key={tea.id}>
{tea.name}: {tea.stock > 0 ? `${tea.stock} in stock` : "Sold out"}
</li>
))}
</ul>
</main>
);
}
type Stock = { id: string; name: string; stock: number };
async function getStock() {
"use cache";
const response = await fetch(`${process.env.SUPPLIER_API_URL}/stock`);
return (await response.json()) as Stock[];
}
export default async function Home() {
const teas = await getStock();
return (
<main>
<h1>Tea shop</h1>
<ul>
{teas.map((tea) => (
<li key={tea.id}>
{tea.name}: {tea.stock > 0 ? `${tea.stock} in stock` : "Sold out"}
</li>
))}
</ul>
</main>
);
}
The URL is relative. The recorded build stops with TypeError: Failed to parse URL from /stock. With the full address, ${process.env.SUPPLIER_API_URL}/stock, the build asks the supplier once and prints ○ /.
The supplier changes its stock once a day, so the shop decides to keep its answer. Change StockList so its result is cached. Which symbol does next build print for / then?
import { Suspense } from "react";
type Stock = { id: string; name: string; stock: number };
async function StockList() {
const response = await fetch(`${process.env.SUPPLIER_API_URL}/stock`);
if (!response.ok) throw new Error(`The supplier answered ${response.status}`);
const teas: Stock[] = await response.json();
return (
<ul>
{teas.map((tea) => (
<li key={tea.id}>
{tea.name}: {tea.stock > 0 ? `${tea.stock} in stock` : "Sold out"}
</li>
))}
</ul>
);
}
export default function Home() {
return (
<main>
<h1>Tea shop</h1>
<Suspense fallback={<p>Asking the supplier…</p>}>
<StockList />
</Suspense>
</main>
);
}
import { Suspense } from "react";
type Stock = { id: string; name: string; stock: number };
async function StockList() {
"use cache";
const response = await fetch(`${process.env.SUPPLIER_API_URL}/stock`);
if (!response.ok) throw new Error(`The supplier answered ${response.status}`);
const teas: Stock[] = await response.json();
return (
<ul>
{teas.map((tea) => (
<li key={tea.id}>
{tea.name}: {tea.stock > 0 ? `${tea.stock} in stock` : "Sold out"}
</li>
))}
</ul>
);
}
export default function Home() {
return (
<main>
<h1>Tea shop</h1>
<Suspense fallback={<p>Asking the supplier…</p>}>
<StockList />
</Suspense>
</main>
);
}
○. Line 6 caches what StockList returns, so the build asks the supplier once and keeps the list in the page. The recorded build prints ○ / with a Revalidate time of 15m, and none of the three visits asked the supplier.
The build stops with TypeError: fetch failed. Why can't the fetch on line 5 work while the app is being built?
import type { Tea } from "@/lib/teas";
async function getMenu() {
"use cache";
const response = await fetch("http://localhost:3000/api/teas");
return (await response.json()) as Tea[];
}
export default async function Home() {
const teas = await getMenu();
return (
<main>
<h1>Tea shop</h1>
<ul>
{teas.map((tea) => (
<li key={tea.id}>
{tea.name}: ${tea.price}
</li>
))}
</ul>
</main>
);
}
No server is running during the build. next start runs after next build, so nothing listens on localhost:3000 yet: the recorded build stops with TypeError: fetch failed and connect ECONNREFUSED 127.0.0.1:3000. Call getTeas() instead of fetching the Route Handler.
Change this page so it gets the teas without a second request. After your change, how many Route Handler: GET /api/teas lines does next start print for two visits to /?
import { Suspense } from "react";
import { headers } from "next/headers";
import type { Tea } from "@/lib/teas";
async function TeaList() {
const host = (await headers()).get("host");
const response = await fetch(`http://${host}/api/teas`);
const teas: Tea[] = await response.json();
return (
<ul>
{teas.map((tea) => (
<li key={tea.id}>
{tea.name}: ${tea.price}
</li>
))}
</ul>
);
}
export default function Home() {
return (
<main>
<h1>Tea shop</h1>
<Suspense fallback={<p>Loading teas…</p>}>
<TeaList />
</Suspense>
</main>
);
}
import { Suspense } from "react";
import { getTeas } from "@/lib/teas";
async function TeaList() {
const teas = await getTeas();
return (
<ul>
{teas.map((tea) => (
<li key={tea.id}>
{tea.name}: ${tea.price}
</li>
))}
</ul>
);
}
export default function Home() {
return (
<main>
<h1>Tea shop</h1>
<Suspense fallback={<p>Loading teas…</p>}>
<TeaList />
</Suspense>
</main>
);
}
0. Line 5 now calls getTeas() inside the same server, so a visit to / runs no Route Handler. In the recording, two visits printed no Route Handler: GET /api/teas line; it printed once, for a request to /api/teas itself.
The shop adds a search: /?id=mint shows Mint tea. A visitor opens /?id=x' OR 'a'='a instead. What does the page show? Then fix line 8.
import { Suspense } from "react";
import db from "@/lib/db";
type Tea = { id: string; name: string; price: number };
async function Found({ searchParams }: { searchParams: Promise<{ id?: string | string[] }> }) {
const { id = "" } = await searchParams;
const { rows } = await db.query<Tea>(`SELECT id, name, price FROM teas WHERE id = '${id}' ORDER BY name`);
if (rows.length === 0) return <p>No tea found.</p>;
return (
<ul>
{rows.map((tea) => (
<li key={tea.id}>
{tea.name}: ${tea.price}
</li>
))}
</ul>
);
}
export default function FindPage({ searchParams }: PageProps<"/">) {
return (
<main>
<h1>Find a tea</h1>
<Suspense fallback={<p>Searching…</p>}>
<Found searchParams={searchParams} />
</Suspense>
</main>
);
}
import { Suspense } from "react";
import db from "@/lib/db";
type Tea = { id: string; name: string; price: number };
async function Found({ searchParams }: { searchParams: Promise<{ id?: string | string[] }> }) {
const { id = "" } = await searchParams;
const { rows } = await db.query<Tea>("SELECT id, name, price FROM teas WHERE id = $1 ORDER BY name", [id]);
if (rows.length === 0) return <p>No tea found.</p>;
return (
<ul>
{rows.map((tea) => (
<li key={tea.id}>
{tea.name}: ${tea.price}
</li>
))}
</ul>
);
}
export default function FindPage({ searchParams }: PageProps<"/">) {
return (
<main>
<h1>Find a tea</h1>
<Suspense fallback={<p>Searching…</p>}>
<Found searchParams={searchParams} />
</Suspense>
</main>
);
}
All three teas: Green tea, Lemon tea and Mint tea. The visitor's text closes the quote and adds OR 'a'='a', which is true for every row. With $1 and [id], the value goes to the database as a parameter: the recorded search finds No tea found., and ?id=mint still finds Mint tea.
Check your understanding
For each question, ask where the code runs, and when: during the build, or for each request.
Question 1 of 7Where does the
fetchon line 6 run?Read the code, then predictTSXimport { Suspense } from "react"; type Stock = { id: string; name: string; stock: number }; async function StockList() { const response = await fetch(`${process.env.SUPPLIER_API_URL}/stock`); if (!response.ok) throw new Error(`The supplier answered ${response.status}`); const teas: Stock[] = await response.json(); return ( <ul> {teas.map((tea) => ( <li key={tea.id}> {tea.name}: {tea.stock > 0 ? `${tea.stock} in stock` : "Sold out"} </li> ))} </ul> ); } export default function Home() { return ( <main> <h1>Tea shop</h1> <Suspense fallback={<p>Asking the supplier…</p>}> <StockList /> </Suspense> </main> ); }Choose an answer to see the explanation.
Question 2 of 7In the recorded visit to the stock page in Chrome, which requests did the browser itself make?
Choose an answer to see the explanation.
Question 3 of 7With
"use cache"at the top ofStockList, what happens on the second visit?Choose an answer to see the explanation.
Question 4 of 7Why shouldn't a Server Component fetch the app's own Route Handler?
Choose an answer to see the explanation.
Question 5 of 7What does
import "server-only"on line 1 oflib/teas.tsdo?Choose an answer to see the explanation.
Question 6 of 7Which line runs the visitor's search safely?
Choose an answer to see the explanation.
Question 7 of 7This page queries the database in
TeaList. Why does line 8 callawait connection()?Read the code, then predictTSXimport { Suspense } from "react"; import { connection } from "next/server"; import db from "@/lib/db"; type Tea = { id: string; name: string; price: number }; async function TeaList() { await connection(); const { rows } = await db.query<Tea>("SELECT id, name, price FROM teas ORDER BY name"); return ( <ul> {rows.map((tea) => ( <li key={tea.id}> {tea.name}: ${tea.price} </li> ))} </ul> ); } export default function Home() { return ( <main> <h1>Tea shop</h1> <Suspense fallback={<p>Loading teas…</p>}> <TeaList /> </Suspense> </main> ); }Choose an answer to see the explanation.
Key takeaways
- A Server Component loads its data while it renders: it awaits
fetch, a database query, or your own function. The browser gets the result in the page, never the request. - On the server,
fetchneeds a full address, and it doesn't throw for an error status: checkresponse.ok. - With Cache Components, keeping a result between requests is opt-in. An uncached fetch runs for each request, and
Suspenseshows something while it runs. With"use cache", it runs once, and the result is kept. - A Server Component can query a database directly. A visitor's value goes into SQL as a parameter, never pasted into the text.
- Don't fetch your own Route Handlers from Server Components: nothing answers during the build, and each request costs a second one. Call the function the Route Handler calls.
- Give data code one home: a server-only module that returns only what pages show.
Remember the one-liner.
Load data where you render it, on the server, through your own server-only functions, never through your own API.
Next.js replaces the global fetch on the server with its own, in patchFetch, at lines 1389 to 1400 of patch-fetch.ts. In this version, a fetch with no cache settings, met while a page is prerendered with Cache Components on, isn't sent at all: lines 645 to 663 return a promise that never settles, which is why the supplier got no request during the build. That's a detail of this version, not a promise of the API; the Under the hood stage reads it.
Coming next: Connecting a database. It chooses a database and an ORM, sets up Postgres with Drizzle, and keeps one client while you work.