Article

Next.js Caching in 2026: use cache, cacheLife, cacheTag, and What Actually Gets Cached

A practical guide to caching in Next.js 16: Cache Components, use cache, cacheLife, cacheTag, updateTag, revalidateTag, Suspense, and the decisions that keep production data correct.

Caching in Next.js used to feel like a conversation with several versions of the framework at once.

One article said fetch was cached by default. Another added cache: 'no-store' everywhere. A third reached for revalidatePath after every mutation. Then a project upgraded, a page that looked static became dynamic, and nobody was completely sure whether the database query ran once per build, once per request, or whenever the platform felt nostalgic.

Next.js 16 gives us a cleaner option with Cache Components. Caching becomes explicit. Fresh data runs at request time by default. Stable data can opt into the cache with 'use cache'. cacheLife describes how stale the result may be, and cacheTag gives the result a name that mutations can invalidate.

The APIs are not the hard part.

The hard part is deciding what the product is allowed to show when the source data has just changed.

This is how I think about caching in a production Next.js app in 2026: what actually gets cached, how the new model differs from the previous one, where Suspense fits, and which invalidation function I use after a write.

The short answer

If you enable Cache Components in Next.js 16:

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

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

export default nextConfig;

my default rules are:

  • Leave request-specific and frequently changing data dynamic.
  • Wrap dynamic UI in a focused <Suspense> boundary.
  • Add 'use cache' only when several requests may safely reuse the result.
  • Add cacheLife so the acceptable staleness is visible in the code.
  • Add cacheTag when a mutation or webhook knows exactly when the data changed.
  • Use updateTag after a Server Action when the author of a change must see it immediately.
  • Use revalidateTag(tag, 'max') when stale-while-revalidate is acceptable.
  • Use revalidatePath when the route is the thing I need to refresh and I do not have a better data tag.

The one sentence version is:

Cache according to product truth, not according to how expensive the query looks.

A slow public catalog may be cacheable. A fast permission check usually is not. Cost matters, but correctness decides the boundary.

First, choose which Next.js caching model you are using

This detail explains a lot of contradictory examples online.

Cache Components are available in Next.js 16 behind the cacheComponents configuration flag. When the feature is enabled, the model centers on:

  • 'use cache'
  • cacheLife
  • cacheTag
  • updateTag
  • revalidateTag
  • revalidatePath
  • <Suspense> for request-time work

If cacheComponents is not enabled, the previous App Router caching model still applies. That model uses familiar controls such as route segment configuration, fetch options, and unstable_cache.

Both models exist because real applications need time to migrate. The mistake is reading an example written for one model and applying it to the other without noticing.

The official Next.js migration guide is explicit about the change. With Cache Components enabled, older route segment controls such as dynamic, revalidate, and fetchCache are replaced by the newer explicit model.

I make the choice once at project level and write the rest of the code for that choice. I do not try to preserve every old caching habit while also adopting Cache Components.

For the larger picture around routes, data access, auth, and mutations, see how I structure a production Next.js App Router app.

The useful mental model: static, cached, or request-time

I no longer start by asking whether an entire route is static or dynamic.

A single page can contain three kinds of work:

  1. Static work that Next.js can prerender without waiting for a request.
  2. Cached work whose result can be reused across requests.
  3. Request-time work that needs current data, a cookie, a header, a search parameter, or another value that only exists when someone visits.

Imagine a product page:

Product page
├── Static: heading, layout, explanatory copy
├── Cached: product details and related products
└── Request-time: current user's price, cart, and permissions

Before Partial Prerendering, it was easy to let the most dynamic part define the whole page. One cookie read could push the entire route into request-time rendering.

With Cache Components, the static shell can be prepared early. Cached content can join that shell or be reused at runtime. Request-specific sections can stream through Suspense when a visitor arrives.

That makes the architecture more precise, but only if the boundaries reflect the product. Wrapping the whole page in 'use cache' because one query is slow throws away the precision the model gives us.

What 'use cache' actually caches

The 'use cache' directive can be placed at file, page, component, or function level.

I usually start at function level because it produces the smallest, clearest cache boundary:

// lib/posts/queries.ts
import { cacheLife, cacheTag } from 'next/cache';
import { db } from '@/lib/db';

export async function getPublishedPosts() {
  'use cache';
  cacheLife('hours');
  cacheTag('posts');

  return db.post.findMany({
    where: { status: 'PUBLISHED' },
    orderBy: { publishedAt: 'desc' },
    select: {
      id: true,
      slug: true,
      title: true,
      excerpt: true,
      publishedAt: true,
    },
  });
}

Next.js caches the function's serializable result. This works for database queries as well as network requests. The boundary is the async function, not the presence of fetch.

The cache key includes more than the function name. Next.js uses the build ID, a function ID, serializable arguments, and values captured from an outer scope. Different inputs create different cache entries.

That means this function naturally caches each product separately:

export async function getProduct(slug: string) {
  'use cache';
  cacheLife('hours');
  cacheTag('products', `product-${slug}`);

  return db.product.findUnique({
    where: { slug },
  });
}

getProduct('blue-chair') and getProduct('oak-desk') do not share one ambiguous result. The slug is part of the key.

Arguments and return values must be serializable. Plain objects, arrays, primitives, dates, maps, sets, typed arrays, and other supported React values are fine. A live database client, request object, or arbitrary class instance is not a useful cache value.

The practical rule is simple: pass a small description of the requested data into the cached function and return the data the caller actually needs.

Why I prefer function-level caching

You can cache an entire page:

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

export default async function BlogPage() {
  // Everything in this file is inside the cached boundary.
}

You can also cache a component:

export async function FeaturedPosts() {
  'use cache';
  // ...
}

Those options are useful when the whole output has one clear freshness rule. For example, a public legal page or a shared marketing section may genuinely be one stable unit.

Most product pages are not that uniform.

A dashboard can contain a stable list of plans, a user-specific account card, and a live activity feed. Caching the query functions separately lets each piece state its own contract. It also makes invalidation easier because tags can describe the data rather than the route that happened to render it.

Function-level caching creates a small question that is easy to answer in code review:

May these arguments return this cached result to another request?

The wider the boundary becomes, the harder that question is to answer.

cacheLife is a product decision written as code

cacheLife controls three different timings:

  • stale: how long the client router may reuse the result without checking the server.
  • revalidate: when the server may serve the cached result and refresh it in the background.
  • expire: when the next request must wait for a fresh result.

Next.js includes named profiles:

ProfileGood starting point for
secondsrapidly changing shared data
minutesfeeds, status data, frequently edited content
hourscatalogs, inventory summaries, weather-like data
daysarticles and content updated daily
weekspodcasts, archives, slower editorial content
maxlegal pages and rarely changing reference content

The names are better than unexplained numbers scattered through a codebase:

export async function getPricingPlans() {
  'use cache';
  cacheLife('hours');
  cacheTag('pricing-plans');

  return db.plan.findMany({
    where: { active: true },
  });
}

For a product-specific rule, define a custom profile:

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

const nextConfig: NextConfig = {
  cacheComponents: true,
  cacheLife: {
    catalog: {
      stale: 60,
      revalidate: 300,
      expire: 3600,
    },
  },
};

export default nextConfig;

Then the query can say what it means:

cacheLife('catalog');

I prefer names based on product behavior rather than infrastructure. A profile called catalog tells me which contract I am changing. A profile called redis-fast tells me how someone once implemented it.

One detail is easy to miss: stale controls the client router cache. It is not the same thing as setting an HTTP Cache-Control header. The server-side refresh behavior is described by revalidate and expire.

The current cacheLife reference documents the exact preset durations. I still choose the profile by asking a product question first: how wrong would it be if this value remained old for one minute, one hour, or one day?

cacheTag gives cached data a business name

Time-based refresh is useful, but many applications already know when data has changed.

When an editor publishes an article, a webhook updates inventory, or a user renames a project, waiting for an arbitrary timer is unnecessary. A cache tag lets the write identify the reads it affected.

export async function getPost(slug: string) {
  'use cache';
  cacheLife('days');
  cacheTag('posts', `post-${slug}`);

  return db.post.findUnique({
    where: { slug },
  });
}

The broad posts tag can invalidate lists. The specific post-${slug} tag can invalidate one detail view.

I use tag names that match data concepts:

posts
post-nextjs-caching
products
product-42
project-8f3a
pricing-plans

I avoid tags based on component names such as homepage-card-v2. Components change more often than the data they represent.

Tags are also case-sensitive and should stay within the documented length limit. More importantly, they need a naming convention. A cache invalidation bug caused by product-42 in one file and products:42 in another is a boring way to spend an afternoon.

updateTag vs revalidateTag vs revalidatePath

These functions sound similar because all three make cached output eligible to change. Their user-facing behavior is different.

Use updateTag for read-your-own-writes

updateTag immediately expires entries with the given tag. The next read waits for fresh data instead of showing the old value.

It can only be called from a Server Action.

// app/posts/actions.ts
'use server';

import { updateTag } from 'next/cache';
import { redirect } from 'next/navigation';
import { db } from '@/lib/db';

export async function publishPost(postId: string) {
  const post = await db.post.update({
    where: { id: postId },
    data: { status: 'PUBLISHED' },
  });

  updateTag('posts');
  updateTag(`post-${post.slug}`);

  redirect(`/blog/${post.slug}`);
}

The person who pressed Publish expects to see a published article at the destination. Showing the old draft state for another request would make the interface feel broken.

That is exactly the problem updateTag solves.

If the boundary between an action and an HTTP endpoint is still unclear, I cover it separately in Server Actions vs API Routes in Next.js.

Use revalidateTag(tag, 'max') for background freshness

revalidateTag(tag, 'max') marks matching data as stale. When the data is next requested, Next.js may serve the existing result immediately while refreshing it in the background.

// app/api/webhooks/catalog/route.ts
import { revalidateTag } from 'next/cache';

export async function POST(request: Request) {
  // Verify the webhook before trusting it.
  const event = await request.json();

  revalidateTag('products', 'max');

  if (event.productId) {
    revalidateTag(`product-${event.productId}`, 'max');
  }

  return Response.json({ received: true });
}

This fits catalogs, blog indexes, documentation, and other shared content where a visitor seeing the previous version briefly is acceptable.

Unlike updateTag, revalidateTag works in Server Actions and Route Handlers. That makes it the natural option for webhooks and external invalidation endpoints.

The single-argument form revalidateTag(tag) is deprecated in the current API. Use a profile such as 'max', or use updateTag inside a Server Action when you need immediate expiration.

Use revalidatePath when the route is the boundary

revalidatePath('/blog') invalidates the specified route. It is useful when a page must refresh and the code does not have a meaningful shared data tag.

import { revalidatePath } from 'next/cache';

revalidatePath('/dashboard');

But paths and data are not the same thing.

If /blog, /, and /dashboard all show the latest posts, invalidating only /blog leaves the other consumers untouched. A posts tag describes the shared dependency more accurately.

My default order is:

  1. Invalidate a specific data tag.
  2. Invalidate a broader data tag if several cached queries belong together.
  3. Revalidate a path when the route itself is the clearest boundary.

Personalized data is where I slow down

Public content is the easy caching case. Personalized data is where a small mistake can show one user's result to another user.

Plain 'use cache' scopes cannot directly read runtime APIs such as cookies(), headers(), or searchParams. The preferred pattern is to read the runtime value outside the cache and pass only the necessary value into a cached function.

import { cookies } from 'next/headers';
import { Suspense } from 'react';

export default function DashboardPage() {
  return (
    <Suspense fallback={<p>Loading your dashboard...</p>}>
      <DashboardContent />
    </Suspense>
  );
}

async function DashboardContent() {
  const cookieStore = await cookies();
  const sessionId = cookieStore.get('session')?.value;

  if (!sessionId) return <p>Please sign in.</p>;

  return <ProjectSummary sessionId={sessionId} />;
}

Technically, sessionId could become part of a cache key inside ProjectSummary. That does not automatically make it a good idea.

I ask:

  • Is the identifier safe to use as part of this cache boundary?
  • Can permissions change before the cache expires?
  • Could the cached result contain data the next request should no longer see?
  • Does the hosting environment persist this cache in the way I expect?
  • Is the performance benefit worth the security and invalidation complexity?

For permission-sensitive reads, I usually keep the query dynamic unless the cache design is extremely clear. A public product catalog and a user's private billing details should not receive the same caching strategy merely because both queries take 80 milliseconds.

Next.js also provides 'use cache: private' for narrower cases involving runtime data, but I treat it as an advanced tool, not a shortcut around designing the security boundary.

Suspense is not a caching API

Suspense and caching often appear together, which makes them easy to conflate.

Caching answers:

May another request reuse this result?

Suspense answers:

May the rest of the page render while this work waits for the current request?

For current, request-time data, keep the work dynamic and wrap the smallest useful subtree in Suspense:

import { Suspense } from 'react';

export default function ProductPage() {
  return (
    <main>
      <h1>Product details</h1>
      <PublicProductDetails />

      <Suspense fallback={<p>Checking availability...</p>}>
        <LiveAvailability />
      </Suspense>
    </main>
  );
}

The heading and cached product details can participate in the static shell. The live section resolves when the request arrives.

Place the boundary close to the slow or request-specific work. One Suspense boundary around the entire page turns useful static content into a large loading state. Several tiny boundaries around work that always completes together add noise. The boundary should match what the user can understand as one loading unit.

With Cache Components enabled, Next.js will also tell you when uncached async work appears outside Suspense. That build-time pressure is helpful. It forces the page to say whether work should be cached or streamed instead of silently choosing for you.

React.cache is request memoization, not shared caching

Another common source of confusion is cache from React:

import { cache } from 'react';

export const getCurrentUser = cache(async () => {
  // Read and verify the current user.
});

React.cache deduplicates work during one server render or request. If several Server Components call getCurrentUser(), the underlying work can run once for that request.

It does not mean the result is shared between unrelated visitors.

That makes React.cache a good fit for repeated session verification or a query several components need during the same render. 'use cache' is the primitive for a reusable Next.js cache entry that can outlive one request.

I keep the distinction in plain language:

  • React.cache: do not repeat this work inside one request.
  • 'use cache': let future requests reuse this output according to a cache policy.

Those are different promises and should be reviewed differently.

Runtime storage depends on where the app runs

'use cache' is not a magical globally shared database.

At runtime, the default server cache uses in-memory LRU storage. On a self-hosted long-running Next.js process, entries can persist across requests. In a serverless environment, separate requests may reach separate instances, so an in-memory entry may not be shared or survive as expected.

Build-time caching and static-shell behavior still have value, but the runtime storage model matters if you are trying to protect a database from traffic across many instances.

Next.js provides 'use cache: remote' and configurable cache handlers for platforms that support a shared persistent cache. A separate Redis or KV layer can also make sense when the application already owns that infrastructure.

I decide this at deployment level, not inside one query function:

  • How many server instances can serve the route?
  • Is the cache local to one process or shared?
  • What happens after a deployment?
  • Can a tag invalidation reach every place that holds the result?
  • Is a network roundtrip to a remote cache cheaper than the original query?

A caching API cannot answer those questions without knowing the hosting model.

Route Handlers need a cached helper

Do not place 'use cache' directly inside a Route Handler body. Extract the cacheable work into a helper:

// app/api/catalog/route.ts
import { cacheLife, cacheTag } from 'next/cache';
import { db } from '@/lib/db';

async function getCatalogResponse() {
  'use cache';
  cacheLife('minutes');
  cacheTag('products');

  return db.product.findMany({
    where: { published: true },
  });
}

export async function GET() {
  const products = await getCatalogResponse();
  return Response.json(products);
}

This is also better architecture. The helper describes the reusable data capability. The Route Handler describes its HTTP representation.

If a Server Component later needs the same catalog, it can call the helper directly instead of making an HTTP request back into its own application.

The endpoint side of that decision is covered in more depth in Next.js API Routes in 2026.

The common mistakes I avoid

Caching everything because the build becomes green

When Next.js reports uncached work outside Suspense, adding 'use cache' everywhere may silence the error. It can also cache data that should remain current or private.

The error is asking for a decision, not a keyword.

Choose between a cache boundary and a Suspense boundary based on the data.

Using one lifetime for an entire product

Marketing copy, product prices, inventory, account permissions, and live notifications do not share one freshness rule.

Cache by data contract, not by route folder.

Invalidation without a naming convention

Tags only work if writers and readers use the same strings. Keep tag creation in small helpers when values are dynamic:

export const productTag = (id: string) => `product-${id}`;

Using revalidatePath('/') as a global reset button

Broad path invalidation feels safe because something will refresh. It also recomputes unrelated work and hides which data changed.

Prefer a specific tag when you know the affected entity.

Expecting revalidateTag to block for fresh data

With the recommended 'max' profile, it serves stale content while refreshing in the background. That is a feature, not a failure.

If the user must see their own mutation immediately, use updateTag inside the Server Action.

Calling updateTag from a webhook

updateTag only works in Server Actions. A Route Handler receiving a webhook should use revalidateTag with the appropriate profile.

Caching authorization by accident

Do not widen a private cache until the cache key, permission changes, invalidation, and storage location are obvious. Fast cross-user data leakage is still data leakage.

Measuring only the first cache hit

A benchmark that shows one warm request is incomplete. Measure the cold path, warm path, revalidation, mutation, deployment, and behavior across instances.

Caching improves a system over time. Test the timeline, not one screenshot.

A practical migration from the previous model

I would not enable Cache Components and rewrite the entire application in the same afternoon.

I migrate by product area:

  1. Upgrade Next.js and fix unrelated upgrade issues first.
  2. Enable cacheComponents in a branch.
  3. Run a production build and list every route that exposes uncached work.
  4. Mark each data source as static, cacheable, or request-time.
  5. Add focused Suspense boundaries for current and personalized work.
  6. Add 'use cache' at the smallest useful shared-data boundary.
  7. Add an explicit cacheLife profile.
  8. Add tags only where a real mutation or webhook can invalidate them.
  9. Replace legacy revalidation calls with deliberate updateTag, revalidateTag, or revalidatePath behavior.
  10. Test cold loads, client navigation, writes, invalidation, and deployment.

I start with public data because it has the clearest failure mode. A blog, documentation section, public catalog, or marketing configuration teaches the new model without putting private user data at risk.

The official Cache Components guide is the best reference for the framework behavior. The codebase still needs its own document explaining which product data may be stale and why.

The decision table I use

SituationDefault choice
Static copy with no async workLet Next.js prerender it
Public data shared by many visitors'use cache' plus cacheLife
Public data changed by CMS or webhookAdd cacheTag and revalidateTag
User changes data and must see it nowupdateTag in the Server Action
One route needs to refreshrevalidatePath
Fresh or personalized request dataKeep it dynamic behind Suspense
Same query repeated in one requestReact.cache
Shared cache across server instancesRemote handler or application cache

These are defaults, not laws. The valuable part is that each choice describes a user-visible behavior.

The questions I ask in code review

Before approving a new cache boundary, I ask:

  • Who is allowed to receive this result?
  • Which inputs make one result different from another?
  • How stale may the result be?
  • What event tells us the source changed?
  • Must the writer see the change immediately?
  • Does the invalidation target data or only one route?
  • What happens after a deployment or on another server instance?
  • What is the uncached fallback when the cache is unavailable?
  • Can we explain the behavior without opening five framework documents?

If those answers are unclear, the code is not ready to cache yet.

That is not an argument against caching. It is how caching stays an optimization instead of becoming a second source of truth.

FAQ

Is fetch cached by default in Next.js 16?

With Cache Components enabled, dynamic work runs at request time by default and caching is opt-in. Put the relevant async function, component, or route inside a 'use cache' scope when its output may be reused. If Cache Components are not enabled, consult the previous caching model because its fetch and route configuration rules differ.

What is the difference between use cache and React.cache?

React.cache deduplicates work during one server request or render. 'use cache' creates a Next.js cache entry whose output may be reused across requests according to cacheLife and the hosting cache configuration.

Should I use updateTag or revalidateTag after a mutation?

Use updateTag inside a Server Action when the person making the change must see fresh data on the next read. Use revalidateTag(tag, 'max') when briefly serving stale data while refreshing in the background is acceptable. Route Handlers cannot call updateTag.

When should I use revalidatePath?

Use revalidatePath when a specific page or layout is the clearest thing to invalidate. When several routes depend on the same data, a shared cache tag is usually more precise.

Can I cache database queries without using fetch?

Yes. Put the database query inside an async 'use cache' function and return a serializable value. Cache Components apply to function or component output, not only to HTTP requests.

Should authenticated data ever be cached?

It can be, but only with a carefully designed key, permission model, invalidation strategy, and hosting model. I keep permission-sensitive data dynamic until every part of that design is obvious. Public shared data is a safer place to begin.

Final thought

The best change in Next.js 16 caching is not a new directive. It is that the code has to say what it means.

'use cache' says a result may be reused. cacheLife says for how long. cacheTag says what data it represents. Suspense says the current request may wait for one part without blocking the rest. The invalidation function says whether users may see a stale value or must wait for fresh data.

That is enough vocabulary to build a fast application.

The engineering work is making sure every word is true.

Share this post

Send it to someone who might find it useful.

Discussion

Responses

0 responses

No approved responses yet.

Response

Join the discussion

Moderated
Guest responseGuestReviewed before publishing