Fetching data, caching and revalidation
Server components can be async, which makes fetching data almost anticlimactic
— you await it. The interesting part is caching: what Next.js remembers, for how
long, and how you tell it something has changed.
Fetching
export default async function ProductsPage() {
const response = await fetch(`${process.env.API_URL}/products`);
const products: Product[] = await response.json();
return <ProductGrid products={products} />;
}
No useEffect, no loading state, no useState. The component waits, and the
HTML arrives complete.
Compare with the client version from the effects lesson: eleven lines, two pieces of state, two round trips before anything appears, and an empty page for crawlers. This is the improvement the whole App Router is built around.
Always handle failure:
const response = await fetch(`${process.env.API_URL}/products`);
if (!response.ok) {
throw new Error(`Failed to load products: ${response.status}`);
}
fetch does not throw on a 404 or 500. It only rejects on a network
failure, so an unchecked response.json() on an error page gives you a confusing
parse error instead of a useful one. Check response.ok every time.
Throwing is the right move here — error.tsx catches it, which is the next
lesson.
Fetching in parallel
// Sequential: 200ms, then another 200ms
const product = await getProduct(slug);
const reviews = await getReviews(slug);
// Parallel: 200ms total
const [product, reviews] = await Promise.all([
getProduct(slug),
getReviews(slug),
]);
Two awaits on separate lines run one after the other. That is fine when the
second needs the first and wasteful when it does not. Promise.all is one of
the cheapest wins available.
Promise.allSettled when one failing should not lose the others — reviews
failing should not take down the product page.
Caching
This is where Next.js surprises people.
By default in Next.js 15, fetch is not cached — every request hits your
API. You opt into caching:
// Cached indefinitely until revalidated
fetch(url, { cache: "force-cache" });
// Cached, refreshed at most every 60 seconds
fetch(url, { next: { revalidate: 60 } });
// Never cached
fetch(url, { cache: "no-store" });
Earlier versions cached aggressively by default, which caught a lot of people out. Check which version a tutorial was written for — this specific default has changed more than once, and stale advice here produces either a stale shop or a very expensive one.
What to use where:
- Product listings —
revalidate: 60. A minute of staleness is fine and it takes enormous load off the API. - A cart or an order —
no-store. Always current, always per-user. - Static content —
force-cachewith a tag.
Tags
Time-based revalidation means waiting. Tags let you invalidate on demand:
fetch(`${API}/products`, { next: { tags: ["products"] } });
"use server";
import { revalidateTag } from "next/cache";
export async function updateProduct(id: string, data: FormData) {
await fetch(`${API}/products/${id}`, { method: "PATCH", body: data });
revalidateTag("products");
}
The shop owner edits a price and every page tagged products refreshes on next
request. No waiting, no full rebuild. For an admin area this is exactly right.
revalidatePath("/products") does the same for one route.
Not everything is fetch
Prisma queries and other non-fetch work are not cached by fetch options. Use
unstable_cache:
import { unstable_cache } from "next/cache";
export const getCategories = unstable_cache(
async () => db.category.findMany(),
["categories"],
{ revalidate: 3600, tags: ["categories"] }
);
The name warns that the API may change. It is widely used and the concept is stable even if the export name moves.
Request deduplication
// layout.tsx
const user = await getCurrentUser();
// page.tsx
const user = await getCurrentUser();
Two components, one request. React deduplicates identical fetch calls within a
single render pass, so you can fetch where the data is needed rather than
threading it down through props.
This only applies within one render. It is not a cache across requests.
Streaming
A slow section need not delay the whole page:
import { Suspense } from "react";
export default async function ProductPage({ params }: Props) {
const { slug } = await params;
const product = await getProduct(slug);
return (
<>
<ProductSummary product={product} />
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews slug={slug} />
</Suspense>
</>
);
}
The product renders immediately; the reviews stream in when ready, with a skeleton in the meantime.
Note that getProduct is awaited outside the Suspense, so the page still
waits for it. Only what is inside the boundary streams. Put the fast, essential
content outside and the slow, secondary content inside.
Static, dynamic and the accidental switch
Next.js renders a route statically at build time when it can, and per-request when it must.
Certain things force dynamic rendering: cookies(), headers(), searchParams,
and cache: "no-store".
This catches people. A product page that could have been static becomes per-request because something deep inside read a cookie. Symptom: a page that was instant becomes slow, with no obvious cause.
For a shop, dynamic is often correct — prices and stock change. Just know which you have:
npm run build
The output marks each route ○ static or ƒ dynamic. Read it after a
build. It is the quickest way to notice you have accidentally made your
storefront uncacheable.
For pages that should be static with known parameters:
export async function generateStaticParams() {
const products = await getProducts();
return products.map((p) => ({ slug: p.slug }));
}
Every product page is then built ahead of time — fast and cheap to serve.
Check your work
Why response.ok must be checked: fetch does not throw on a 404 or 500,
so an unchecked json() gives a confusing parse error instead of a useful one.
Why two awaits on separate lines are slower: they run sequentially.
Promise.all runs them together.
The caching default in Next.js 15: fetch is not cached unless you ask.
Earlier versions cached aggressively, which is why old tutorials mislead.
What to use where: revalidate: 60 for product listings, no-store for
carts and orders, force-cache with a tag for static content.
What tags give you: invalidation on demand with revalidateTag, rather than
waiting for a time window.
Why the same fetch in a layout and a page makes one request: React deduplicates identical fetches within a render pass.
What Suspense streams: only what is inside the boundary. Anything awaited
outside still delays the page.
What forces dynamic rendering: cookies(), headers(), searchParams, and
no-store. Check the build output to see which routes are which.
Practice
- Fetch products in a server component and render them. Confirm no loading state is needed.
- Point the fetch at a bad URL. See it not throw, then add the
response.okcheck. - Fetch two things sequentially and log the total time. Switch to
Promise.alland compare. - Add
revalidate: 10and watch the data go stale for ten seconds. - Tag a fetch and call
revalidateTagfrom a form. Confirm it updates immediately. - Call the same fetch in a layout and a page. Confirm your API logs one request.
- Wrap a deliberately slow component in
Suspensewith a skeleton. - Run
npm run buildand note which routes are static. Addcookies()to one and see it change. - Add
generateStaticParamsfor product pages and confirm they prerender.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship