+1 (781) 556-6029

Next.js caching: prove what is cached before you tune it

Two complaints arrive about Next.js caching, and they are the same complaint. One team ships a content change and the old page keeps serving. Another team watches their hosting bill grow because every request re-renders a page whose data changes twice a day. In both cases nobody on the team can say, from reading the code, whether a given route is cached, for how long, or by which layer.

That is the real problem. Caching defaults are a tuning question; not knowing what your app does is a diagnosis question, and it comes first.

Why the App Router confused people

When the App Router shipped, caching was mostly implicit. fetch was patched to cache by default. A route became dynamic — silently, for the whole route — the moment any component read cookies(), headers(), or searchParams. A separate full route cache stored rendered output, and a router cache in the browser held client-side navigations for a time you did not set.

None of these were unreasonable individually. Together they meant that caching behavior was a derived property of the whole render tree, changed by a line added three components deep. Adding an analytics helper that reads a cookie could turn a static marketing page dynamic, and nothing in review would say so.

Next.js has been walking this back. Version 15 flipped fetch and route handlers to uncached by default, which removed the stale-content surprise and left the cost surprise. The direction since then — shipped as Cache Components, built on the use cache directive — is to make caching explicit: nothing is cached unless you say it is, and you say it at the function, component, or file you actually want cached.

Check your own version before acting on any of this. The behavior differs materially between 14, 15, and 16, and most of the bad advice on this subject is correct for a version you are not running.

Step 1: read the build output

Before changing anything, run a production build and read the route table it prints. Every route is marked static (prerendered) or dynamic (server-rendered per request). That table is the ground truth for what your app does today, and it takes thirty seconds to obtain.

Print it, then go through it with someone who knows the product and ask one question per row: is this what we intended? The answers are usually a short list of genuine mistakes — a product listing page that is dynamic because a layout component reads a cookie for a feature flag, a settings page that is static and should not be — surrounded by routes that were fine all along.

When a route is dynamic and you cannot see why, the usual culprits are in the layout rather than the page. Dynamic APIs anywhere in the tree opt the route out, and layouts are shared, so one headers() call in a shell component will show up on every route underneath it.

Step 2: verify at runtime, not from the diagram

Build output tells you the intent at build time. For anything with revalidation, confirm the behavior by observing responses: request the route twice and compare the cache headers and the age your host reports, change the underlying data and time how long the old response survives, then request it again after the revalidation window and check that the second request — not the first — gets fresh content. Stale-while-revalidate semantics mean the request that triggers the refresh usually still receives the stale body. Teams report this as a bug roughly once per engagement.

Do the same for client navigations. A page that looks correct on hard refresh and stale on in-app navigation is a router cache observation, not a server caching observation, and the fix lives in a different place.

Step 3: adopt use cache where the data is, not where the page is

The explicit model is a directive at the top of a function, component, or file:

async function getPricingTiers() {
  'use cache';
  cacheLife('hours');
  cacheTag('pricing');
  const res = await fetch('https://api.example.com/pricing');
  return res.json();
}

Three properties are worth naming, because they change how you structure code.

The cache key is derived from the function's arguments and its closed-over values, so the function must be honest about its inputs. A cached function that reads request state it did not receive as an argument is a bug the framework will try to stop you from writing.

cacheLife sets the freshness profile — named profiles like hours or days, or explicit stale, revalidate, and expire values. Pick these from what the business tolerates, not from what feels safe. "Pricing may be up to one hour stale" is a decision someone in the room can make; revalidate: 3600 buried in a file is not a decision anyone remembers making.

cacheTag plus a tag invalidation call in your write path is what makes editorial and admin changes appear promptly without dropping the cache lifetime to seconds. Most teams that think they need a short revalidate window actually need one tag and one invalidation call in the mutation that changes the data.

The structural consequence is that caching moves down into the data layer and stops being a route-level property. A page can then be a shell that renders instantly, with cached and uncached regions inside it behind Suspense boundaries — the same partial-prerendering shape we described in Where to put Suspense boundaries. That is the actual win here, and it is worth more than any individual revalidate value.

Step 4: decide what does not belong in the framework cache

Not every caching problem is a rendering problem. Three cases we routinely move elsewhere:

Per-user data that varies on every request should usually not be cached at the framework level at all. Cache the expensive upstream call in your data layer with a user-scoped key, or accept the cost.

Data that many routes need within one request — the current organization, permissions, feature flags — belongs in React's request-scoped cache(), which deduplicates within a single render pass and forgets afterwards. This is a different mechanism from use cache and solves a different problem; conflating them is common.

A read-heavy API you do not control is often better fronted by a small cache in your own backend than by the page cache, because you can invalidate it on your own terms and reuse it from more than one route.

What we do on an engagement

The order is the same every time: read the build output, verify a handful of routes against real responses, list the routes whose behavior does not match intent, and fix those. Only then discuss the caching model. Migrating to explicit caching on a codebase whose current behavior nobody has documented means you are changing two unknowns at once, and if latency or freshness moves you will not know which change moved it.

Adopt the explicit model incrementally: enable it, let the build surface the places that now need a decision, and work through them in shippable batches with the route table as your checklist. Re-measure — field data for the page-load effect, your host's metrics for the compute effect — and keep the numbers next to the change. A caching decision with no recorded measurement gets re-litigated in six months by someone who was not there, and they will guess too.