All posts
React & Next.js··10 min read

Next.js 16 Caching, Demystified: use cache, PPR, and Revalidation

Next.js caching has burned every team I have worked with. Here is how the Next.js 16 model, use cache, cacheLife, cacheTag and PPR, actually works.

By

On this page

Every team I have shipped a Next.js app with has lost a day to caching. Someone deploys, the dashboard shows stale numbers, and we spend an afternoon arguing about whether it was the fetch cache, the full route cache, the router cache, or unstable_cache quietly holding onto a value nobody asked it to keep. The old model cached aggressively by default and made you opt out, usually by sprinkling cache: 'no-store' and export const dynamic = 'force-dynamic' like salt until the staleness went away.

Next.js 16 inverts that. Nothing is cached unless you say so, caching is an explicit directive you write in code, and you invalidate by tag instead of guessing which of four caches is lying to you. This is the model I have wanted for years. Here is how it actually works, where the sharp edges are, and how to migrate without a rewrite.

The default flipped: nothing is cached now

In the App Router up through Next.js 14, a bare fetch() inside a Server Component was cached indefinitely unless you opted out. That single decision caused more production incidents than any other Next.js feature I can name, because the caching was invisible at the call site.

In Next.js 16, fetch is no longer cached by default. A request is a request. If you want caching, you ask for it explicitly with the use cache directive. This is the headline change, and it means most of the defensive cache: 'no-store' you wrote in the last three years is now dead code you can delete.

// Next.js 16: this hits the origin every render. No surprise caching.
async function getQuote(symbol: string) {
  const res = await fetch(`https://api.example.com/quote/${symbol}`)
  return res.json()
}

To opt into caching you mark the unit of work with use cache. It works at three scopes: a whole file, a single function, or a component. The directive caches the return value, keyed automatically by the function arguments and any closed-over values Next.js can serialize.

// lib/products.ts
import { cacheLife, cacheTag } from 'next/cache'
 
export async function getProduct(id: string) {
  'use cache'
  cacheLife('hours')
  cacheTag(`product:${id}`)
 
  const res = await fetch(`https://api.example.com/products/${id}`)
  if (!res.ok) throw new Error(`product ${id} failed: ${res.status}`)
  return res.json() as Promise<Product>
}

Two things to internalize. First, the cache key is derived from the arguments, so getProduct('a') and getProduct('b') are separate entries automatically — you do not build key strings by hand the way you did with unstable_cache. Second, anything not serializable as an argument or closure (a live request header, cookies(), a Date.now()) cannot cross into a use cache boundary, and the compiler will tell you. That constraint is the whole point: a cached function must be a pure function of its inputs, or the cache is a lie.

cacheLife controls freshness, cacheTag controls invalidation

These two functions do different jobs and people conflate them constantly.

cacheLife sets the time-based behavior: how long an entry is fresh, how long it can be served stale while revalidating in the background, and how long it lives before it is dropped entirely. You can pass a named profile or a custom object.

import { cacheLife } from 'next/cache'
 
async function getHomepageFeed() {
  'use cache'
  // Named profile shorthand
  cacheLife('minutes')
 
  // Or fully custom, in seconds
  cacheLife({
    stale: 60,        // serve from client router cache up to 60s
    revalidate: 300,  // refresh server cache every 5 min
    expire: 3600,     // hard cap; after 1h, block on a fresh fetch
  })
 
  return fetchFeed()
}

The built-in profiles cover most cases: seconds, minutes, hours, days, weeks, max. You can also register custom named profiles in next.config.ts so cacheLife('feed') means the same thing everywhere instead of copy-pasted magic numbers.

// next.config.ts
import type { NextConfig } from 'next'
 
const nextConfig: NextConfig = {
  cacheComponents: true, // enables use cache + PPR
  cacheLife: {
    feed: { stale: 30, revalidate: 120, expire: 600 },
  },
}
 
export default nextConfig

cacheTag is the other half. It attaches one or more labels to a cache entry so you can purge it on demand, independent of time. A product entry tagged product:42 can be invalidated the instant that product changes, instead of waiting out its revalidate window. Tag whatever your write path touches, and tag it at the granularity you mutate at.

Invalidating from a Server Action: revalidateTag vs updateTag

When data changes, you invalidate the relevant tags from a Server Action or Route Handler. Next.js 16 gives you two functions, and the difference matters more than the docs make it sound.

revalidateTag(tag) marks every entry with that tag as stale. The next request that touches it triggers a refresh. It does not block the current response — the user who triggered the write may still see the old value on their immediate next read, because invalidation and refetch are decoupled.

updateTag(tag) is the one you usually want after a mutation. It expires the tag and refreshes the data within the same request, so the action returns with the cache already consistent. This is the fix for the classic "I saved it but the list still shows the old row" bug.

// app/products/actions.ts
'use server'
 
import { revalidateTag, updateTag } from 'next/cache'
import { db } from '@/lib/db'
 
export async function updateProductPrice(id: string, price: number) {
  await db.product.update({ where: { id }, data: { price } })
 
  // Refresh THIS request's cache so the redirect/return is consistent.
  updateTag(`product:${id}`)
}
 
export async function bustCatalog() {
  // Background invalidation: next reader pays the refresh cost, not us.
  revalidateTag('catalog')
}

My rule: use updateTag after a user-initiated write where that same user expects to see their change immediately. Use revalidateTag for fan-out invalidation where eventual consistency is fine — a webhook from your CMS, a cron job, a bulk import. Reach for updateTag by default in form actions, because "I just clicked save and nothing changed" is the bug report you do not want.

PPR: a static shell with dynamic holes

Partial Prerendering is the rendering half of this story, and cacheComponents: true turns it on. The mental model is simple once it clicks: Next.js renders your page at build time into a static shell, and anywhere you read request-time data, it leaves a hole that streams in at request time. One route, served as a static document instantly, with the personalized bits filling in.

The boundary between static and dynamic is <Suspense>. Everything outside a Suspense boundary that does not touch request data becomes part of the prerendered shell. Anything that reads cookies(), headers(), searchParams, or any uncached dynamic source goes inside a boundary and streams.

// app/products/[id]/page.tsx
import { Suspense } from 'react'
import { getProduct } from '@/lib/products'
import { Recommendations } from './recommendations'
import { CartButton } from './cart-button'
 
export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params
  const product = await getProduct(id) // cached -> part of the static shell
 
  return (
    <main>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
 
      {/* Dynamic hole: reads the user's cart cookie, streams in */}
      <Suspense fallback={<CartButton.Skeleton />}>
        <CartButton productId={id} />
      </Suspense>
 
      {/* Dynamic hole: personalized, slower upstream call */}
      <Suspense fallback={<Recommendations.Skeleton />}>
        <Recommendations productId={id} />
      </Suspense>
    </main>
  )
}

The product header ships in the static shell because getProduct is cached. The cart button and recommendations read per-user state, so they live behind Suspense and stream after the shell paints. The user sees the page structure and product copy immediately, and the personalized pieces resolve a beat later. You get static-page TTFB with dynamic-page correctness, on a single route, without force-dynamic nuking the whole thing.

The trap: if you read a dynamic source outside a Suspense boundary, the build cannot prerender that part of the shell and Next.js will tell you at build time. The fix is always the same — either wrap it in Suspense or move it behind use cache. Treat those build errors as the framework doing your performance review for you.

Migrating from getServerSideProps and unstable_cache

If you are coming from the Pages Router or early App Router, here is the direct mapping.

Old API / patternNext.js 16 replacement
fetch() cached by defaultfetch() uncached by default; add use cache to opt in
cache: 'force-cache''use cache' directive on the function
cache: 'no-store'nothing — it is the default now
export const dynamic = 'force-dynamic'<Suspense> around the dynamic part, or just read request data
export const revalidate = 300cacheLife({ revalidate: 300 }) inside use cache
unstable_cache(fn, keys, { tags })use cache + cacheTag() (key is automatic)
revalidateTag(tag) (eager refetch)updateTag(tag) in-request, revalidateTag(tag) for background
getServerSidePropsasync Server Component reading request data inside Suspense
getStaticProps + revalidateuse cache + cacheLife on the data function
manual cache key stringsderived from function arguments automatically

The unstable_cache migration is the satisfying one. That API made you hand-build a key array and a separate tags array, and getting the key wrong meant silent cache collisions across users — a genuine data-leak class of bug. With use cache the key is the function signature, so the collision footgun is gone.

// Before: unstable_cache, manual keys, easy to leak across users
import { unstable_cache } from 'next/cache'
 
const getUserDashboard = unstable_cache(
  async (userId: string) => db.dashboard.find(userId),
  ['user-dashboard'],            // forget to include userId here -> data leak
  { tags: ['dashboard'], revalidate: 60 },
)
 
// After: key is the argument, scope is explicit, leak is impossible
import { cacheLife, cacheTag } from 'next/cache'
 
async function getUserDashboard(userId: string) {
  'use cache'
  cacheLife({ revalidate: 60, stale: 30, expire: 300 })
  cacheTag(`dashboard:${userId}`)
  return db.dashboard.find(userId)
}

Run the upgrade with the official tooling rather than by hand. The codemod handles most of the mechanical rewrites and flags the dynamic-access errors PPR introduces.

npx @next/codemod@latest upgrade latest
# then enable the new model
# in next.config.ts: cacheComponents: true
npm run build   # fix every dynamic-outside-Suspense error it reports

A decision framework you can apply today

When you are about to render something and you are not sure how to cache it, walk this list top to bottom and stop at the first match.

  • Is it the same for every user and changes rarely? use cache with cacheLife('hours') or 'days'). Tag it so a CMS webhook can bust it.
  • Same for everyone but changes on writes you control? use cache + cacheTag, and call updateTag from the Server Action that does the write.
  • Per-user or per-request (cart, auth, geo, search params)? Do not cache. Put it behind <Suspense> so PPR streams it into the static shell.
  • A user just submitted a form and expects to see the result? updateTag in that action, never revalidateTag — they should not have to refresh.
  • Fan-out invalidation from a cron, webhook, or bulk job? revalidateTag. Eventual consistency is fine and you avoid blocking the trigger.
  • Still tempted to write force-dynamic? You almost never need it now. Find the one dynamic read forcing your hand and wrap that in Suspense instead of dynamiting the whole route.

The shift that matters: caching in Next.js 16 is something you opt into, in code, at a named boundary, with an explicit lifetime and an explicit invalidation tag. There is no more invisible default holding a stale value you never asked it to keep. The first time you ship a route where the shell is instant, the personalized parts stream, and a single updateTag keeps everything honest after a write, the years of no-store whack-a-mole will feel like a different framework. It mostly is.