Fetching data

Fetch where the data lives, and control how long an answer may be reused.

07of 16
≈ 13Min read
7Parts

Fetch in the component

The component is async, the data is there before the HTML is written, and the browser never sees a spinner for it. Half of the code you would write in a client app — the state, the effect, the loading flag, the API route — disappears.

no useEffect, no loading state, no endpoint
export default async function DoorsPage() {
  const res = await fetch("https://api.domvybe.com/doors");
  const doors = await res.json();

  return <ul>{doors.map((d) => <li key={d.id}>{d.name}</li>)}</ul>;
}

// or skip HTTP entirely — this component runs on the server
const doors = await db.doors.all();

Caching, per request

fetch is deduplicated automatically for the duration of one render. cache() gives the same behaviour to any function, which is what stops a layout and three components making the same query four times.

the same fetch twice is one request
// layout and page both need the user; only one request is made
const user = await fetch("/api/me").then((r) => r.json());

// for anything that is not fetch — a database call — ask for it explicitly
import { cache } from "react";
export const getUser = cache(async (id) => db.users.byId(id));

Caching, between requests

how long may this answer be reused?
fetch(url)                                   // in Next 15: NOT cached by default
fetch(url, { cache: "force-cache" })         // cached until something invalidates it
fetch(url, { cache: "no-store" })            // never — and the route becomes dynamic
fetch(url, { next: { revalidate: 3600 } })   // reuse for an hour, then refresh
fetch(url, { next: { tags: ["doors"] } })    // and let you invalidate it by name

// for a whole route
export const revalidate = 3600;
export const dynamic = "force-dynamic";
The default changed

In the App Router up to Next 14, fetch was cached by default and people were surprised by stale data. From Next 15 it is uncached by default and people are surprised by the bill. Always state what you mean.

Invalidating on purpose

Time-based revalidation is a guess; tags are a statement. Tag the reads, invalidate the tag in the write, and the catalogue is never stale for an hour because nobody wanted to think about it.

after a mutation, say what changed
"use server";

export async function createDoor(data) {
  await db.doors.insert(data);

  revalidateTag("doors");        // every fetch tagged "doors"
  revalidatePath("/doors");      // and this route
}

Parallel, not sequential

Sequential awaits are only necessary when the second genuinely needs the first. Everything else should start together — and a section that is slow and independent belongs behind its own Suspense instead.

the waterfall you write by accident
// 300 ms + 300 ms — the second await waits for the first
const door = await getDoor(id);
const reviews = await getReviews(id);

// 300 ms — both start immediately
const [door, reviews] = await Promise.all([getDoor(id), getReviews(id)]);

// or let each section stream on its own
<Suspense fallback={<Skeleton />}><Reviews id={id} /></Suspense>

Where the data actually is

NeedWhere
Page content, read on the serverIn the server component, awaited
Something after a clickA server action, or a route handler
Live data, polling, infinite scrollA client component with TanStack Query
Third-party API with a secretServer only — the key must never ship

Reading the build output

A route you expected to be static showing as dynamic means something in it read cookies, headers or searchParams — or used no-store. This table is the fastest debugging tool in the framework.

it tells you what you actually built
Route (app)                     Size     First Load JS
┌ ○ /                          1.2 kB          92 kB
├ ● /doors                     2.4 kB          95 kB
└ ƒ /doors/[id]                1.8 kB          94 kB

○  Static     prerendered at build time
●  SSG        prerendered with generateStaticParams
ƒ  Dynamic    server-rendered on demand

Practice