Web development basics · Lesson 6 of 11 · about 35 minutes

Data from an API

The bakery's prices now come from its supplier's API, through one server-only module that holds the key, sets a timeout and checks every item. Then the decision that matters most: when to ask the API. Fetch once at build, on every request, or every few seconds? Tested against a supplier that changes prices, goes down, slows down and sends bad data, each choice behaves very differently. One surprise: a warm build cache kept a price stale through a rebuild.

By the end you'll know

  • How to fetch data from an API in a server component, and keep its key on the server
  • How to set a timeout and check what an API sends back
  • The three main ways Next.js can fetch: at build, on every request, or on an interval, and what each costs
  • What visitors see when the API is down, slow or wrong, under each choice
  • How server-only and environment variables protect secrets
  • Why a rebuild doesn't always mean fresh data

Before you start

  • You need: Lessons 3 and 4: fetch, errors and timeouts in the browser, then the bakery's Next.js app. Python 3 for the made-up API.
  • Versions: Next.js 16.4.5 and server-only 0.0.1, November 2026, without the newer Cache Components option (which changes how caching is written; the trade-offs stay the same).
  • The example: Juniper & Rye, the made-up bakery, and Bakehouse, its made-up supplier. Every run and line of output below is real.

The short version

  • Fetch on the server, in a server component, and the API key never reaches the browser. import "server-only" makes the build fail if a client component tries to import it.
  • Set a timeout and check the data. A slow API failed in 2.02 seconds instead of hanging, and an item with a text price was dropped and logged instead of crashing the page.
  • Pick when to fetch, per page. At build: cheapest, and stale until the next build. Every request: always current, and down when the API is. Every few seconds: briefly behind, and kept the last good page when the API was down.
  • The route table tells you which you got: ○ static, ƒ dynamic, and a Revalidate column for interval pages.
  • A rebuild isn't a refresh. With Next.js's build cache still on disk, a rebuild made no API calls and kept the old price.

The supplier's API

Bakehouse supplies the bakery's prices. Its API (a small Python server, made up for this lesson and in the practice folder) has one real endpoint and a control panel for the experiments:

  • GET /v1/bakes returns today's bakes and prices, if the request carries the right key: Authorization: Bearer test-key-not-secret.
  • POST /_control changes its behaviour: {"mode": "down"} answers 503; "slow" waits four seconds; "bad" sends dark rye's price as text, "9.00", instead of a number; {"price": {"dark-rye": 9.5}} changes a price.
  • It logs every request, so you can count how often the bakery asks.

(Your machine)

BASH
python3 bakehouse_api.py
curl -s http://127.0.0.1:4000/v1/bakes
curl -s -H "Authorization: Bearer test-key-not-secret" http://127.0.0.1:4000/v1/bakes

The first starts the API (leave it running and use another terminal); the other two ask for the bakes without and with the key.

TEXT
{"error": "missing or wrong API key"}
{"bakes": [{"slug": "country-sourdough", "name": "Country sourdough", "price": 8.5}, {"slug": "dark-rye", "name": "Dark rye", "price": 9.0}, {"slug": "cardamom-bun", "name": "Cardamom bun", "price": 4.25}]}

The API's log:

TEXT
    1.0s  GET /v1/bakes -> 401 (no or wrong key)
    1.0s  GET /v1/bakes -> 200 (ok; dark rye 9.0)

One module that talks to the API

Every request to Bakehouse goes through one file, lib/bakehouse.js:

JS
import "server-only";

const BASE = process.env.BAKEHOUSE_URL;
const KEY = process.env.BAKEHOUSE_API_KEY;

// Today's bakes from the supplier's API. Runs only on the server.
export async function getBakes(fetchOptions = {}) {
  const res = await fetch(`${BASE}/v1/bakes`, {
    headers: { Authorization: `Bearer ${KEY}` },
    signal: AbortSignal.timeout(2000),          // give up after 2 seconds
    ...fetchOptions,
  });
  if (!res.ok) throw new Error(`Bakehouse answered ${res.status}`);
  return checkBakes(await res.json());
}

// Trust nothing: keep only items with the shape the page needs.
function checkBakes(body) {
  if (!Array.isArray(body?.bakes)) throw new Error("Bakehouse sent no list of bakes");
  const good = body.bakes.filter((b) =>
    typeof b?.slug === "string" && typeof b?.name === "string" &&
    typeof b?.price === "number" && Number.isFinite(b.price));
  if (good.length < body.bakes.length) {
    console.warn(`Bakehouse: dropped ${body.bakes.length - good.length} malformed item(s)`);
  }
  return good;
}
  • import "server-only" marks this file as server code. If any client component imports it, even indirectly, the build fails (tested below).
  • process.env.BAKEHOUSE_URL and BAKEHOUSE_API_KEY come from environment variables, not the code. Locally they're in .env.local, which Next.js loads automatically and which belongs in .gitignore. In production, they're set in the hosting platform (Lesson 8).
  • AbortSignal.timeout(2000) is Lesson 3's AbortController timeout in one line.
  • if (!res.ok) throw is Lesson 3's lesson again: fetch doesn't fail on a 503.
  • checkBakes keeps only items with a text slug and name and a real number for a price, and logs how many it dropped. The page never sees anything it can't display.
  • fetchOptions lets each page choose how this request is cached, which is the rest of this lesson.

.env.local:

TEXT
BAKEHOUSE_URL=http://127.0.0.1:4000
BAKEHOUSE_API_KEY=test-key-not-secret

The menu page calls it:

JS
import MenuList from "../components/MenuList";
import { getBakes } from "../../lib/bakehouse";

export const metadata = { title: "Today's bakes" };

export default async function MenuPage() {
  const bakes = await getBakes(/* caching choice goes here */);
  return (
    <>
      <h1>Today's bakes</h1>
      <MenuList items={bakes} />
      <p className="note">Prices from Bakehouse at {new Date().toISOString().slice(11, 19)} UTC</p>
    </>
  );
}

The note at the bottom records when the page was made, which shows whether a page is new or a saved copy. And if anything on the page throws, app/menu/error.js is shown instead:

JS
"use client";

// Shown instead of the menu if anything on the page throws.
export default function MenuError({ reset }) {
  return (
    <>
      <h1>Today's bakes</h1>
      <p role="status">We couldn't load today's menu just now. The bakery is open as usual.</p>
      <button type="button" onClick={() => reset()}>Try again</button>
    </>
  );
}

error.js is an error boundary: Next.js puts it around the page in that folder, and shows it if the page fails. It must be a client component, because reset (try again) runs in the browser.

When to ask the API

The same getBakes call can be made at three moments. The page was built four ways, changing only the options passed to getBakes, and the route table recorded what each became:

OptionsRoute table lineMeaning
none├ ○ /menuStatic: fetched once, during the build
{ cache: "force-cache" }├ ○ /menuStatic, and the response is saved for reuse
{ cache: "no-store" }├ ƒ /menuDynamic: fetched on every request
{ next: { revalidate: 10 } }├ ○ /menu 10s 1yStatic, rebuilt in the background at most every 10 seconds

The interval build's table gained two columns, Revalidate and Expire:

TEXT
Route (app)                    Revalidate  Expire
├ ○ /menu                             10s      1y

The first surprise is the first row. Next.js's documentation says fetch isn't cached by default, and that's true of the response. But the page that uses it was still built as static: with nothing in it that depends on a particular visitor, Next.js fetched once during the build and saved the finished page.

Freshness, measured

To see what each means, a script builds the page one way, starts it, changes dark rye's price at the supplier, and reloads the page over the next 16 seconds, counting the supplier's log lines:

BASH
api '{"price": {"dark-rye": 9.5}}'
for t in 1 6 11 12 16; do
  # wait until t seconds have passed, then:
  echo "   ${t}s  page says dark rye $(ask)"
done

api posts to Bakehouse's control panel; ask fetches /menu with curl and pulls out dark rye's price. The full script is in the practice folder. The results:

TEXT
== static (no cache option), clean build: getBakes()
   0s   page says dark rye $9.00
        (supplier changes dark rye to $9.50)
   1s  page says dark rye $9.00
   6s  page says dark rye $9.00
   11s  page says dark rye $9.00
   12s  page says dark rye $9.00
   16s  page says dark rye $9.00
   supplier API calls after startup: 0
TEXT
== no-store: getBakes({ cache: "no-store" })
   0s   page says dark rye $9.00
        (supplier changes dark rye to $9.50)
   1s  page says dark rye $9.50
   6s  page says dark rye $9.50
   11s  page says dark rye $9.50
   12s  page says dark rye $9.50
   16s  page says dark rye $9.50
   supplier API calls after startup: 6
TEXT
== revalidate every 10 s: getBakes({ next: { revalidate: 10 } })
   0s   page says dark rye $9.00
        (supplier changes dark rye to $9.50)
   1s  page says dark rye $9.00
   6s  page says dark rye $9.00
   11s  page says dark rye $9.00
   12s  page says dark rye $9.50
   16s  page says dark rye $9.50
   supplier API calls after startup: 1
  • Static never changed. The page was made during the build, and the supplier wasn't asked once after the server started. The price stays $9.00 until the next build.
  • Every request was current within a second, and asked the supplier six times for six page loads. A thousand visitors an hour is a thousand calls an hour, and the page is only ever as fast as the supplier.
  • Every 10 seconds showed the old price until 11 seconds, then the new one, with one call. Here's how: when a request arrives after the interval has passed, Next.js serves the saved page anyway and rebuilds it in the background. The 11-second request got the old page and triggered the rebuild; the 12-second request got the new one. That's called stale-while-revalidate, and it means one visitor per interval sees a slightly old page, so that nobody waits for the supplier.
Three ways to fetch, tested on freshness, cost and failure. Select the diagram to enlarge it.

A rebuild that didn't refresh

While running these tests, the static page once showed $9.50 straight after a build, though the supplier had just been reset to $9.00, and the supplier's log showed no request during that build. Next.js had reused a response saved in its build cache, .next/cache/fetch-cache, by an earlier build. So the test was repeated on purpose: build cleanly, change the supplier's price to $9.75, and build again with the cache still there:

TEXT
== rebuild with the build cache still there
   supplier says dark rye $9.75; API calls during the build: 0
   page says dark rye $9.00
== rebuild after deleting .next/cache/fetch-cache
   API calls during the build: 1
   page says dark rye $9.75

The rebuild made no API calls and kept the old price. Deleting the cache folder fixed it. The documentation says a default fetch runs once during next build; in this test, with a warm cache, it ran zero times. CI systems are often set up to keep .next/cache between builds, because it makes builds faster. If a site redeploys to pick up new data, that's exactly when this bites. Two safe habits: give data that changes a revalidate interval (or no-store), so freshness doesn't depend on builds at all; and when a build must pick up new data, clear the fetch cache first.

When the API misbehaves

Each failure was tried against the every-request page, then the interval page. A script switches Bakehouse's mode and reads the page's main content and HTTP status.

Every request

TEXT
== per request (no-store)
-- supplier: ok
   HTTP 200, 0.075909s: Today's bakes Country sourdough $8.50 Dark rye $9.00 Cardamom bun $4.25 Prices from Bakehouse at 03:59:49 UTC
-- supplier: down
   HTTP 500, 0.031082s: 
   JavaScript on : HTTP 500, main says: "Today's bakesWe couldn't load today's menu just now. The bakery is open as usual.Try again"
   JavaScript off: HTTP 500, main says: ""
-- supplier: slow
   HTTP 500, 2.020825s: 
-- supplier: bad
   HTTP 200, 0.015484s: Today's bakes Country sourdough $8.50 Cardamom bun $4.25 Prices from Bakehouse at 04:00:25 UTC
-- server log lines from the app
   ⨯ Error: Bakehouse answered 503
   ⨯ Error: Bakehouse answered 503
   ⨯ Error: Bakehouse answered 503
   ⨯ Error [TimeoutError]: The operation was aborted due to timeout
   Bakehouse: dropped 1 malformed item(s)
  • Down: the page returned HTTP 500, and curl's copy had an empty main area. In a browser with JavaScript, the error boundary appeared: "We couldn't load today's menu just now", with Try again. With JavaScript off, the page was blank. Because error.js is a client component, its message only appears once JavaScript runs: Lesson 3's progressive enhancement problem, in a new place.
  • Slow: the timeout fired at 2.02 seconds, and the page failed the same way. Without it, every visitor would have waited four seconds, and on a truly stuck API, until something else gave up.
  • Bad data: dark rye's text price was dropped, the rest of the menu showed, and the server logged "dropped 1 malformed item(s)". Without checkBakes, MenuList's b.price.toFixed(2) would have thrown on a string, and the whole menu would have been the error page.
  • The log has a line for each failure. On a real site, those lines are how you find out before customers tell you.

Every 10 seconds

TEXT
== refresh every 10 s, supplier goes down
-- supplier: ok
   HTTP 200, 0.009538s: Today's bakes Country sourdough $8.50 Dark rye $9.00 Cardamom bun $4.25 Prices from Bakehouse at 04:00:32 UTC
-- supplier: down, 11 s later
   HTTP 200, 0.005922s: Today's bakes Country sourdough $8.50 Dark rye $9.00 Cardamom bun $4.25 Prices from Bakehouse at 04:00:32 UTC
   HTTP 200, 0.004426s: Today's bakes Country sourdough $8.50 Dark rye $9.00 Cardamom bun $4.25 Prices from Bakehouse at 04:00:32 UTC
-- server log lines from the app
   ⨯ Error: Bakehouse answered 503
   ⨯ Error: Bakehouse answered 503
   Error: Bakehouse answered 503

Visitors never noticed. When the background rebuild failed, Next.js kept serving the last good page: same menu, same "04:00:32" timestamp, HTTP 200, in about 5 milliseconds. The failures went to the log instead of to customers. For a menu, a price that's a few minutes old is far better than an error page, which makes an interval the right choice for most pages built on someone else's API.

Keeping the key secret

A quick check that the key never reached the browser:

TEXT
== is the key in any browser JavaScript?
   files in .next/static containing it: 0

Then the mistake server-only exists to catch: a new client component, PriceCheck, imports getBakes to check prices from a button. The build:

TEXT
> Build error occurred
Error: Turbopack build failed with 2 errors:
./lib/bakehouse.js:1:1
Error: You're importing a module that depends on "server-only". This API is only available in Server Components in the App Router, but you are using it in the Pages Router.
    Learn more: https://nextjs.org/docs/app/building-your-application/rendering/server-components
> 1 | import "server-only";
    | ^^^^^^^^^^^^^^^^^^^^^
  2 |
  3 | const BASE = process.env.BAKEHOUSE_URL;
  4 | const KEY = process.env.BAKEHOUSE_API_KEY;
Ecmascript file had an error

The build refused, which is the point: code that holds a key can't end up in the browser by accident. One oddity: the message says "you are using it in the Pages Router", though this app only uses the App Router. Error messages aren't always right about why; the import trace that followed it (PriceCheck.js, a client component, importing lib/bakehouse.js) shows exactly where the problem is.

Two rules about environment variables in Next.js:

  • Variables are server-only by default. process.env.BAKEHOUSE_API_KEY in client code would just be undefined. Without server-only, that mistake doesn't fail the build; it fails quietly in the browser.
  • NEXT_PUBLIC_ variables are copied into browser JavaScript at build time. Name a variable that way only if you'd be happy to publish its value. Never put a key in one.

In real systems

  • Choose freshness per page, from how often the data changes and what a stale answer costs. Prices: an interval of minutes. Stock levels during a sale: every request, or an API built for that load. Opening hours: static, rebuilt when they change.
  • Revalidate on demand when you know data changed: revalidatePath and revalidateTag let a webhook from the supplier refresh exactly the pages affected, instead of waiting for an interval.
  • Respect the supplier's limits. Every-request pages multiply your traffic onto their API. Many APIs rate-limit (429 Too Many Requests) or charge per call.
  • Log and alert on failures. The interval page hid the outage from customers; it should not hide it from you. The Monitoring track's alerts apply here.
  • Validate at the edge of your system, where data arrives. Libraries like Zod do what checkBakes does, with less code, for bigger shapes.

Spot the problem

Work out what's wrong before you read the explanation.

"We redeploy every morning to update prices." A shop's product pages fetch prices from its stock system with no cache options. Prices change overnight, so a scheduled job redeploys the site at 6am. For a week everything works. Then the CI provider enables build caching to speed up builds, and from the next morning, the site keeps showing last week's prices after every deploy, though each build reports success. What's wrong? The pages were static, and the build reused cached API responses instead of asking again. The redeploy rebuilt the pages from saved answers, exactly as in this lesson's test. Three fixes: give the price fetch a revalidate interval, so freshness doesn't depend on builds; or revalidate on demand when the stock system changes prices; and if a build must pick up new data, clear .next/cache/fetch-cache in the pipeline first. Then check: after a deploy, does the page's data match the source?

Keep this

ChoiceRoute tableFreshSupplier loadSupplier down
No options (build time)○Until the next build, if the build really asksOne call per buildUnaffected
cache: "no-store"ƒAlwaysEvery page loadError page
next: { revalidate: N }○ with Revalidate NWithin N seconds, plus one visitorAt most one call per N secondsLast good page
AlwaysServer-only module; key in an environment variable; timeout; check every item; log failures

Where you are now

You can fetch from an API on the server with its key kept secret, time out slow requests, check what comes back, and choose, per page, between build-time, every-request and interval fetching, knowing what each does to freshness, load and failures. You've also seen that a rebuild isn't always a refresh. The next lesson adds people to the bakery: logging in with Supabase, and what the browser can and can't be trusted with.

Check yourself

  1. Why does the menu page fetch from the API on the server?

  2. A fetch with no options built the menu as ○ static. What does that mean for prices?

  3. With revalidate: 10, why did the 11-second request still show $9.00?

  4. The supplier went down. Which page kept working?

  5. What did checkBakes do with a price sent as the text '9.00'?

  6. Why did a rebuild keep showing $9.00 after the price changed?

Key terms

  • API (application programming interface): a way for programs to ask another system for data or actions, here over HTTP.
  • API key: a secret that identifies and authorizes the caller.
  • Environment variable: a setting given to a program from outside its code, like BAKEHOUSE_API_KEY.
  • server-only: a package that makes the build fail if server code is imported into a client component.
  • Error boundary (error.js): what Next.js shows when a page in that folder throws.
  • Static, dynamic and revalidated pages: made at build, made per request, or made at build and rebuilt in the background on an interval.
  • Stale-while-revalidate: serving a saved copy while fetching a fresh one in the background.
  • Build cache: files Next.js keeps between builds to make them faster, including saved fetch responses.
  • Validation: checking that data has the shape you expect before using it.

Go deeper