cf.completefrontend
Open lab My progress
THE NEXT.JS FIELD GUIDE

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.

By the end, you can
  • 01
    Fetch 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".
  • 02
    Skip the extra requestExplain why a Server Component shouldn't fetch your own Route Handlers, and call the same function directly instead.
  • 03
    Give 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.

The stock page, fetching from the supplierNext.js
app/
app/page.tsx
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>  );}
Recorded from Next.js 16.4.3
localhost:3000/Tea shop
Tea shop
  • 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.

Three windows: the browser, the Next.js server and the supplier's API. The browser sends GET slash to the server. The server's StockList sends GET slash stock to the supplier, which answers with three teas as JSON after 200 milliseconds. The server sends the browser HTML with Tea shop, Green tea 12 in stock, Mint tea Sold out and Lemon tea 5 in stock. The browser made only one request.browserTea shopGreen tea: 12 in stockMint tea: Sold outLemon tea: 5 in stockGET /HTMLserverStockListawait fetch/stockJSONAPIGET /stock3 teas, JSONafter 200 msone request: GET /runs StockListthe supplier's stand-in
One visit to /. 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.
Real-life analogyThe assistant who phones the supplier

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
The main idea

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 /stock on 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 404 or a 500 is still an answer, and its promise resolves. fetch rejects only when there's no answer to read: the network fails, the address isn't a valid URL, or the request is aborted. Check response.ok, as line 7 does. The error it throws shows the nearest error.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.

Playground: when does the page ask the supplier?
The stock page, with this version of app/page.tsxNext.js
app/
app/page.tsx
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>  );}
Recorded from Next.js 16.4.3
$ 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 content
Try it yourself
In Suspense

The 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.

Every version was built with next build from Next.js 16.4.3, against the same stand-in for the supplier, and the two that built were visited three times. Only app/page.tsx changes. Reset goes back to the first version.

Each row is one version: how often the supplier's stand-in was asked during the build, and during the visits after it.

When the supplier is asked
The versionAsked during next buildAsked during the visits
In SuspenseNever.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 cacheNever: the build stopped first.No visits: a build that fails has nothing to start.
A table of four moments: next build, then visits 1, 2 and 3. In the row In Suspense, the build sends no request to the supplier, and each of the three visits asks once. In the row use cache, the build asks once, and none of the three visits asks.next buildvisit 1visit 2visit 3In Suspenseno requestasksasksasks"use cache"asksno requestno requestno request
When the supplier is asked, as the recordings show. Inside 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.

A Server Component that queries the databaseNext.js
app/lib/
app/page.tsx
import { 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>  );}
Recorded from Next.js 16.4.3
localhost:3000/Tea shop
Tea shop
  • 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:

The database page without connection(): PGlite reads the clockNext.js
app/lib/
app/page.tsx
import { 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 failed · Next.js 16.4.3
$ 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: null

The 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.

A visitor's value goes in as a parameter

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.

A cached menu that fetches the shop's own Route HandlerNext.js
app/api/teas/lib/
app/page.tsx
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>  );}
next build failed · Next.js 16.4.3
$ 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: null

The 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.

A page that fetches the shop's own Route Handler for each requestNext.js
app/api/teas/lib/
app/page.tsx
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>  );}
Recorded from Next.js 16.4.3
$ 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/teas

It 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:

The page and the Route Handler call the same functionNext.js
app/api/teas/lib/
app/page.tsx
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>  );}
Recorded from Next.js 16.4.3
$ 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/teas

Line 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.

Playground: one visit, with and without the extra request
Who does whatFetch /api/teas
What the visitor seesafter step 1
tea-shop.example/

A blank page

React isn't running yet: clicks do nothing.

The page isn't complete yet.

Step 1 of 7
Browser: The visitor opens the shop

The browser asks the server for the page at /.

Each step is one thing that happens on one visit, in order, after the build. Both pages' screens are from their recordings. Times aren't to scale. Reset goes back to the first page's first step.

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.

Fetching your own Route Handler, or calling the function
What we compareFetching /api/teasCalling getTeas()
During next buildNo 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 visitA second HTTP request, from the server to itself, and the Route Handler runs.One call to getTeas(), inside the same server.
How the teas travelAs JSON, turned into text and back.As objects, with nothing to convert.
What it needs to knowThe 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.

lib/teas.ts: every read of the teas, in one placeTSX
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.

In the middle, lib/teas.ts, marked server-only, holds getTeas. Arrows from app/page.tsx and from app/api/teas/route.ts point to it, and an arrow goes from it to the database. Below, a Client Component is crossed out, with the label: the build stops.app/page.tsxapi/teas/route.tslib/teas.tsserver-onlygetTeas()databaseid, name, pricea Client Componentthe build stops
One home for the data code. The page and the Route Handler both call 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-only line, 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:

Three ways to organize data code
The approachWhen it fitsWhat it looks like
Fetch your existing APIA 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 componentsPrototypes 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:

Where does this code go?
  • 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
Try it yourself
0 of 8 correct

Sort each piece: does it belong in the data module, in a Server Component, or in a Route Handler?

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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

  • "fetch runs in the browser, so data loads after the page." No. In a Server Component, fetch runs 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 fetch returns 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 used force-cache by 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 with Failed 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, and Suspense streams 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.

Exercise 1 · Warm-upCount the supplier's requests

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?

Starter codeTSX
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>
  );
}

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upFind the bug in the address

    This page's build stops while it prerenders /. What's wrong with line 5?

    Starter codeTSX
    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>
      );
    }
    

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeKeep the stock

      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?

      Starter codeTSX
      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>
        );
      }
      

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeWhy the cached menu can't build

        The build stops with TypeError: fetch failed. Why can't the fetch on line 5 work while the app is being built?

        Starter codeTSX
        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>
          );
        }
        

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeCall the logic directly

          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 /?

          Starter codeTSX
          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>
            );
          }
          

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeThe search that finds everything

            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.

            Starter codeTSX
            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>
              );
            }
            

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask where the code runs, and when: during the build, or for each request.

              The Fetching data quiz · 7 questionsScore: first tries count
              1. Question 1 of 7Where does the fetch on line 6 run?

                Read the code, then predictTSX
                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>
                  );
                }
                

                Choose an answer to see the explanation.

              2. 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.

              3. Question 3 of 7With "use cache" at the top of StockList, what happens on the second visit?

                Choose an answer to see the explanation.

              4. Question 4 of 7Why shouldn't a Server Component fetch the app's own Route Handler?

                Choose an answer to see the explanation.

              5. Question 5 of 7What does import "server-only" on line 1 of lib/teas.ts do?

                Choose an answer to see the explanation.

              6. Question 6 of 7Which line runs the visitor's search safely?

                Choose an answer to see the explanation.

              7. Question 7 of 7This page queries the database in TeaList. Why does line 8 call await connection()?

                Read the code, then predictTSX
                import { 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, fetch needs a full address, and it doesn't throw for an error status: check response.ok.
              • With Cache Components, keeping a result between requests is opt-in. An uncached fetch runs for each request, and Suspense shows 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.

              Read it in Next.js's own code

              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.

              CompleteFrontend Clear concepts. Working examples.