On this page
Caching is the part of the Next.js App Router that generates the most confused bug reports on our projects. A page shows stale prices after an update. A dashboard renders the same user's data for everyone. A build suddenly takes twelve minutes because every route became dynamic. Almost every time, the code is fine and the mental model is wrong.
The model is harder than it should be partly because it has changed. Next.js 13 and 14 cached aggressively by default. Next.js 15 reversed several of those defaults. Next.js 16 introduced Cache Components and made the use cache directive the recommended way to cache, alongside the older APIs that still work.
This article explains the layers, shows the patterns we use on client projects, and is explicit about which version each behaviour applies to. If you maintain an older app, check your next version in package.json before applying anything here.
The four caching layers
Most confusion disappears once you separate these four layers. They are independent, and a request can hit or miss each of them.
| Layer | Where it lives | What it stores | How long |
|---|---|---|---|
| Request memoization | Server, per request | Return values of identical fetch calls during one render | One request |
| Data cache | Server, persistent | Results of fetch (and unstable_cache / use cache functions) | Until revalidated or expired |
| Full route cache | Server, persistent | Rendered HTML and RSC payload of static routes | Until revalidated or redeployed |
| Router cache | Browser, in memory | RSC payloads of visited and prefetched routes | Session, subject to stale times |
Two consequences are worth stating plainly:
- A route can only be statically rendered (and stored in the full route cache) if none of its data is dynamic. One uncached fetch or one call to
cookies()makes the whole route dynamic, unless you use Partial Prerendering or Cache Components to isolate it. - Revalidating data on the server does not clear a browser's router cache by itself. Server Actions that call
revalidatePathorrevalidateTagdo refresh the current client, but other open tabs keep what they have until they navigate or refresh.
How defaults changed between versions
This table is the one we paste into project READMEs.
| Behaviour | Next 13.4–14 | Next 15 | Next 16 with Cache Components |
|---|---|---|---|
fetch default | Cached (force-cache) | Not cached | Not cached; cache explicitly with use cache |
| GET Route Handlers | Static by default | Dynamic by default | Dynamic unless cached |
| Router cache for page segments | 30 s dynamic, 5 min static | 0 s by default (layouts and loading states still reused) | Same as 15 |
cookies(), headers(), params | Synchronous | Async (await) | Async |
| Recommended caching API | fetch options, unstable_cache | Same, use cache experimental | use cache with cacheLife and cacheTag |
If you upgraded from 14 to 15 and your hosting bill or response times jumped, the change in fetch defaults is the first place to look.
Caching fetch requests
On Next 13.4 to 15, and in apps that have not enabled Cache Components, you control the data cache through options on fetch.
// app/products/page.tsx
export default async function ProductsPage() {
// Cached, revalidated at most once an hour, tagged for on-demand invalidation
const products = await fetch("https://api.example.com/products", {
next: { revalidate: 3600, tags: ["products"] },
}).then((r) => r.json());
// Never cached: fresh on every request
const stock = await fetch("https://api.example.com/stock", {
cache: "no-store",
}).then((r) => r.json());
return <ProductGrid products={products} stock={stock} />;
}Because stock uses no-store, this whole page renders dynamically on every request, even though products is cached. The data cache still saves the call to the products API; it just does not make the page static. If you want the static shell with a dynamic island, move stock into its own component behind <Suspense> and use Partial Prerendering (experimental in 14 and 15, part of Cache Components in 16).
Revalidation with revalidate is stale-while-revalidate: the first request after the window expires still receives the cached value, and triggers a background refresh. Users do not wait, but one of them sees data that is up to one window plus one request old.
Route segment config
Before Cache Components, you could set caching behaviour for an entire route segment by exporting constants from a page, layout or route file.
// app/blog/[slug]/page.tsx
export const revalidate = 600; // ISR: rebuild at most every 10 minutes
export const dynamicParams = true; // render unknown slugs on demand
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map((p) => ({ slug: p.slug }));
}Other options you will meet:
export const dynamic = "force-dynamic"renders on every request and skips the full route cache. Useful for dashboards; often overused as a fix for stale data.export const dynamic = "force-static"forces static rendering and makescookies()andheaders()return empty values. Useful for marketing pages that accidentally became dynamic.export const fetchCachechanges the default for all fetches in the segment. We avoid it; per-call options are clearer.
When Cache Components is enabled in Next 16, dynamic, revalidate and fetchCache segment options are not supported. Routes are dynamic by default, and you opt parts of them into caching with use cache. That is a meaningful migration, so plan it rather than flipping the flag.
Caching non-fetch work
Database queries through an ORM, SDK calls and expensive computations do not go through fetch, so fetch options do nothing for them.
unstable_cache (Next 14 and 15)
import { unstable_cache } from "next/cache";
import { db } from "@/lib/db";
export const getCategoryCounts = unstable_cache(
async () => db.product.groupBy({ by: ["category"], _count: true }),
["category-counts"],
{ revalidate: 3600, tags: ["products"] }
);It works, and it is still supported, but the key array is easy to get wrong. Arguments to the wrapped function are added to the key automatically; anything the function reads from outside (a closure variable, an environment flag) is not.
use cache (Next 16 with Cache Components)
In Next 16 you enable Cache Components with cacheComponents: true in next.config.ts. You can then mark a function, a component or a whole file as cacheable.
import { cacheLife, cacheTag } from "next/cache";
import { db } from "@/lib/db";
export async function getProduct(id: string) {
"use cache";
cacheLife("hours");
cacheTag("products", `product-${id}`);
return db.product.findUnique({ where: { id } });
}The arguments and closed-over values become part of the cache key automatically, which removes the most common unstable_cache bug. You cannot call cookies() or headers() inside a use cache scope; read them outside and pass the values you need as arguments, understanding that each distinct value creates a separate cache entry.
In Next 15 the same directive existed behind the experimental dynamicIO flag, with unstable_cacheLife and unstable_cacheTag as the import names. If you are on 15, we would not adopt it in production; upgrade to 16 first.
On-demand revalidation
Time-based revalidation is a fallback. When your own application changes data, invalidate precisely.
"use server";
import { revalidatePath, revalidateTag } from "next/cache";
export async function updateProduct(id: string, data: ProductInput) {
await db.product.update({ where: { id }, data });
revalidateTag(`product-${id}`, "max");
revalidatePath("/products");
}A version note: in Next 14 and 15, revalidateTag takes a single argument. In Next 16 it takes a second argument, a cacheLife profile such as "max", and serves stale content while it refreshes. Next 16 also adds updateTag, for use inside Server Actions, which expires the entry immediately so the user who made the change sees their own write. For a form that edits a record and redirects back to it, updateTag is usually what you want.
For changes that originate outside Next.js (a CMS publish, an admin tool, a webhook), expose a Route Handler that validates a secret and calls revalidateTag.
Pitfalls we see on real projects
- Personalised data in a cached function. A seed-stage fintech client had an account summary cached by
unstable_cachewith a key that did not include the user ID. For about an hour, whoever loaded the page first set the summary for everyone. Always pass identity as an argument, and prefer not caching per-user data at all unless you have measured a need. force-dynamicas a stale-data fix. It works by disabling caching for the whole route, which then hammers the database. Find the specific stale fetch and tag it instead.- Reading
searchParamsin a layout-level component. This makes the route dynamic. Keep search-dependent UI in the page or behind<Suspense>. - Expecting revalidation to reach other users' open tabs. The router cache lives in each browser. If near-real-time updates matter, poll with
router.refresh()or use a real-time channel. - Self-hosting with several instances. The default data cache is on local disk per instance. Behind a load balancer, instances disagree. Configure a shared cache handler (Redis is common) or accept the inconsistency knowingly.
- Treating dev as representative. Caching behaves differently under
next dev. Test caching withnext build && next start.
Summary checklist
- We know which Next.js version we are on and which defaults apply.
- Every data source is deliberately cached or deliberately dynamic, not accidentally one or the other.
- Cached data is tagged, and mutations invalidate by tag.
- Per-user data never goes into a shared cache without the user ID in the key.
- Dynamic parts are isolated behind
<Suspense>so the rest of the route can stay static. - Self-hosted multi-instance deployments use a shared cache handler.
- Caching is verified against a production build, not the dev server.
Caching in the App Router is powerful once the layers are clear. Most of the work is deciding, for each piece of data, how fresh it needs to be and who is allowed to see it, and then writing that decision down in code.

