RizTech Academy logo
RizTech Academy
Next.js: Routing and RenderingLesson 3 of 635 min

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-cache with 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

  1. Fetch products in a server component and render them. Confirm no loading state is needed.
  2. Point the fetch at a bad URL. See it not throw, then add the response.ok check.
  3. Fetch two things sequentially and log the total time. Switch to Promise.all and compare.
  4. Add revalidate: 10 and watch the data go stale for ten seconds.
  5. Tag a fetch and call revalidateTag from a form. Confirm it updates immediately.
  6. Call the same fetch in a layout and a page. Confirm your API logs one request.
  7. Wrap a deliberately slow component in Suspense with a skeleton.
  8. Run npm run build and note which routes are static. Add cookies() to one and see it change.
  9. Add generateStaticParams for 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