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.
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.
// 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
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";
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.
"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.
// 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
| Need | Where |
|---|---|
| Page content, read on the server | In the server component, awaited |
| Something after a click | A server action, or a route handler |
| Live data, polling, infinite scroll | A client component with TanStack Query |
| Third-party API with a secret | Server 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.
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