Next.js

Next.js Caching and Revalidation: What Lives Where

How Next.js 16 caching works with Cache Components: use cache, cacheLife, cacheTag, revalidateTag vs updateTag, and how to verify each layer.

In Next.js 16, caching is opt-in through Cache Components: you mark async functions or components with the use cache directive, give them a lifetime with cacheLife, and tag them with cacheTag. Revalidation is then either time-based, through that lifetime, or on demand, through revalidateTag, updateTag or revalidatePath after a mutation.

Quick answer

  • Enable cacheComponents: true in next.config.ts, then add 'use cache' plus cacheLife(...) to data functions or components.
  • Time-based revalidation comes from the cacheLife profile (stale, revalidate, expire). Without one, the default profile applies: 5 minutes stale, 15 minutes revalidate, never expires.
  • On-demand revalidation: tag with cacheTag, then call updateTag in a Server Action (read-your-own-writes) or revalidateTag(tag, 'max') (stale-while-revalidate).
  • Cached output lives in three places: prerendered HTML, a server cache store, and the browser. Each is scoped to one deployment.
  • Verify with Cache-Control headers, NEXT_PRIVATE_DEBUG_CACHE=1 and a timestamp probe, on a production build, not in next dev.

Version assumptions: this article follows the official documentation as read in 2026-11 for Next.js 16.x, where Cache Components shipped in 16.0.0. If your project does not set cacheComponents, you are on the older model, covered near the end.

The model: what is cached where

The Caching guide describes a cached function's output as an RSC payload, and everything else is a copy of that payload in a different place:

LayerWhat is storedControlled bySurvives a new deploy?
Prerendered HTML (static shell)Build-time output, plus ISR upgradesrevalidate and expire in cacheLifeNo
Server cache storeuse cache entries computed at runtimecacheLife, tags, cacheHandlersNo
BrowserPayload from navigations and prefetchesstale in cacheLifeNo

Two details are easy to miss. First, the default server store is per-instance memory. On serverless, the use cache reference says entries typically do not persist across requests, while build-time caching works normally. Self-hosted, entries persist and you size them with cacheMaxMemorySize.

Second, the cache key includes the build ID (or deploymentId if configured), the function ID and the serializable arguments. Every deploy therefore starts cold, even for use cache: remote.

Caching data and components

Enable the flag first. Assumes Next.js 16.x, App Router, Node.js runtime:

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

const nextConfig: NextConfig = {
  cacheComponents: true,
}

export default nextConfig

The docs also describe a partialPrefetching option and warn if it is unset, so check the cacheComponents reference for your exact release.

Now a cached, tagged data function. The db object is a placeholder for your own data layer, so this is illustrative code, not a runnable project:

// app/lib/posts.ts (illustrative)
import { cacheLife, cacheTag } from 'next/cache'

export async function getPosts() {
  'use cache'
  cacheLife('hours')
  cacheTag('posts')
  return db.query('SELECT id, title FROM posts ORDER BY created_at DESC')
}

Arguments become part of the cache key, so getPost(id) gets one entry per id. Arguments and return values must be serializable: class instances, symbols and URL objects are rejected.

Runtime data does not go inside the cache

A cached scope cannot call cookies(), headers() or read searchParams, including through a helper it calls. Read them outside and pass the value in:

// app/profile/page.tsx (illustrative)
import { cookies } from 'next/headers'
import { Suspense } from 'react'

export default function Page() {
  return (
    <Suspense fallback={<p>Loading...</p>}>
      <ProfileContent />
    </Suspense>
  )
}

async function ProfileContent() {
  const session = (await cookies()).get('session')?.value ?? ''
  return <CachedProfile sessionId={session} />
}

async function CachedProfile({ sessionId }: { sessionId: string }) {
  'use cache'
  // sessionId is part of the cache key
  const data = await fetchUserData(sessionId)
  return <div>{data.name}</div>
}

The docs note that this runtime entry is in-memory by default, so on serverless it may recompute per request. If you need durability across instances, use cache: remote with a cache handler exists, at the cost of a network round trip per lookup.

Revalidation: time-based and on-demand

Revalidating lists the built-in cacheLife profiles:

Profilestalerevalidateexpire
default5m15mnever
seconds30s1s60s
minutes5m1m1h
hours5m1h1d
days5m1d1w
weeks5m1w30d
max5m30d1y

stale is the browser window, revalidate is when the server refreshes in the background, and expire is the hard limit after which a request blocks. A very short lifetime (the seconds profile, revalidate: 0, or expire under 5 minutes) is excluded from prerenders and becomes a dynamic hole instead.

updateTag vs revalidateTag

updateTagrevalidateTag
WhereServer Actions onlyServer Actions and Route Handlers
BehaviorExpires immediatelyStale-while-revalidate
Use forUser must see own changeBackground refresh is fine

The revalidateTag reference documents the signature as revalidateTag(tag, profile). The single-argument form is deprecated and behaves like { expire: 0 }. Two behaviors matter in practice:

  • A revalidation is triggered by the next request, not by the call itself, so pages refresh as they are visited.
  • Tags are case-sensitive and limited to 256 characters. A longer tag is never assigned, so revalidating it silently does nothing.

A Server Action that creates content and shows it immediately:

// app/actions.ts (illustrative)
'use server'

import { updateTag } from 'next/cache'
import { redirect } from 'next/navigation'

export async function createPost(formData: FormData) {
  const post = await db.post.create({
    data: { title: String(formData.get('title')) },
  })
  updateTag('posts')
  redirect(`/posts/${post.id}`)
}

Webhooks from a CMS or another service

updateTag is not available outside Server Actions. The docs say to pass { expire: 0 } when the caller needs data expired immediately, or 'max' when stale-while-revalidate is acceptable. A minimal Route Handler, illustrative and without production-grade secret handling:

// app/api/revalidate/route.ts (illustrative)
import type { NextRequest } from 'next/server'
import { revalidateTag } from 'next/cache'

export async function POST(request: NextRequest) {
  if (request.headers.get('x-webhook-secret') !== process.env.REVALIDATE_SECRET) {
    return Response.json({ error: 'unauthorized' }, { status: 401 })
  }
  const { tag } = await request.json()
  if (typeof tag !== 'string') {
    return Response.json({ error: 'missing tag' }, { status: 400 })
  }
  revalidateTag(tag, 'max')
  return Response.json({ revalidated: true, now: Date.now() })
}

Webhook senders retry and deliver duplicates, but revalidating a tag twice is harmless, so this endpoint is naturally idempotent. For the delivery side, see Webhooks vs polling: how to design a reliable integration.

Prefer tags over revalidatePath. The docs call tag-based revalidation more precise, and the self-hosting guide describes revalidatePath as a convenience layer over a special per-page tag.

How to verify it

Do this on a production build. In development, pages render on demand and are not cached, so next dev proves nothing about caching.

  1. Build and start

    Run npm run build && npm run start. The build output marks which routes are static, partially prerendered or dynamic.

  2. Check Cache-Control

    The self-hosting guide says fully prerendered pages send Cache-Control: public, and pages that access dynamic APIs send private, no-cache, no-store, max-age=0, must-revalidate.

    # curl is available on macOS, Linux and current Windows 10/11
    curl -sI http://localhost:3000/blog | grep -i cache-control
    
  3. Probe with a timestamp

    Temporarily render Date.now() from inside a use cache function. Reload: the value should stay fixed until the lifetime passes or you invalidate the tag. If it changes on every request, the entry is not being stored (serverless, a too-short lifetime, or Draft Mode).

  4. Turn on cache logging

    The use cache reference documents NEXT_PRIVATE_DEBUG_CACHE=1 for verbose cache logs.

    # macOS / Linux (sh, bash)
    NEXT_PRIVATE_DEBUG_CACHE=1 npm run start
    
    # Windows PowerShell
    $env:NEXT_PRIVATE_DEBUG_CACHE = "1"; npm run start
    
  5. Invalidate and re-check

    POST to the webhook with a tag. The first request afterwards serves the stale value while it refreshes (with 'max'), the second shows the new one. With { expire: 0 } the first request already blocks and returns fresh data.

Failure modes worth knowing

  • Multiple instances. By default revalidateTag() on one instance only invalidates that instance's cache. The self-hosting guide says to implement refreshTags() in a custom cache handler backed by shared storage.
  • Draft Mode. Cached functions re-execute on every request and results are not saved.
  • Build hangs. Passing a promise from cookies() into a cached component makes prerender wait until a 50 second timeout. Await the value outside and pass the string.
  • Reverse proxies. Streaming needs buffering disabled end to end, or the static shell and streamed parts arrive together.

Where this meets backend work

Cache invalidation is an integration problem as much as a framework one: something upstream changes, a signal arrives, and several instances must agree on what is stale. At Legacies, Alexandru Talnaci is co-founder and Technical Lead, with responsibility for backend architecture, integrations and product stability, which is the layer where webhook delivery and invalidation contracts get decided. The Legacies section has more context, and Selected work shows his full-stack projects.

Frequently Asked Questions

Is Next.js caching on by default?

Not for dynamic data. With Cache Components, nothing is cached until you add use cache, and uncached async work must sit behind a Suspense boundary or stream at request time. Synchronous, predictable work such as module imports is prerendered automatically.

What is the difference between revalidateTag and revalidatePath?

revalidateTag invalidates every cached entry carrying a tag, across all pages that use it. revalidatePath invalidates a specific page or layout path. The docs recommend tags where possible because they over-invalidate less.

Why does my cached page still show old data after revalidateTag?

With the 'max' profile, the first request after invalidation is intentionally served stale while the refresh runs. Also check that the tag string matches exactly (it is case-sensitive), that it is under 256 characters, and that you are not running several instances with the default in-memory store.

Can I use updateTag in a Route Handler?

No, updateTag works only in Server Actions. In a Route Handler, call revalidateTag(tag, { expire: 0 }) if the data must be gone immediately, or revalidateTag(tag, 'max') if stale-while-revalidate is acceptable.

About the author

Alexandru Talnaci

Full-stack and systems engineer in Bucharest. Co-founder and technical lead at Legacies, working across web applications, integrations and C++ systems.

Explore the projects behind the notes
Back to all notesSubscribe via RSS