Caching in the App Router used to be spread across several places. You could set revalidate on a fetch call, export revalidate or dynamic from a route segment, wrap a database call in unstable_cache, and then work out which of those settings won for a given page. Next.js 16 replaces that with one rule: nothing is cached unless you say so, and you say so with a directive next to the code being cached.
This post walks through that model as we use it on client projects, then lays out the order we migrate older apps in.
Turning on Cache Components#
Cache Components is a single flag in next.config.ts. The installed docs pair it with partialPrefetching, and leaving that unset logs a warning, so set both:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
};
export default nextConfig;New apps created with create-next-app get this by default. If you are coming from Next.js 15, cacheComponents replaces experimental.dynamicIO, experimental.useCache and experimental.ppr. Cache Components also requires the Node.js runtime, so any export const runtime in a route segment has to go, including runtime = "nodejs". If you have not upgraded yet, start with our Next.js 16 upgrade notes.
The model: static, cached, or request-time#
With the flag on, Next.js prerenders a static shell for each route and streams the rest at request time. Every piece of a page falls into one of three groups:
| Kind of work | What you write | Where it ends up |
|---|---|---|
| Synchronous or pure rendering | Nothing | The prerendered shell |
| Data that is the same for every visitor | "use cache" plus cacheLife | The shell, refreshed on its lifetime |
Uncached data, cookies, headers, searchParams | A component inside <Suspense> | Streamed at request time, with the fallback in the shell |
The important change is the default. A fetch or database call that is not inside a "use cache" scope runs on every request. There is no hidden data cache to opt out of, which is why dynamic = "force-dynamic" and unstable_noStore are no longer needed.
The "use cache" directive#
"use cache" marks a function, a component, or a whole file as cacheable. Cached functions and components must be async.
import { cacheLife, cacheTag } from "next/cache";
export type Post = { slug: string; title: string; body: string };
export async function getPost(slug: string): Promise<Post | null> {
"use cache";
cacheLife("days");
cacheTag("posts", `post-${slug}`);
const res = await fetch(`https://cms.example.com/posts/${slug}`);
if (!res.ok) return null;
return res.json();
}Put the directive at the top of a file and every export in that file becomes a cached function, which also means every export must be async. That includes generateMetadata and generateStaticParams if they live in a page or layout with a file-level directive.
How the cache key is built#
You do not write cache keys any more. The docs list what goes into one:
- the build ID (or
deploymentId, if configured), so a new build starts fresh - a hash of the function's location and signature
- the serialized arguments, or props for a component
- any variables captured from an outer scope, which are bound as arguments
Arguments and return values must be serializable. Primitives, plain objects, arrays, Date, Map and Set are fine. Class instances, symbols and URL instances are not. Return values can also be JSX.
One useful exception: a cached component can accept children or a Server Action as a prop, as long as it only passes them through and never reads them. That lets you cache a frame around content that is not cached itself.
Setting lifetimes with cacheLife#
cacheLife sets how long an entry lives. Call it inside the "use cache" scope, never at module level. A profile has three timings:
stale: how long the client router can reuse the data without asking the server.revalidate: after this, the next request is served the cached value while a fresh one is generated in the background.expire: after this long with no traffic, the next request waits for fresh content.
These are the built-in profiles, copied from the installed reference:
| Profile | stale | revalidate | expire |
|---|---|---|---|
default | 5 minutes | 15 minutes | never |
seconds | 30 seconds | 1 second | 1 minute |
minutes | 5 minutes | 1 minute | 1 hour |
hours | 5 minutes | 1 hour | 1 day |
days | 5 minutes | 1 day | 1 week |
weeks | 5 minutes | 1 week | 30 days |
max | 5 minutes | 30 days | 1 year |
If you skip cacheLife, the default profile applies. The docs recommend calling it in every scope anyway, and we agree. An outer cache without an explicit lifetime takes on shorter lifetimes from caches nested inside it, and nesting a short-lived cache inside one with no cacheLife fails the build during prerendering. Writing the profile at the call site removes that whole class of surprise.
Lifetime also decides whether cached content can be prerendered. A revalidate of 0, an expire under five minutes, or a stale under 30 seconds keeps the entry out of the prerender, and it becomes a hole filled at request time. Of the presets, only seconds crosses that line, so treat it like request-time data and give it a <Suspense> boundary.
Custom and inline profiles#
When the presets do not match how your content changes, define named profiles under cacheLife in next.config.ts. Omitted fields inherit from default, and expire must be longer than revalidate:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
cacheLife: {
catalog: {
stale: 300, // 5 minutes
revalidate: 900, // 15 minutes
expire: 86400, // 1 day
},
},
};
export default nextConfig;Then call cacheLife("catalog"). Next.js generates the function's types from your config during next dev, next build or next typegen, so custom names autocomplete and type-check like the built-in ones.
For a one-off, pass an object instead: cacheLife({ revalidate: 300 }). You can also call cacheLife in different branches, such as a short lifetime when a CMS record is missing, as long as only one call runs per invocation. Built-in profiles can be redefined too, but we leave the time-named ones alone: anyone reading cacheLife("days") will assume it means a day.
Invalidating with tags#
Time-based lifetimes handle content that drifts. For content that changes when someone edits it, tag the entry and expire it on demand. cacheTag takes one or more strings (up to 128 per call, each up to 256 characters):
cacheTag("posts", `post-${slug}`);Next.js 16 gives you two functions to expire a tag, and they behave differently.
updateTag(tag) | revalidateTag(tag, profile) | |
|---|---|---|
| Where it runs | Server Actions only | Server Actions and Route Handlers |
| Next request | Waits for fresh data | Gets stale data while it refreshes, within the profile's window |
| Use it for | A user's own edit that must show up right away | CMS webhooks, background updates, anything where a short delay is fine |
updateTag is for read-your-own-writes. Someone submits a form, and the page they land on should show what they just saved:
"use server";
import { updateTag } from "next/cache";
import { redirect } from "next/navigation";
export async function renamePost(formData: FormData): Promise<void> {
const slug = formData.get("slug");
const title = formData.get("title");
if (typeof slug !== "string" || typeof title !== "string") return;
await fetch(`https://cms.example.com/posts/${slug}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title }),
});
updateTag("posts");
updateTag(`post-${slug}`);
redirect(`/posts/${slug}`);
}revalidateTag now takes a second argument. "max" is the recommended value: the tagged data is marked stale, and visitors get the old version while a fresh one is built in the background. You can also pass any other profile name, or an object with an expire in seconds. The revalidation happens when a page using the tag is next requested, not when you call the function.
import { revalidateTag } from "next/cache";
import type { NextRequest } from "next/server";
export async function POST(request: NextRequest) {
if (request.headers.get("x-webhook-secret") !== process.env.REVALIDATE_SECRET) {
return Response.json({ ok: false }, { status: 401 });
}
const { tag } = (await request.json()) as { tag?: string };
if (!tag) return Response.json({ ok: false }, { status: 400 });
revalidateTag(tag, "max");
return Response.json({ ok: true });
}If a webhook needs the data gone immediately and updateTag is not available because you are in a Route Handler, use revalidateTag(tag, { expire: 0 }). The old single-argument revalidateTag(tag) is deprecated and behaves like { expire: 0 }.
revalidatePath still exists and is unchanged, but the docs suggest tags first because they are more precise.
Runtime data and <Suspense>#
Cookies, headers and searchParams describe one request, so they cannot be read inside a "use cache" scope. The rule follows the call stack: if a helper called from a cached function reads cookies(), it fails with the next-request-in-use-cache error. On a dynamic route that error shows up when the route runs, so a build can pass and the app can still fail under next start. Test the routes you migrate.
The pattern is to read the runtime value in an uncached component, then pass the plain value into a cached function as an argument. That component must sit inside <Suspense>. Without a boundary, Next.js has nothing to put in the static shell where the request-time part goes, and the dev overlay reports the route as blocking. With one, the fallback ships in the shell and the real content streams in.
import { cacheLife, cacheTag } from "next/cache";
import { Suspense } from "react";
type Product = { id: string; name: string; price: number };
type Sort = "name" | "price";
async function getProducts(sort: Sort): Promise<Product[]> {
"use cache";
cacheLife("hours");
cacheTag("products");
const res = await fetch(`https://api.example.com/products?sort=${sort}`);
return res.json();
}
async function ProductList({ searchParams }: { searchParams: Promise<{ sort?: string }> }) {
const { sort } = await searchParams;
const products = await getProducts(sort === "price" ? "price" : "name");
return (
<ul>
{products.map((product) => (
<li key={product.id}>
{product.name}: ${product.price}
</li>
))}
</ul>
);
}
export default function Page({ searchParams }: { searchParams: Promise<{ sort?: string }> }) {
return (
<main>
<h1>Products</h1>
<Suspense fallback={<p>Loading products...</p>}>
<ProductList searchParams={searchParams} />
</Suspense>
</main>
);
}Two details in that example matter. The page passes the searchParams promise down instead of awaiting it at the top, so the heading stays in the prerendered shell. And sort is narrowed to two known values before it reaches the cached function, because every distinct argument creates its own cache entry. Passing raw query strings into a cache is an easy way to fill it with junk.
The same applies to cookies() and headers(): read them inside the boundary, extract the value you need, and pass that value to the cached code. For the rare case where you cannot restructure the code this way, the docs describe "use cache: private". We cover boundary placement in more detail in streaming with Suspense.
What replaces what#
Most of the older caching surface maps directly onto the new APIs. This is the list we work from, taken from the installed migration guide:
| Before | With Cache Components |
|---|---|
fetch(url, { next: { revalidate: 3600 } }) | Fetch inside a "use cache" function with cacheLife("hours") |
fetch(url, { next: { tags: ["posts"] } }) | cacheTag("posts") in the same cached function |
export const revalidate = 3600 | "use cache" plus cacheLife around the cached data or UI |
export const revalidate = 0 | Remove it; uncached data already runs per request |
export const dynamic = "force-dynamic" | Remove it |
export const dynamic = "force-static" | "use cache" with cacheLife("max"); optionally ensureStatic |
export const fetchCache = ... | Remove it and cache at the data source |
export const dynamicParams = false | Remove it; call notFound() for unknown params |
unstable_cache(fn, keys, opts) | Optional: a "use cache" function with cacheLife and cacheTag |
unstable_noStore() | Remove it; use connection() if work must run per request |
revalidateTag(tag) | updateTag(tag) in actions, or revalidateTag(tag, "max") |
After the flag is on, segments that still export dynamic, revalidate or fetchCache error, and so does dynamicParams. That is helpful: the build gives you a to-do list.
Migrating off next.revalidate#
Here is the order we follow when moving an existing App Router project. It keeps the app building at every step, which matters more than finishing quickly.
Upgrade to Next.js 16 and enable the flag
Get the app running on 16 with its current caching first. Then add
cacheComponents: trueandpartialPrefetching: true, and remove anyruntimeexports.Delete route segment configs
Remove
dynamic,revalidate,fetchCacheanddynamicParamsfrom every segment. Note each removedrevalidatevalue; you will need it in step 4.Opt out the routes you are not converting yet
export const instant = falseon a page or layout defers validation for that segment without forcing it dynamic. Thecache-components-instant-falsecodemod adds it everywhere in one pass:npx @next/codemod@canary cache-components-instant-false ./app(pass./src/appfor asrc/project).Turn each revalidating fetch into a cached function
Move the fetch into an async function with
"use cache", map itsrevalidateseconds to a profile, and movenext.tagstocacheTag. Exact matches are rare, so pick the closest preset or define a custom profile.Update invalidation calls
Replace single-argument
revalidateTagcalls withupdateTagin Server Actions, andrevalidateTag(tag, "max")in Route Handlers and webhooks.Wrap runtime reads in Suspense
Move
cookies(),headers(),searchParamsand uncached data into components under a<Suspense>boundary, then removeinstant = falsefrom that route and clear any remaining dev overlay insights.Fix synchronous IO
new Date(),Date.now(),Math.random()andcrypto.randomUUID()during prerender fail the build even withinstant = false. Callconnection()before them inside a<Suspense>boundary, or move them into a Client Component.
Step 4 is where most of the diff lives. A typical before and after:
type Post = { slug: string; title: string };
export default async function Page() {
const res = await fetch("https://cms.example.com/posts", {
next: { revalidate: 3600, tags: ["posts"] },
});
const posts: Post[] = await res.json();
return (
<ul>
{posts.map((post) => (
<li key={post.slug}>{post.title}</li>
))}
</ul>
);
}import { cacheLife, cacheTag } from "next/cache";
type Post = { slug: string; title: string };
async function getPosts(): Promise<Post[]> {
"use cache";
cacheLife("hours");
cacheTag("posts");
const res = await fetch("https://cms.example.com/posts");
return res.json();
}
export default async function Page() {
const posts = await getPosts();
return (
<ul>
{posts.map((post) => (
<li key={post.slug}>{post.title}</li>
))}
</ul>
);
}For the mapping itself, compare your old value to the revalidate column of the profile table: 60 seconds is minutes, 3600 is hours, 86400 is days, 604800 is weeks. Remember each preset also carries an expire, so hours stops serving stale content after a day without traffic. If a page used revalidate: 300 and that number was deliberate, write cacheLife({ revalidate: 300 }) or add a named profile rather than rounding to a preset.
For content that only changes when someone edits it, we often drop the time-based value entirely and use cacheLife("max") with a tag, then call revalidateTag from the CMS webhook. The docs recommend the same pairing. It avoids rebuilding content on a timer when nothing has changed.
Checking that it works#
Set NEXT_PRIVATE_DEBUG_CACHE=1 before next dev or next start for verbose cache logs. In development, console output from inside cached functions is replayed with a Cache prefix, which makes it easy to see when a function body actually ran. If a build hangs and times out with "Filling a cache during prerender timed out", a cached function is waiting on a runtime promise. Usually that means a cookies() promise or params was passed in as a prop instead of an awaited value.
Where to go next#
The official references: use cache, cacheLife, cacheTag, updateTag, revalidateTag, and the migration guide. The copy under node_modules/next/dist/docs matches the version you run, so check it when a post (this one included) disagrees with your project. New to the App Router? Start with our Next.js developer guide.
