Next.js & the Network
Next.js & the Network

Next.js caching & revalidation: Cache Components

Lesson · ~45 min
After this unit you can
  • Explain the Next.js 16 default (with cacheComponents, nothing is cached until use cache) and what changed from Next 14 and 15
  • Write a use cache function with cacheLife and cacheTag, reading cookies()/headers() outside the cached scope and passing values in as arguments
  • Choose updateTag, revalidateTag(tag, "max"), revalidatePath or refresh after a write, from where it runs and who must see the change
  • Predict what a cacheLife profile does over time: stale (client router), revalidate (background regeneration), expire (blocking), defaults and nesting
  • Explain why a local next start doesn't prove how production caches, and read cache behaviour on the real platform instead

Read

Simple
1 / 2

In Next.js 16, nothing is cached until you say so

Flag

This unit is about Next's own caches: results stored inside the Next.js server and the browser's router, keyed by function and arguments. The previous unit's HTTP headers are a separate layer that Next also writes.

Next.js 16 introduced Cache Components, switched on with one flag:

// next.config.ts
const nextConfig: NextConfig = { cacheComponents: true };

(Since Next 16.3 the docs also set partialPrefetching: true next to it. That option changes what <Link> prefetches, not what the server caches, and leaving it unset logs a warning.)

With it on, data work in a page, layout or GET Route Handler (a database query, a fetch, cookies()) runs at request time by default. Caching is opt-in, with a directive:

import { cacheLife } from "next/cache";

export async function getProducts() {
  "use cache";
  cacheLife("hours");
  return db.product.findMany();
}

The first call runs the query and stores the result; later calls with the same arguments get the stored result until its lifetime runs out. Arguments are part of the key, so a getProduct(id) written the same way stores getProduct("a") and getProduct("b") as separate entries.

If you learned Next on an older version, your habits point the other way:

Next 14Next 15Next 16 + cacheComponents
fetch() in a Server Componentcached by defaultnot cached by defaultnot cached; wrap the work in use cache
GET Route Handlerstatic by defaultdynamic by defaultrequest time, unless it touches nothing uncached (then prerendered)
How you opt infetch options, unstable_cache, export const revalidatesame, plus experimental use cacheuse cache, cacheLife, cacheTag (stable)

Two consequences:

  • Old tutorials are wrong for new code. export const revalidate = 3600 and fetch(url, { next: { revalidate } }) belong to the previous model. Under Cache Components the migration guide replaces segment configs like revalidate and dynamic with use cache plus cacheLife.
  • The flag matters. A Next 16 app without cacheComponents still runs the previous model (fetch not cached by default, cache: 'force-cache', unstable_cache, segment config). Check next.config before you reason about a codebase.

Medium
1 / 2

A cached product page with a personal cart, line by line

Flag

A product page every visitor shares, a cart badge that is different for each of them, and an admin edit that must show up at once. Next.js 16, cacheComponents: true.

// lib/products.ts
import { cacheLife, cacheTag } from "next/cache";

export async function getProduct(id: string) {
  "use cache";
  cacheLife("days");
  cacheTag("products", `product-${id}`);
  return db.product.findUnique({ where: { id } });
}
// app/products/[id]/page.tsx
import { Suspense } from "react";
import { cookies } from "next/headers";
import { notFound } from "next/navigation";
import { getProduct, getBestSellerIds } from "@/lib/products";

export async function generateStaticParams() {
  return (await getBestSellerIds()).map((id) => ({ id })); // prerender these at build
}

export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const product = await getProduct(id);
  if (!product) notFound();
  return (
    <main>
      <h1>{product.name}</h1>
      <Suspense fallback={<span>Cart…</span>}>
        <CartBadge />
      </Suspense>
    </main>
  );
}

async function CartBadge() {
  const cartId = (await cookies()).get("cart")?.value;
  if (!cartId) return <span>Cart (0)</span>;
  return <span>Cart ({await getCartCount(cartId)})</span>;
}
// app/admin/products/actions.ts
"use server";
import { updateTag } from "next/cache";

export async function renameProduct(id: string, name: string) {
  await requireAdmin(); // actions are public endpoints
  await db.product.update({ where: { id }, data: { name } });
  updateTag(`product-${id}`);
}

Why each piece is the way it is:

  • generateStaticParams. Under Cache Components, params counts as request-time data unless at least one sample value is known at build. Listing the best sellers lets Next prerender those pages, and makes await params legal outside <Suspense>.
  • "use cache" on the data function, not the page. The product is shared by everyone; the cart isn't. Caching at the data level lets one page mix both.
  • id is an argument, so it's in the cache key. Each product gets its own entry. A value the function closes over from an outer scope would join the key too.
  • cacheLife("days") makes the expected lifetime readable at the call site instead of inherited from the default profile (15-minute revalidate).
  • Two tags. product-${id} invalidates one product; products invalidates every list and page that shows any product.
  • cookies() lives in CartBadge, outside any cached scope, inside <Suspense>. A use cache function can't call cookies() or headers() (Next throws). Read them outside, and if a cached helper needs the value, pass it in as an argument, where it becomes part of the key.
  • <Suspense> around the badge lets the shared part render ahead of time while the badge waits for the request. Without it, Next 16's build fails with a blocking-route error: runtime data (cookies()) was accessed outside of <Suspense>.
  • updateTag in the Server Action, not revalidateTag: the admin expects to see the new name on the next render. updateTag expires the entry so the next read waits for fresh data.

Complex
1 / 2

What goes into the cache key, and what can't go into a cached scope

Flag

A use cache entry's key is built from the build ID, a function ID (a hash of where the function lives), and its serialisable inputs: arguments for a function, props for a component, plus any variable it closes over from an outer scope. Most cache bugs are a wrong guess about that last part.

Closures are keys.

async function Orders({ userId }: { userId: string }) {
  const load = async (status: string) => {
    "use cache";
    return db.order.findMany({ where: { userId, status } }); // userId is captured
  };
  return <List orders={await load("open")} />;
}

userId comes from the closure, so it's part of load's key: user A and user B get separate entries. That is how you cache per-user data safely. The dangerous version is a value the key can't see. React.cache storage set outside the scope isn't visible inside it (a use cache scope has its own), and a module-level Map shared between cached and uncached code can hand the cached code a promise that never resolves during the build (the docs show this hanging the build until it times out).

Request data stays outside. cookies(), headers() and searchParams are per request; a cached result is shared. Calling cookies() inside a use cache scope throws. ('use cache: private' does allow it, because its results are never kept in a server cache across requests, only in the browser's router cache; Next 16.2 still marks it experimental.) Passing the cookies() promise in as a prop is worse: during next build the cached function waits on request data that doesn't exist, and the build hangs until "Filling a cache during prerender timed out". Await it outside and pass the value:

async function Prices() {
  const currency = (await cookies()).get("currency")?.value ?? "USD"; // outside: per request
  return <PriceList currency={currency} />;                           // a string, in the key
}

async function PriceList({ currency }: { currency: string }) {
  "use cache";
  cacheLife("hours");
  return renderPrices(await getPrices(currency));
}

Only serialisable things cross in. Arguments follow React's Server Component rules (no class instances, no functions you call). children and Server Actions may be passed through a cached component as long as it doesn't look inside them, which is how a cached shell can wrap a dynamic part.

Nested lifetimes. An outer use cache without its own cacheLife runs on default (15 minutes), and an inner cache with a shorter revalidate silently shortens it; a longer one can't lengthen it. With an explicit cacheLife on the outer scope, the outer value wins either way. The docs recommend always writing it, so a cache's lifetime can be read where it's declared.

Quiz

Next.js 16 with cacheComponents: true. A Server Component awaits a database query and has no use cache anywhere above it. When does the query run?

Flag

A page written for Next 14 reads searchParams (so it renders on every request) and calls fetch(url) with no options, relying on Next 14 serving that fetch from its Data Cache. After upgrading to Next 15 or 16 (without Cache Components), what happens?

Flag

A use cache function needs the visitor's currency, stored in a cookie. What's the right shape?

Flag

Inside async function Orders({ userId }), a nested const load = async (status) => { "use cache"; return db.orders(userId, status) } is called. Do two users share one cached result?

Flag

In a Server Action, a user saves their display name, then redirect("/profile"). The profile reads data tagged user-42. Which call guarantees the redirected page shows the new name?

Flag

A CMS calls your Route Handler POST /api/cms-webhook when an article is published. How should it invalidate post-<slug>?

Flag

/blog and /dashboard both show posts fetched with the posts tag. A Server Action calls revalidatePath("/blog"). What does /dashboard show on its next visit?

Flag

getProducts() uses cacheLife("hours") (revalidate 1 hour, expire 1 day) and was cached at 09:00. Traffic is steady. What does a request at 10:10 get?

Flag

The build output lists /sitemap.xml with a 4-hour revalidate, and on next start it regenerates on schedule. Someone reports production serving yesterday's sitemap. What settles it?

Flag

Fill in the blank

Turn on Next.js 16's opt-in caching model.

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

Cache this query for hours and tag it so a write can invalidate it.

Flag
import { cacheLife, cacheTag } from "next/cache";

export async function getProducts() {
  
  cacheLife("hours");
  ("products");
  return db.product.findMany();
}

Invalidate the same tag from a Server Action (the writer must see it at once) and from a CMS webhook Route Handler.

Flag
// app/admin/actions.ts — Server Action
("products");

// app/api/cms-webhook/route.ts — Route Handler
revalidateTag("products", "");

Read the cookie outside the cache and hand the cached component a plain value.

Flag
async function Prices() {
  const currency = ( cookies()).get("currency")?.value ?? "USD";
  return <PriceList currency={currency} />;
}

async function PriceList({ currency }: { currency: string }) {
  
  return renderPrices(await getPrices(currency));
}

Predict the output

"use server";
export async function createPost(formData: FormData) {
  await db.post.create({ data: { title: String(formData.get("title")) } });
  revalidateTag("posts", "max");
  redirect("/posts"); // /posts lists posts read with cacheTag("posts")
}
Flag
async function Widget() {
  "use cache";
  cacheLife("hours"); // revalidate: 1 hour
  return <Stats data={await getStats()} />;
}

export default async function Dashboard() {
  "use cache"; // no cacheLife here
  return <main><h1>Dashboard</h1><Widget /></main>;
}
Flag
// app/feed/page.tsx
export default async function Feed() {
  "use cache";
  cacheLife({ stale: 300, revalidate: 60, expire: 3600 });
  return <FeedList items={await getFeed()} />;
}

// The user opens /feed, clicks a <Link> to /about,
// and 2 minutes later clicks a <Link> back to /feed.
Flag

Further reading