Skip to main content
Web & Performance

Next.js 16 Cache Components in Practice: use cache, cacheTag and updateTag

How we rebuilt this site's CMS-driven pages on Next.js 16 Cache Components — instant static pages, content that updates the moment you hit save, and a database fallback that never breaks the site.

Ahsan Bilal

Principal Software Engineer

5 min read
On this page

Next.js 16 makes caching explicit. Instead of guessing which fetch calls are cached and which routes are dynamic, you turn on Cache Components and mark exactly what to cache with the "use cache" directive. We migrated this site — a portfolio with a MongoDB-backed admin CMS — to that model. Here's what we learned.

The mental model

With cacheComponents: true in next.config.ts, everything is dynamic by default at request time, and you opt into caching per function or component:

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

At build time, Next.js prerenders as much of each route as it can into a static shell. Anything that depends on the request — cookies, headers, uncached data — must sit inside a <Suspense> boundary and streams in afterwards. The result is Partial Prerendering by default: instant HTML for the static parts, streamed HTML for the rest.

Caching data with "use cache"

Our content layer is a set of small functions, each cached and tagged:

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

export async function getPublishedPosts() {
  "use cache";
  cacheTag("posts");
  cacheLife("max");

  return db.post.findMany({
    where: { status: "PUBLISHED" },
    orderBy: { publishedAt: "desc" },
  });
}

Three things are happening:

  1. "use cache" caches the function's return value, keyed by its arguments.
  2. cacheTag("posts") labels the entry so we can invalidate it later.
  3. cacheLife("max") says "keep this as long as possible" — we'll invalidate explicitly instead of relying on timers.

Pages simply call these functions. Because the data is cached, the blog index and every post can be fully prerendered at build time.

Invalidating on save with updateTag

When an admin saves a post, the server action writes to the database and expires the relevant tags:

ts
"use server";
import { updateTag } from "next/cache";

export async function savePost(id: string, formData: FormData) {
  await requireAdmin();
  const post = await db.post.update({ where: { id }, data: parse(formData) });
  updateTag("posts");
  updateTag(`post:${post.slug}`);
}

updateTag is designed for exactly this read-your-own-writes case: it expires the cache immediately, so the editor who just hit save sees the new content on their next request. (For background revalidation where slightly stale content is fine, revalidateTag with a cache-life profile is the alternative.) Note that updateTag is only available in Server Actions.

With tags in place, one save refreshes the blog index, the post page, the home page's "latest articles", the RSS feed and the sitemap — because all of them read through the same tagged functions.

Choosing cache lifetimes

cacheLife profiles control how long an entry is considered fresh and when it expires. We use three:

DataProfileWhy
CMS content (posts, projects, profile)maxChanges only when an admin saves; tags handle invalidation
Home page (includes GitHub stats)hoursExternal data we don't control
FooterdaysRarely changes

Keeping request-specific code out of the cache

You can't read cookies or headers inside a "use cache" scope — the cached result would leak between users. Our admin panel reads the session cookie, so admin pages keep the data fetching dynamic and wrap it in Suspense. The layout and loading skeleton still prerender, so the admin shell appears instantly while the authenticated content streams in.

A related gotcha: values like Date.now() or Math.random() during prerendering make output non-deterministic. Next.js 16 flags these at build time. Our inquiry form recorded a timestamp in component state for spam detection; the build failed until we moved the read into an effect that runs only in the browser.

A fallback that never breaks the site

The cached functions wrap database calls with a fallback: if DATABASE_URL is missing or the database is unreachable, they return content from Markdown files in the repository. Builds succeed without a database, local development works offline, and a database outage degrades to slightly older content rather than an error page.

ts
async function withFallback<T>(read: () => Promise<T>, fallback: () => T): Promise<T> {
  if (!isDatabaseConfigured) return fallback();
  try {
    return await read();
  } catch (error) {
    console.error("[content] database read failed, using fallback", error);
    return fallback();
  }
}

Dynamic routes need at least one static param

With Cache Components, generateStaticParams must return at least one entry for a dynamic route so Next.js can validate the route at build time. We return all published slugs, with a placeholder if there are none yet; slugs published after the build render on first request and are cached from then on.

Results

  • Public pages are static HTML at the edge, with no database query on most requests.
  • Editors see changes immediately after saving — no redeploy, no waiting for a timer.
  • The build and the live site survive database outages.
  • Caching behavior is visible in code, not hidden in framework defaults.

Cache Components ask you to think about caching up front. In exchange, you get predictable performance and a codebase where every cached piece of data says exactly how fresh it is and what invalidates it.

_Need a fast, CMS-driven site or a Next.js migration? See web & app development._

#Next.js#React#Caching#Performance#TypeScript
All articles →