Command Palette

Search for a command to run...

Hectal
PHASE 20Advanced ~27 min· topic 5 of 7

Topic 20.5

Data and Caching in Next.js

In one line

In Next.js 16 server components await their data directly; with Cache Components enabled you cache functions or components with "use cache", set lifetimes with cacheLife, tag them with cacheTag and refresh them with revalidateTag, updateTag or revalidatePath, while request-time data streams behind <Suspense>, so one route can be part static, part dynamic.

Think of it like this

A school canteen. Some food is cooked in the morning and kept warm all day (static, cached at build time). Some dishes are cooked again every hour (time-based revalidation). When the cook changes the menu, the old dish is thrown away at once (on-demand revalidation). And your own order with extra cheese is made only when you ask (dynamic, per request). A good canteen serves the ready food immediately and brings your special order a moment later (streaming).

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Static rendering
Rendering at build time (or in the background) and serving the saved result to everyone.
Dynamic rendering
Rendering on every request because the result depends on that request.
Runtime API
Something only known when a request arrives: cookies(), headers(), searchParams, unknown params.
Cache Components
The Next.js 16 mode (cacheComponents: true) where you mark cached work with "use cache" and stream the rest.
"use cache"
A directive that caches a function's or component's result, keyed by its arguments.
cacheLife
Sets how long a cached entry stays fresh and when it is refreshed or thrown away.
cacheTag / revalidateTag / updateTag
Label cached entries, then mark them stale (refresh in background) or expire them right away.
Static shell
The prerendered part of a page, served instantly, with fallbacks where dynamic parts will stream in.
Memoization
Remembering a result so the same call in the same render doesn't run twice.

Step by step

01Fetch in an async server component

The page awaits its data. Start independent requests together so they don't form a waterfall.

app/artist/[username]/page.tsxwhole filetsx
import { getArtist, getAlbums } from "@/lib/data";

export default async function Page({ params }: PageProps<"/artist/[username]">) {
  const { username } = await params;
  // start both, then wait for both
  const [artist, albums] = await Promise.all([getArtist(username), getAlbums(username)]);
  return (
    <>
      <h1>{artist.name}</h1>
      <ul>{albums.map((a) => <li key={a.id}>{a.title}</li>)}</ul>
    </>
  );
}

02Turn on Cache Components

One flag in the config switches the app to the Next.js 16 caching model with partial prerendering.

next.config.tswhole filets
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  cacheComponents: true,
};

export default nextConfig;

03Cache data with "use cache", cacheLife and cacheTag

The directive goes at the top of the function body. The tag lets a Server Action refresh it after an edit.

app/lib/data.tswhole filets
import { cacheLife, cacheTag } from "next/cache";
import { db } from "@/lib/db";

export async function getProducts() {
  "use cache";
  cacheLife("hours");   // stale 5 min, revalidate 1 h, expire 1 day
  cacheTag("products");
  return db.product.findMany();
}

04Stream what depends on the request

Code that reads cookies can't be in the static shell. Wrap it in Suspense: its fallback goes in the shell and the real content streams per request. Everything else on the page stays static.

app/blog/page.tsxwhole filetsx
import { Suspense } from "react";
import { cookies } from "next/headers";
import { getPosts } from "@/lib/data"; // "use cache" inside

export default function BlogPage() {
  return (
    <>
      <h1>Our Blog</h1>                                   {/* static */}
      <BlogPosts />                                         {/* cached, in the shell */}
      <Suspense fallback={<p>Loading your preferences...</p>}>
        <UserPreferences />                                 {/* streams per request */}
      </Suspense>
    </>
  );
}

async function BlogPosts() {
  const posts = await getPosts();
  return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;
}

async function UserPreferences() {
  const theme = (await cookies()).get("theme")?.value ?? "light";
  return <p>Your theme: {theme}</p>;
}
Stream what depends on the requestdiagram
Rendering diagram…

05Refresh after a change

After a mutation, expire what changed. updateTag makes the author see their own edit immediately; revalidateTag(tag, "max") is for background refreshes such as a CMS webhook.

app/actions.tswhole filets
"use server";
import { updateTag } from "next/cache";
import { redirect } from "next/navigation";

export async function createProduct(formData: FormData) {
  // ...authenticate, authorise, validate (Topic 20.3)...
  const product = await db.product.create({ data: { name: String(formData.get("name")) } });
  updateTag("products");               // the list is fresh on the next render
  redirect(`/products/${product.id}`); // call after updating the cache
}

Try it yourself

  1. 1

    Different arguments, different entries

    In the cache model, call getPrice("book") after the first read. Predict dbReads. (It goes up: the argument is part of the cache key.)

  2. 2

    Swap revalidateTag for updateTag

    Replace the revalidateTag call with updateTag. Which line changes, and why would an author editing a product prefer this?

  3. 3

    Make the shell wait

    In the streaming model, remove the <Suspense> around UserPreferences. Predict how many chunks arrive. That is what Next.js warns about as a blocking route.

Code & diagrams

A model of "use cache", revalidateTag and updateTag New tab

A plain-JavaScript model of the behaviour described in the Next.js 16 docs, not Next.js itself. The second read hits the cache; after `revalidateTag` one visitor still sees ₹100 while ₹120 loads; after `updateTag` the very next read waits and gets ₹150.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

pen: ₹100 | db reads: 1
pen: ₹100 | db reads: 1
pen: ₹100 (stale, refreshing)
pen: ₹120 | db reads: 2
pen: ₹150 (read-your-own-writes) | db reads: 3
Sequential awaits vs Promise.all New tab

The same rule as Topic 14.1, now inside an async server component: each `await` pauses the function, so independent requests should start before you wait.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Sequential (a waterfall):
  start artist
  end artist
  start albums
  end albums
Parallel (start both, then await):
  start artist
  start albums
  end artist
  end albums
A. R. Rahman [ 'Roja', 'Lagaan' ]
Static shell plus a streamed dynamic hole React 19+ New tab

A model of partial prerendering with real `react-dom/server` streaming: in Next.js the first chunk would be saved at build time and served from a CDN, and only the `UserPreferences` part would be rendered per request.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

static shell: Our Blog | Server Components | Caching in Next.js | Loading your preferences...
streamed hole: Our Blog | Server Components | Caching in Next.js | Your theme: dark
The previous caching model (without Cache Components)tsx
// app/blog/[id]/page.tsx: ISR in the previous model
export const revalidate = 60; // rebuild at most once a minute

export async function generateStaticParams() {
  const posts = await fetch("https://api.example.com/posts", { cache: "force-cache" }).then((r) => r.json());
  return posts.map((p: { id: number }) => ({ id: String(p.id) }));
}

export default async function Page({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const post = await fetch(`https://api.example.com/posts/${id}`, {
    next: { revalidate: 60, tags: ["posts"] }, // cached, refreshed, taggable
  }).then((r) => r.json());
  return <h1>{post.title}</h1>;
}

// elsewhere: export const dynamic = "force-dynamic"; // always render per request

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Read cookies inside "use cache"

In a cached function write "use cache"; const theme = (await cookies()).get("theme");.

terminal
$ next dev / next build
── what you'll see ──
Error: Route /shop used `cookies()` inside "use cache". Accessing Dynamic data sources inside a cache scope is not supported. If you need this data inside a cached function use `cookies()` outside of the cached function and pass the required dynamic data in as an argument. See more info here: …

Break #2

Expect edits to show up in development caching tests

Test cacheLife("hours") with npm run dev and wonder why every refresh shows new data.

terminal
$ Browser
── what you'll see ──
Data is fresh on every reload, as if nothing were cached.

Myth vs fact

Myth

fetch in Next.js is cached by default.

Fact

That was Next.js 13 and 14. Since Next.js 15 fetch is not cached unless you opt in ("use cache", or cache: "force-cache" in the previous model).

Myth

Reading cookies makes the whole page dynamic.

Fact

With Cache Components only the part inside the nearest <Suspense> is rendered per request; the rest stays in the static shell. (In the previous model, cookies() did make the whole route dynamic.)

Myth

revalidatePath and revalidateTag rebuild the page immediately.

Fact

They mark data stale (revalidateTag with stale-while-revalidate). The new version is generated on a following request. Use updateTag in a Server Action when the user must see their own change at once.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Every cache in Next.js is scoped to a deployment: the build id is part of the key, so a new deploy starts with fresh prerenders and empty "use cache" entries. On serverless hosts the default in-memory store is per instance and short-lived; "use cache: remote" moves entries to a shared cache handler, worth it only with a high hit rate.

  • ▸

    "Short-lived" caches (the seconds profile, revalidate: 0, or expire under 5 minutes) are excluded from prerendering and become dynamic holes automatically.

  • ▸

    Push dynamic access down the tree: pass params or searchParams promises to a child inside Suspense instead of awaiting them in a layout, so the layout and everything else stays in the static shell.

  • ▸

    Bots get the whole page rendered at request time (no shell), so data used in the shell must also be reachable at request time, not only during the build.

Remember this

  1. 1

    Fetching in server components: make the component async and await your data with fetch or a database client: const posts = await db.post.findMany(). No useEffect, no loading state in the component itself, and no API route needed. Identical fetch calls in one render are memoized (deduplicated), and for database calls you wrap the function in React's **cache** so several components can call getUser(id) and share one result per request. Watch for waterfalls (Topic 14.1): two awaits in a row run one after the other; start independent requests together with Promise.all, or let separate components fetch in parallel inside their own Suspense boundaries.

  2. 2

    Static vs dynamic rendering. Static (prerendered) means Next.js renders at build time (or in the background later) and serves the saved result to everyone. Dynamic means it renders on each request because the output depends on that request. Runtime APIs make a part dynamic: cookies(), headers(), searchParams, and params that weren't listed in generateStaticParams. Uncached data (a fetch or query without a cache directive) and values that change every call (Date.now(), Math.random(), crypto.randomUUID()) are also request-time work. Since Next.js 15, fetch is not cached by default.

  3. 3

    Cache Components (Next.js 16). Turn it on with cacheComponents: true in next.config.ts. Then every route is partially prerendered: at build time Next.js renders a static shell (everything that can finish without a request, plus Suspense fallbacks), and the dynamic parts stream in at request time (Topic 20.1). Next.js asks you to be explicit: request-time work must either be cached with "use cache" or **wrapped in <Suspense>**; otherwise the dev overlay and the build report a "blocking route" and suggest the fix. For a value that must be unique per request, call await connection() (from next/server) first and wrap the component in Suspense.

  4. 4

    **"use cache"** caches the return value of an async function (data level: getProducts()) or a whole component or page (UI level), and "use cache" at the top of a file caches all its exports. The function's arguments and captured variables become the cache key, so getUser("1") and getUser("2") are cached separately, and arguments must be serializable. You can't read cookies() or headers() inside a "use cache" scope: read them outside and pass the value in as an argument (or use the "use cache: private" variant). Cached output becomes part of the static shell when its lifetime is long enough.

  5. 5

    Lifetimes and revalidation. cacheLife("hours") (from next/cache) sets how long an entry is used: built-in profiles are default, seconds, minutes, hours, days, weeks and max, each with stale, revalidate and expire times (for hours: 5 minutes, 1 hour, 1 day), or pass your own { stale, revalidate, expire }. Time-based revalidation refreshes an entry after revalidate. On-demand revalidation after a change: cacheTag("posts") labels an entry; revalidateTag("posts", "max") marks it stale (the next visitor gets the old data while fresh data loads, stale-while-revalidate), usable in Server Actions and Route Handlers; updateTag("posts") expires it immediately so the user who just made a change sees it (read-your-own-writes), only in Server Actions; revalidatePath("/blog") invalidates everything for a path; refresh() re-renders the current page without touching cached data.

  6. 6

    Without Cache Components (the previous model, still common in existing apps): cache a request with fetch(url, { cache: "force-cache" }), revalidate it with { next: { revalidate: 3600 } } or tag it with { next: { tags: ["posts"] } }; set a whole page's default with export const revalidate = 60 (this is ISR together with generateStaticParams) or force request-time rendering with export const dynamic = "force-dynamic"; unstable_cache caches non-fetch functions. The build output shows the result per route: ○ static, ● SSG, ◐ partial prerender (Cache Components), ƒ dynamic. In development, pages are always rendered on demand, so check caching with next build and next start.

Explain it without notes

01

How do you fetch data in a Next.js server component, and how do you avoid waterfalls?

02

What makes part of a route dynamic, and how does Cache Components handle it?

03

Explain "use cache", cacheLife and cacheTag. What is part of the cache key?

04

Compare revalidateTag, updateTag and revalidatePath.

Practice

01

Write a cached getCategory(slug) that lasts a day and can be refreshed by the tag categories.

02

A product page shows product details (same for everyone) and "Recently viewed" (from a cookie). Write the page so the details are in the static shell.

Trade-offs

  • ↔

    Longer cache lifetimes mean faster, cheaper pages but staler data; tags with on-demand revalidation give both, at the cost of remembering to invalidate after every mutation.

  • ↔

    Caching UI (a whole component) saves the most work but is the coarsest; caching data functions is more reusable across pages.

Done when you can

  • I can fetch data in async server components without waterfalls.

  • I can tell which code makes a route dynamic and wrap it in Suspense.

  • I can use "use cache", cacheLife and cacheTag.

  • I can choose between revalidateTag, updateTag, revalidatePath and refresh.

  • I can read the build route table symbols.

  • I know the previous model's fetch options and revalidate/dynamic exports.