Skip to content

Next.js App Router caching, explained with real examples

The four caching layers, how defaults changed from Next 14 to 15 to 16, fetch options, route segment config, unstable_cache and use cache, on-demand revalidation and the pitfalls we see on client projects.

Written by

Emily Watson

Senior Full-Stack Developer

Published
Reading time
7 min
Share
Image: Florian Olivo on Unsplash · Source
On this page
  1. The four caching layers
  2. How defaults changed between versions
  3. Caching fetch requests
  4. Route segment config
  5. Caching non-fetch work
  6. On-demand revalidation
  7. Pitfalls we see on real projects
  8. Summary checklist

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.

LayerWhere it livesWhat it storesHow long
Request memoizationServer, per requestReturn values of identical fetch calls during one renderOne request
Data cacheServer, persistentResults of fetch (and unstable_cache / use cache functions)Until revalidated or expired
Full route cacheServer, persistentRendered HTML and RSC payload of static routesUntil revalidated or redeployed
Router cacheBrowser, in memoryRSC payloads of visited and prefetched routesSession, 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 revalidatePath or revalidateTag do 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.

BehaviourNext 13.4–14Next 15Next 16 with Cache Components
fetch defaultCached (force-cache)Not cachedNot cached; cache explicitly with use cache
GET Route HandlersStatic by defaultDynamic by defaultDynamic unless cached
Router cache for page segments30 s dynamic, 5 min static0 s by default (layouts and loading states still reused)Same as 15
cookies(), headers(), paramsSynchronousAsync (await)Async
Recommended caching APIfetch options, unstable_cacheSame, use cache experimentaluse 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.

tsx
// 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.

tsx
// 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 makes cookies() and headers() return empty values. Useful for marketing pages that accidentally became dynamic.
  • export const fetchCache changes 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)

ts
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.

ts
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.

ts
"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_cache with 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-dynamic as 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 searchParams in 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 with next 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.

More in Web Development

Need help putting this into practice?

Talk to the engineers who write these posts. A 30-minute call gets you a rough plan, a timeline and a price range for your setup, whether or not we work together.

  • Reply within one business day
  • NDA on request
  • Senior engineer on every call