Cache Components in Next.js

Cache Components in Next.js

Cache Components is the Next.js 16 caching model behind Instant Navigations. Learn use cache, cacheLife, and cacheTag, and why data is dynamic by default.

Aurora Scharff

Aurora Scharff

July 9, 2026

This post is written by Aurora Scharff, who works on the Next.js team at Vercel.

If you've worked in the Next.js App Router, you've probably spent time tracking down why a route was cached when you didn't ask for it, or why it ran dynamically when you expected it to be static. Caching was on by default and mostly invisible in your code.

Cache Components, introduced in Next.js 16, flips that default. Data is dynamic and runs on every request unless you cache it with a "use cache" directive, so caching becomes a choice you make in the code. A product listing looks the same for everyone, so it can be cached, while a logged-in user's cart has to run fresh each time. Cache Components is how you tell Next.js which is which. The Our Journey with Caching post covers how the old default evolved and why it's changing, and the Next.js 16.3 Instant Navigations release builds directly on this model.

In this post, we'll walk through the concepts using a single store page as a running example, from caching the first component to invalidating on demand. It assumes you're comfortable with Server Components and Suspense. For a refresher, see React Frameworks and Server-Side Features and React Server Components in Practice.

Turning it on

You enable Cache Components with a single flag in your Next.js config:

      // next.config.ts
import type { NextConfig } from 'next';

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

export default nextConfig;

    

This changes the default for your whole app: all data access is dynamic until you cache it. It also replaces the older dynamicIO, useCache, and ppr options, which all fold into this one setting. Because it changes the default everywhere at once, turning it on in an existing project is a migration rather than a quick toggle.

Here's the page we'll work with, the front page of a store. It fetches the product catalog and a set of personalized recommendations:

      // app/page.tsx
export default async function StorePage() {
  const products = await getProducts();
  return (
    <main>
      <h1>Store</h1>
      <ProductGrid products={products} />
      <Recommendations />
    </main>
  );
}

    

Under the old model, this page would run, and whether it ended up static or dynamic depended on what getProducts did internally. With Cache Components on, it won't render silently. In development, Next.js flags that getProducts reads uncached data outside a Suspense boundary, so the route can't be prerendered, and it offers you three ways to resolve it.

Nothing in the code says whether the result can be reused or has to be fetched per request, and the framework won't guess. So it asks you to decide.

Stream, Cache, or Block

Each piece of data a route awaits is handled in one of three ways:

  • Stream it with <Suspense>. The user sees a fallback right away, and the real content streams in when it resolves.
  • Cache it with "use cache". The result is reused across requests, so it's already there when the page renders.
  • Block on it with export const instant = false. The route waits for the server to finish before anything renders.

Stream and Cache both let the page show something the moment it loads, whether that's a loading fallback or previously cached content. Block is the opt-out. Use it for a route where you'd rather wait for the server than show a shell first, like a blog post that shouldn't flash a skeleton.

Our store page has two data sources that want different answers. The catalog is the same for every visitor, so we'll cache it. The recommendations are per-user and have to run on every request, so we'll stream them in behind a fallback.

To cache the catalog, we pull its fetch out of the page and into its own Products component. That gives "use cache" a scope to sit on, and lets the page render the catalog and the recommendations as separate pieces:

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

export default function StorePage() {
  return (
    <main>
      <h1>Store</h1>
      <Products />
      <Suspense fallback={<RecommendationsSkeleton />}>
        <Recommendations />
      </Suspense>
    </main>
  );
}

async function Products() {
  'use cache';
  const products = await getProducts();
  return <ProductGrid products={products} />;
}

    

The error is gone, and the page now renders in two layers. The heading and the product grid are prerendered: Next.js computes their HTML ahead of the request and sends it right away as a shell. The recommendations run per request and stream in behind the skeleton. The user sees the catalog immediately instead of a blank screen while the slowest query finishes.

Because the shell doesn't depend on the request, it can also be cached on a CDN and served from close to the user, with no round trip to your server. Only the recommendations need the server on each request.

This two-layer split is Partial Prerendering (PPR). It used to be a separate experimental flag. Now it's the rendering model Cache Components builds on, so you get it without configuring anything.

The use cache directive

We cached Products by putting "use cache" at the top of the component. Like React's other directives, "use client" and "use server", it's a string at the top of a scope that changes how the code inside it is treated. It works at three levels, depending on how much you want to cache.

At the top of a file, every export is cached. This is the whole-page version, for pages with no per-request data at all:

      // app/about/page.tsx
'use cache';

export default async function AboutPage() {
  const team = await getTeamMembers();
  return <TeamList members={team} />;
}

    

On a single component, like Products above, when only part of a page should be cached:

      async function Products() {
  'use cache';
  const products = await getProducts();
  return <ProductGrid products={products} />;
}

    

Or on a plain async function, to cache a query wherever it's called from:

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

    

The function level is often the most useful. If several pages call getProducts, they share one cache entry, and the components calling it don't need to know it's cached.

The first time a cached function runs, Next.js stores the result. The next call with the same inputs returns the stored value instead of running the function again.

You never write the cache key yourself. Next.js derives it from the function's identity and its serialized arguments, so calling a cached function with different arguments caches each variant on its own:

      async function getProduct(slug: string) {
  'use cache';
  return db.product.findUnique({ where: { slug } });
}

    

Calling getProduct('shoes') and getProduct('hats') produces two separate entries, because the argument is part of the key. Closed-over variables count too, so if the function reads a value from an outer scope, that value becomes part of the key as well.

Because the key comes from the arguments, those arguments have to be serializable: primitives, plain objects, arrays, Date, Map, and Set. The return value uses the same serialization React already uses for Server Component output, which is why Products can return JSX and not just plain data.

Setting a lifetime with cacheLife

How long is the cached catalog valid? By default, an entry stays fresh for a set amount of time, but you'll usually want to set your own. That's what cacheLife is for. Call it inside a use cache scope with a named profile:

      import { cacheLife } from 'next/cache';

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

    

A profile bundles three values:

  • stale is how long the client reuses the entry before checking with the server again.
  • revalidate is how often the server refreshes the entry in the background.
  • expire is the hard limit, the point at which the entry is too old to serve and is regenerated on demand.

Built-in profiles run from seconds to max. The hours profile above refreshes in the background every hour and expires after a day, which fits a catalog that changes a few times a week. When no named profile fits, pass exact numbers in seconds:

      cacheLife({ stale: 300, revalidate: 900, expire: 3600 });

    

If you've used React Query, stale is the same idea as staleTime: how long a value is treated as fresh before it's worth refetching. The revalidate window is like the background refresh you get from a library like SWR. The difference is that this runs on the server and applies per cached function, so you tune each piece of data on its own rather than setting one policy for the whole route.

Invalidating with cacheTag

Time-based expiry works for data that drifts, but not for data that changes at a known moment. When someone on the team renames a product in the admin panel, the catalog shouldn't keep showing the old name for up to an hour.

For that, tag the entry with cacheTag where you read the data, then clear the tag when you write. If you've used React Query, this is the server-side version of invalidating a query by key with queryClient.invalidateQueries({ queryKey: ['products'] }).

Tag the data where you read it:

      import { cacheLife, cacheTag } from 'next/cache';

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

    

Then invalidate the tag from the Server Action that performs the write:

      'use server';
import { revalidateTag } from 'next/cache';

export async function renameProduct(id: string, formData: FormData) {
  await db.product.update({
    where: { id },
    data: { name: formData.get('name') },
  });
  revalidateTag('products', 'max');
}

    

Two things changed here in Next.js 16. revalidateTag now takes a second argument, a cacheLife profile that controls how the entry behaves after you invalidate it. And there's a new updateTag for Server Actions, for when the user has to see their own change right away, whether that's on the same screen or the next one:

      'use server';
import { updateTag } from 'next/cache';

export async function renameProduct(id: string, formData: FormData) {
  await db.product.update({
    where: { id },
    data: { name: formData.get('name') },
  });
  updateTag('products'); // expires and re-reads before the response returns
}

    

The difference between the two is timing. updateTag expires the entry and re-reads it before the response returns, so the admin who renamed the product sees the new name as soon as the form submits. revalidateTag clears the tag and refreshes in the background, so the admin might still see the old name for a moment, but the next visitor gets the new one.

Use updateTag for read-your-own-writes flows, where a stale result would look like the write failed. Use revalidateTag when a short delay is fine. Both clear the entry on the server and in the client's router cache in the same call.

Reading cookies and headers

There's one piece of the store page left: the recommendations. Say they're based on a region cookie, and the query is slow enough that you'd like to cache it per region. Moving "use cache" into the component doesn't work:

      import { cookies } from 'next/headers';

async function Recommendations() {
  'use cache';
  const region = (await cookies()).get('region')?.value; // Error
  const products = await getRecommendations(region);
  return <ProductGrid products={products} />;
}

    

A use cache scope can't call cookies(), headers(), or read searchParams, and this fails at build or request time. The reason is that a cache entry is shared across requests, while those values are different for every request. If you could read a cookie inside the cache, the first user's region would be baked into an entry everyone else receives afterward. The rule keeps you from accidentally leaking one user's data to another.

The fix is to read the request value in an uncached data-fetching function and pass it into the cached one as an argument. It becomes part of the cache key, so each region gets its own entry:

      import { cookies } from 'next/headers';

async function Recommendations() {
  const products = await getRecommendations();
  return <ProductGrid products={products} />;
}

async function getRecommendations() {
  const region = (await cookies()).get('region')?.value ?? 'us';
  return getRegionRecommendations(region);
}

async function getRegionRecommendations(region: string) {
  'use cache';
  return db.recommendations.findMany({ where: { region } });
}

    

Reading the cookie now happens in getRecommendations, which is uncached and runs on every request, so Recommendations stays inside the Suspense boundary on the page. The region is passed into getRegionRecommendations as an argument, so the expensive query behind it is cached per region instead of running for every visitor.

If you can't refactor to pass values in, there's a "use cache: private" variant that caches per user and is allowed to read request APIs. Most code won't need it.

What you no longer need

Part of what made the old model hard was the number of APIs that controlled caching, each with its own rules. Cache Components removes most of that surface. Here are the APIs you stop reaching for once the flag is on:

No longer neededWhat you use instead
export const dynamic = 'force-dynamic'Nothing, dynamic is the default
export const dynamic = 'force-static''use cache' with cacheLife('max')
export const revalidate = 3600cacheLife('hours') in a use cache scope
fetch(url, { next: { revalidate, tags } })'use cache' with cacheLife and cacheTag
unstable_cache(fn, keys, { tags })'use cache' with cacheTag, and no manual keys
unstable_noStore()Nothing, uncached by default
experimental_ppr = trueNothing, PPR is built in

The old fetch and unstable_cache layers still work if you leave them in place, so nothing forces a rewrite. One difference to keep in mind: those older caches persisted across deployments, while use cache stores entries in memory by default, so on serverless they're scoped to a single instance. For durable or shared caching, you configure a cache handler or use "use cache: remote".

Since the flag changes the default across your whole app at once, adopting it in an existing project takes some care. Next.js ships an agent skill that has a coding agent migrate an app one subtree at a time. The development errors are written for an agent to act on as well, covered in Next.js 16.3: AI Improvements.

How this makes navigation instant

The store page's shell, the heading and the cached catalog, is the same for every visitor. Because it's shared, Next.js can prefetch it and keep a copy on the client. When a user clicks a link to the store, the router renders that shell without a round trip to the server, and the recommendations stream in behind their skeleton, just like on a fresh load. The server still produces the initial HTML for the first visit, so first paint and SEO are unchanged.

This is Instant Navigations, in preview in Next.js 16.3. Alongside it, a new Partial Prefetching mode changes what the router fetches: instead of a prefetch request per link, Next.js prefetches one reusable shell per route and caches it on the client. That shell is the same one your "use cache" calls prerender. The announcement covers the rest, including the instant() Playwright helper for catching regressions and the Navigation Inspector for seeing what each route prefetches.

Key Takeaways

  • Cache Components flips the default. With cacheComponents: true, data is dynamic by default and you opt into caching with "use cache".
  • Awaited data is streamed, cached, or blocked. Wrap it in <Suspense>, cache it with "use cache", or opt the route out with export const instant = false. Next.js flags anything undecided in development.
  • "use cache" works at the file, component, or function level. Next.js derives the cache key from the function and its serialized arguments, so you never write one.
  • cacheLife sets lifetime, cacheTag handles invalidation. Revalidate on a timer with a profile, or clear a tag on demand with revalidateTag and updateTag.
  • Cached scopes can't read per-request APIs. Read cookies(), headers(), and searchParams outside the cache and pass the values in.

Conclusion

The store page started as one async component that Next.js couldn't make a decision about. Splitting it into a cached catalog and streamed recommendations took two small changes, and each later need, whether a lifetime, on-demand invalidation, or a per-region cache, had one API to reach for. Data stays dynamic until you cache it, and Next.js flags what still needs a decision in development, so you know what's static and what's dynamic instead of finding out in production. The same prerendered shell that speeds up the first load is what the client reuses to make navigation instant.

You can see all of it in a real app. Next Beats is a music player built on the Next.js 16.3 Preview with Cache Components enabled, and the source is on GitHub. If you're on Next.js 16, try migrating one of your own apps in a branch, using the adoption skill to work through it one subtree at a time, and see how it renders under the new model.


Sources:

  • Next.js Documentation > Cache Components
  • Next.js Documentation > use cache
  • Next.js Documentation > cacheComponents
  • Next.js Documentation > cacheLife
  • Next.js Documentation > cacheTag
  • Next.js Documentation > updateTag
  • Next.js 16.3: Instant Navigations
  • Next.js 16.3: AI Improvements
  • Next.js Blog > Our Journey with Caching

More certificates.dev articles

Get the latest news and updates on developer certifications. Content is updated regularly, so please make sure to bookmark this page or sign up to get the latest content directly in your inbox.