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.

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 { unstable_cacheLife as cacheLife } from "next/cache";

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

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

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

const config: NextConfig = {
  experimental: {
    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;

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 { unstable_cacheTag as 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);        // invalidate just this post
  // revalidateTag("post");            // ... or every post-tagged cache
}

updateTag: read-your-writes inside actions

revalidateTagmarks 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: invalidate the cache so future requests refetch.
  revalidateTag("comments:" + postId);
}
Two-step refresh
Think of updateTagas "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 { unstable_cacheTag as cacheTag, unstable_cacheLife as 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.

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.
  • cacheLife sets freshness; built-in profiles areseconds, minutes, hours, days, weeks, 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.
Built with Next.js, Tailwind & Sandpack.
Learn. Build. Ship.