Skip to main content
ANVISoftware Solutions
Lesson 14 of 18Intermediate18 min

Data Fetching and Caching

By the end of this lesson

Fetch on the server and control how results are cached.

A server component can await data before any HTML is sent, which removes the loading state, the effect and the extra round trip. What replaces them is a decision: how long may this result be reused, and when should it be recomputed?

That decision is caching, and it is the part of Next.js that causes the most confusion, because the same page can be built once at deploy time, rebuilt periodically, or built fresh for every request — and the difference comes from small options in your fetch calls rather than from anything visible in the markup.

Note before the examples: caching defaults have changed across Next.js versions. In current versions a fetch is not cached unless you ask for it. Check the behaviour for the version in your own project rather than trusting a sample found online.

Three fetches with three different caching intentions
TSX
// Cached until you invalidate it. Suitable for data that
// changes when someone edits it, not on a timer.
async function getDepartments() {
  const response = await fetch("https://api.example.com/departments", {
    cache: "force-cache",
    next: { tags: ["departments"] },
  });
  return response.json();
}

// Re-fetched at most once every five minutes. A stale result for up to
// five minutes is acceptable for a directory listing.
async function getEmployees() {
  const response = await fetch("https://api.example.com/employees", {
    next: { revalidate: 300 },
  });
  return response.json();
}

// Never cached. Correct for anything per-user or genuinely live.
async function getMyPendingApprovals(userId: string) {
  const response = await fetch(
    "https://api.example.com/approvals?userId=" + userId,
    { cache: "no-store" }
  );
  return response.json();
}
  • cache: "force-cache" stores the result and reuses it indefinitely. The tag gives you a handle for clearing it later, which is what makes this safe to use for data that changes.
  • next: { revalidate: 300 } sets a time window. After five minutes the next request triggers a refresh in the background, so no user waits for it — which also means one user may see the previous result.
  • cache: "no-store" opts out. Use it for anything that depends on who is asking, and for data where being a minute out of date would be wrong.
  • Any fetch marked no-store makes the route dynamic, which is covered below. This is the most common reason a page a team expected to be static turns out not to be.
  • These options belong to the fetch, not to the page, so one page can combine a cached department list with an uncached approvals count.

Static and dynamic rendering. This is the trade-off that everything else in this lesson serves:

 Static renderingDynamic rendering
When the HTML is producedAt build time, or on the first request and then reusedOn every request
What the user waits forA file being served — fast and predictableYour data source, every time
FreshnessAs fresh as the last build or revalidationCurrent as of this request
Load on your databaseIndependent of trafficProportional to traffic
Can it be personalised?No — every visitor gets the same HTMLYes — it can read cookies, headers and the signed-in user
FitsCourse catalogues, lesson content, marketing pages, public listingsA signed-in dashboard, search results, anything showing the user's own data
generateStaticParams: building a page per course at build time
TSX
import { notFound } from "next/navigation";
import { getAllCourseSlugs, getCourseBySlug } from "@/data/courses";

// Tells Next.js which values of [slug] exist, so it can render
// each one during the build instead of on demand.
export async function generateStaticParams() {
  const slugs = await getAllCourseSlugs();
  return slugs.map((slug) => ({ slug }));
}

export default async function CoursePage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const course = await getCourseBySlug(slug);

  if (!course) notFound();

  return (
    <article>
      <h1>{course.title}</h1>
      <p>{course.shortDescription}</p>
      <CourseOutline modules={course.modules} />
    </article>
  );
}
  • The returned array is one object per page, with keys matching the dynamic segments. Twelve courses means twelve pages rendered during the build.
  • The property values must be strings. A number will not do, even for an id — convert it.
  • A slug that was not returned is still handled: by default Next.js renders it on demand the first time it is requested, then keeps the result. That is what makes this workable for content that grows after a deploy.
  • The notFound() call still matters. generateStaticParams lists what exists now; it does not stop anyone typing a URL that does not.
  • This pays off most for content with many pages and few writers. A catalogue of courses fits; a page showing each user's own progress does not, because there is nothing stable to build.

Invalidation: how cached data gets updated before its time is up.

Time-based revalidation
next: { revalidate: seconds } on a fetch, or an exported revalidate value for a whole route. Simple, and always a guess about how stale is acceptable.
Tag-based revalidation
Tag a fetch with next: { tags: ["employees"] }, then call revalidateTag("employees") after a change. The cache clears when the data actually changes rather than on a timer.
Path revalidation
revalidatePath("/employees") clears a specific route. Useful when you know which page is affected but the data came from somewhere untagged.
Where invalidation is called from
A server action or a route handler — the code that performs the change. Adding an employee and then clearing the employees tag in the same function keeps the two from drifting apart.
What forces a route to be dynamic
Reading cookies or headers, using searchParams, or any fetch with no-store. Once one of those is present, the route is rendered per request whatever else it does.
Newer caching model
Recent Next.js versions add an opt-in model built around a "use cache" directive, where caching is declared on a function or component rather than inferred from fetch options. It is worth reading about for a new project; the options above remain supported.

Summary

  • A server component awaits data directly, so there is no loading state and no extra round trip
  • Caching is set per fetch: force-cache to store it, revalidate for a time window, no-store to opt out
  • Static rendering produces HTML ahead of time; dynamic rendering produces it per request and can be personalised
  • generateStaticParams renders a page per known value of a dynamic segment at build time
  • Tag-based invalidation updates data when it changes, which beats guessing a timer
  • One no-store fetch or one cookie read makes the whole route dynamic

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Try it yourself

A course catalogue page lists every course. Courses change a few times a month, when someone publishes one.

Choose a caching approach, write the fetch, and write the invalidation. Then explain what the user sees in the minute after a new course is published.

Show solution

Tag the fetch and clear the tag when a course is published. The catalogue is then served from cache almost always, and updates within moments of a change rather than on a timer.

What the user sees: before publishing, the cached catalogue. The publish action writes the course and clears the tag, so the next request renders fresh HTML that includes it. There is no window of staleness to explain, which is the advantage over a revalidate window.

A time-based alternative — revalidate: 3600 — is simpler and needs no coordination with the publishing code. It costs up to an hour of staleness, and for a marketing catalogue that may be perfectly acceptable. Choose it when the writing path is outside your control, such as content edited in a third-party system with no webhook.

What would be wrong here is no-store. The catalogue is identical for every visitor and changes a few times a month, so rendering it per request means thousands of identical queries to produce the same HTML.

TypeScript
// data/courses.ts
export async function getCourses() {
  const response = await fetch("https://api.example.com/courses", {
    cache: "force-cache",
    next: { tags: ["courses"] },
  });
  if (!response.ok) throw new Error("Could not load courses");
  return response.json() as Promise<Course[]>;
}

// app/admin/courses/actions.ts
"use server";

import { revalidateTag } from "next/cache";

export async function publishCourse(formData: FormData) {
  await saveCourse(formData);
  // Clear the cache for everything tagged "courses" — the catalogue
  // updates on the next request rather than waiting for a timer.
  revalidateTag("courses");
}

Think about it

Think about it

An employee directory page has three pieces of data: the department list, which changes twice a year; the employee list, which changes weekly; and the signed-in user's pending approvals, which must be current.

How should each be fetched, and what does the presence of the third one do to the page?

Show solution

Departments: cached with a tag, cleared when a department is added. Employees: tagged as well, cleared on any change, or a revalidate window of an hour if the writing side is not yours. Approvals: no-store, because they are per-user and must be current.

The third one makes the whole route dynamic. Once the page reads something per-request, the HTML cannot be produced in advance, so the department and employee caches no longer save you a render — though they do still save the two upstream requests, which is worth having.

If that matters, the usual restructuring is to keep the page static and move the approvals into their own boundary, so the shared part of the page can be prerendered while the personal part is filled in per request. Suspense around the dynamic component is the mechanism, and recent Next.js versions make this pattern the centre of their caching model.

The question worth asking first is whether the approvals count needs to be on this page at all. A number in the navigation that updates on the client, or a separate approvals page, keeps the directory static and simple. Architecture is often cheaper than configuration.

Saved in this browser only.