webdev.complete
📦 Caching, Metadata, Images, Fonts
🚀Next.js & T3
Lesson 95 of 117
30 min

Caching in Next 16

Cache Components, 'use cache', revalidateTag, updateTag.

Next.js used to cache everything by default. Then nothing by default. Then it tried to thread a middle path with implicit rules nobody could remember. Next 16 finally fixes it with Cache Components: caching is opt-in, expressed with a directive next to your code, and refreshed by tags or explicit updates. If you've been confused by Next caching in the last two years, this is the lesson where it stops being confusing.

The mental model: cached or dynamic, you choose

In Cache Components, every async function or component is either cached or dynamic. Cached means the result is stored and reused across requests. Dynamic means it runs fresh on every request. You opt into cached behavior with the "use cache" directive. Without it, the default is dynamic.

app/posts/page.tsx
async function getPosts() {
  "use cache";          // this function's result is cached
  const res = await fetch("https://api.example.com/posts");
  return res.json();
}

export default async function PostsPage() {
  const posts = await getPosts();   // hits cache; refetches when invalidated
  return <PostList posts={posts} />;
}
Where can you put the directive?
"use cache" can sit at the top of a file, the top of a function, or even inline in an arrow function body. Pick the smallest scope you actually want cached. The function has to be async.

Turn it on first

None of the code in this lesson does anything until you enable the flag. Cache Components is opt-in at the project level:

next.config.ts
import type { NextConfig } from "next";

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

export default nextConfig;
It used to be called dynamicIO
If you find a tutorial that sets experimental.dynamicIO, it predates the rename. The flag is cacheComponents and it sits at the top level of the config, not under experimental.

cacheLife: how long is it good for?

Caches need a freshness policy. cacheLife sets two things: how often Next refreshes in the background, and how long stale content is allowed to be served while the refresh runs. Use one of the built-in profiles, or pass a config.

tsx
import { cacheLife } from "next/cache";

async function getPrices() {
  "use cache";
  cacheLife("hours");        // built-in profile
  // built-in names: seconds, minutes, hours, days, weeks, default, max
  return fetchPrices();
}

You can also configure custom profiles in next.config.ts:

next.config.ts
import type { NextConfig } from "next";

const config: NextConfig = {
  cacheComponents: true,
  cacheLife: {
    pricing: {
      stale: 60,         // serve cached for 60s without revalidating
      revalidate: 300,   // refresh every 5 min in background
      expire: 3600,      // hard expiry after 1 hour
    },
  },
};

export default config;

You can redefine the built-in names too, including default, which is the profile a use cache scope gets when it never calls cacheLife. Next generates the type signature for cacheLife from your config during next dev and next build, so autocomplete shows your numbers rather than the presets.

cacheTag: invalidate by name

Tag a cached function with strings, and later you can blow away every cache entry sharing that tag from anywhere in the app. This is the killer feature: you don't have to know URLs to invalidate.

tsx
import { cacheTag } from "next/cache";

async function getPost(id: string) {
  "use cache";
  cacheTag("post", "post:" + id);
  return db.post.findUnique({ where: { id } });
}

When a mutation runs (Server Action, webhook, anything), you invalidate by tag:

app/actions.ts
"use server";

import { revalidateTag } from "next/cache";

export async function editPost(id: string, data: PostInput) {
  await db.post.update({ where: { id }, data });
  revalidateTag("post:" + id, "max");   // invalidate just this post
  // revalidateTag("post", "max");       // ... or every post-tagged cache
}
The second argument is not optional any more
revalidateTag(tag) with one argument is deprecated and raises a TypeScript error. Pass a cache profile. "max" is the recommended one: it marks the entry stale and serves the stale copy while a fresh one is fetched in the background. For a webhook that must expire something immediately, pass { expire: 0 } instead.

One consequence worth internalising: with "max", nothing revalidates at the moment you call it. The entry is only refetched the next time someone visits a page using that tag, which is what stops one call from stampeding every cached route at once.

updateTag: read-your-writes inside actions

revalidateTag marks the cache stale for the next request. But what if the user expects to see their own write immediately, in the same request that mutated? That's what updateTag is for. Inside a Server Action, updateTag evicts the cached entry and forces the following await on the same tag to recompute.

app/comments/actions.ts
"use server";

import { updateTag, revalidateTag } from "next/cache";

export async function addComment(postId: string, body: string) {
  await db.comment.create({ data: { postId, body } });

  // For the current action&apos;s response: read-your-writes guarantee.
  updateTag("comments:" + postId);

  // For everyone else: mark it stale so future requests refetch.
  revalidateTag("comments:" + postId, "max");
}
Two-step refresh
Think of updateTag as "refresh now for the person who clicked the button" and revalidateTag as "refresh later for everyone else." Most mutating actions call both.

Cached components, not just functions

A whole component can be cached. Drop the directive at the top of the component body and Next caches its JSX output, including every server-only async call inside it.

app/marketing/featured.tsx
async function FeaturedProducts() {
  "use cache";
  cacheTag("featured");
  cacheLife("hours");
  const products = await db.product.findMany({ where: { featured: true } });
  return (
    <ul>{products.map((p) => <li key={p.id}>{p.name}</li>)}</ul>
  );
}

Partial Prerendering: static shell, dynamic islands

Cache Components are the engine behind Partial Prerendering (PPR). Your page can be mostly cached HTML (header, product grid, footer) with a couple of dynamic holes (current user's cart count, personalized banner). The static parts ship instantly from the edge; the dynamic parts stream in. You opt into dynamism with <Suspense> boundaries wrapping the dynamic pieces.

app/page.tsx
import { Suspense } from "react";

export default function Home() {
  return (
    <>
      <StaticHero />          // cached, ships as HTML
      <Suspense fallback={<CartSkeleton />}>
        <CartCount />          // dynamic, streamed
      </Suspense>
      <Footer />               // cached
    </>
  );
}

Migrating from unstable_cache

If your code uses unstable_cache (the older API), here's the swap. The new directive is simpler and composable.

Before (unstable_cache)
import { unstable_cache } from "next/cache";

const getUser = unstable_cache(
  async (id: string) => db.user.findUnique({ where: { id } }),
  ["user"],
  { tags: ["user"], revalidate: 60 },
);
After (use cache)
import { cacheTag, cacheLife } from "next/cache";

async function getUser(id: string) {
  "use cache";
  cacheTag("user", "user:" + id);
  cacheLife("minutes");
  return db.user.findUnique({ where: { id } });
}
Don't cache personal data
Caches are shared across users by default. If a function returns anything personalized (the current user's name, cart, role), either include the user ID in the tag, or don't cache it. Leaking another user's data into the cache is a classic early bug.

Two other flavours: private and remote

Plain use cache stores entries in the server's memory and refuses to let you touch cookies(), headers(), or searchParams inside the cached scope. The preferred fix is to read that runtime data outside the cached function and pass it in as an argument. When that refactor isn't practical, there are two variants.

"use cache: private" lets the cached function read request APIs. The result is never written to the server; it lives in the browser's memory for that one page view and is gone after a reload. Because it depends on request data, it runs on every server render and is skipped when Next prerenders the static shell.

tsx
import { cookies } from "next/headers";

async function Recommendations() {
  "use cache: private";
  const locale = (await cookies()).get("locale")?.value;
  return renderPicks(await getPicks(locale));
}

"use cache: remote" goes the other way. In-memory entries get evicted under memory pressure and are not shared between instances, so a busy deployment can hammer your CMS harder than you expect. The remote variant hands storage to a cache handler your platform provides, which persists entries and shares them across instances. The trade is a network roundtrip on every cache check, and most platforms bill for it.

Pick the boring one first
Reach for plain use cache and pass runtime values as arguments. Only move to private when a compliance rule or an awkward call site blocks that, and only to remote when you have measured upstream requests you cannot explain.

fetch is no longer magically cached

In old Next, fetch() was cached by default. That behavior is gone. Now fetch() is just fetch. If you want the result cached, wrap the call in a function that opts into "use cache". Less magic, more clarity.

Quiz

Quiz1 / 3

How do you cache a function in Next 16 Cache Components?

Recap

  • Cache Components are opt-in. Add "use cache" where you want caching.
  • Enable it with cacheComponents: true in next.config.ts, or nothing here applies.
  • cacheLife sets freshness; built-in profiles areseconds, minutes, hours, days, weeks, default, and max.
  • cacheTag labels entries; revalidateTag invalidates them across requests.
  • updateTag gives read-your-writes inside the action that mutated.
  • Partial Prerendering ships the cached shell instantly and streams dynamic <Suspense> holes.
  • fetch() is no longer magically cached; opt in explicitly.
  • "use cache: private" reads cookies and headers but stores nothing on the server. "use cache: remote" persists entries in a platform cache handler at the cost of a network hop.
Built with Next.js, Tailwind & Sandpack.
Learn. Build. Ship.