AI Reading
Quick summary of this article
Learn the Next.js 16 caching model end to end: uncached fetch by default, the use cache directive, cacheLife and cacheTag, plus revalidateTag, updateTag and refresh invalidation.
- In Next.js 16
fetch()is not cached by default — caching is always an explicit decision. - Wrapping a function with
use cache(enabled viacacheComponents: true) caches any async work, not just fetch. cacheTaglabels cached entries so a Server Action can invalidate exactly the right data withrevalidateTag(tag, 'max').updateTaggives read-your-writes in Server Actions: expiry and refresh happen inside the same request.
Complete Next.js 16 Series — Module 6 of 12
Caching is where most Next.js bugs are born. The mental model is simple once it clicks: render on the server, cache deliberately, invalidate precisely.
Fetching in a Server Component
Any async Server Component can fetch directly:
async function getPosts() {
const res = await fetch("https://api.example.com/posts", {
next: { revalidate: 60 },
});
if (!res.ok) throw new Error("Failed to load posts");
return res.json() as Promise<Post[]>;
}
export default async function BlogIndex() {
const posts = await getPosts();
return (
<ul className="divide-y">
{posts.map((p) => (
<li key={p.id} className="py-4">{p.title}</li>
))}
</ul>
);
}
next: { revalidate: 60 } means: serve the cached response, and after 60 seconds fetch a fresh copy in the background (stale-while-revalidate).
The three fetch modes
// 1. Cached with a TTL
fetch(url, { next: { revalidate: 300 } });
// 2. Fully static — cache forever until a tag is invalidated
fetch(url, { cache: "force-cache", next: { tags: ["posts"] } });
// 3. Never cached — always fresh, makes the route dynamic
fetch(url, { cache: "no-store" });
Everything else — no options at all — is uncached by default in Next.js 15 and 16. This is the change that trips up developers coming from Next.js 13, where fetch was cached aggressively.
Request Deduplication Comes Free
If three components fetch the same URL in one render pass, React memoises the promise. You do not need to thread data down through props:
// header.tsx
const user = await getUser(); // runs
// sidebar.tsx
const user = await getUser(); // deduplicated — no second request
Deduplication is per-request only. It is not a cache — the next request runs the fetch again. Only caching survives across requests.
use cache: Cache Any Work, Not Just Fetch
Enable Cache Components in next.config.ts:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
};
export default nextConfig;
Now use cache caches the result of any async function — a database query, a heavy computation, a third-party SDK call:
import { cacheLife, cacheTag } from "next/cache";
async function getProducts() {
"use cache";
cacheLife("hours"); // built-in profile
cacheTag("products"); // label for invalidation
return db.product.findMany({ orderBy: { createdAt: "desc" } });
}
Profiles describe freshness and revalidation in one word:
"seconds"— very short-lived, near-real-time"minutes"— dashboards and feeds"hours"— typical content sites"days"— rarely changing reference data"max"— effectively permanent until invalidated
You can also define a custom profile:
import { unstable_cacheLife as cacheLife } from "next/cache";
cacheLife({
stale: 300, // client considers it fresh for 5 minutes
revalidate: 900, // server regenerates after 15 minutes
expire: 86400, // hard limit of one day
});
use cache is the modern replacement for unstable_cache, and unlike fetch caching it works for non-HTTP data sources.
Invalidating on Write
Caching is only half the story. The other half is making a write appear immediately.
// src/app/admin/products/actions.ts
"use server";
import { revalidateTag, updateTag } from "next/cache";
export async function createProduct(formData: FormData) {
const name = String(formData.get("name"));
await db.product.create({ data: { name } });
// Read-your-writes: expires the entry and refreshes it in the same response,
// so the author immediately sees their new product.
updateTag("products");
}
export async function syncCatalogFromSupplier() {
await runSupplierImport();
// Stale-while-revalidate: visitors keep seeing the old catalog while
// it regenerates in the background. `revalidateTag` in Next.js 16
// requires a cacheLife profile as its second argument.
revalidateTag("products", "max");
}
Choosing between them:
updateTag(tag)— Server Actions only. The user made a change, so they must see it now. Slightly slower response.revalidateTag(tag, profile)— background content that can be briefly stale. Faster response, stale-while-revalidate semantics.refresh()— re-fetches uncached data for the current request inside a Server Action, without touching the cache.
Migrating from Next.js 15?
revalidateTag("posts")with one argument now produces a TypeScript error. Add a profile:revalidateTag("posts", "max").
Route-Level Caching Controls
Two exports still control a whole route:
export const revalidate = 3600; // ISR: regenerate at most hourly
export const dynamic = "force-static"; // never use request data
But prefer segment-level caching with use cache — it is more granular and composes with streaming.
Building a Real Data Layer
Put fetching in one module so tags and profiles stay consistent:
// src/lib/posts.ts
import { cacheLife, cacheTag } from "next/cache";
export async function getPost(slug: string) {
"use cache";
cacheLife("days");
cacheTag("posts", `post-${slug}`);
return db.post.findUnique({ where: { slug } });
}
export async function getRecentPosts(limit = 5) {
"use cache";
cacheLife("hours");
cacheTag("posts");
return db.post.findMany({ orderBy: { publishedAt: "desc" }, take: limit });
}
Because each post is tagged with its own post-${slug}, an edit to one article invalidates only that article — not the entire archive.
Streaming with Suspense
Fetching sequentially creates waterfalls. Split slow sections behind Suspense so the fast shell ships first:
import { Suspense } from "react";
export default function DashboardPage() {
return (
<div className="space-y-8">
<h1 className="text-2xl font-bold">Dashboard</h1>
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentOrders />
</Suspense>
</div>
);
}
Both components fetch in parallel, and each one streams in as soon as it resolves. With loading.tsx you get this automatically for a whole segment; <Suspense> gives you control per section.
Parallel Fetches in One Component
If you need several independent values before rendering, start them together:
const [user, orders, invoices] = await Promise.all([
getUser(),
getOrders(),
getInvoices(),
]);
Or start them eagerly and await later, which keeps the component free to render the shell first:
const ordersPromise = getOrders();
const invoicesPromise = getInvoices();
return (
<Suspense fallback={<Skeleton />}>
<Orders promise={ordersPromise} />
<Invoices promise={invoicesPromise} />
</Suspense>
);
Module 6 Recap
fetchis uncached by default — cache explicitly withrevalidate,force-cacheorno-store.- Duplicate
fetchcalls in one render are deduplicated automatically. use cache(withcacheComponents: true) caches any async function; combine withcacheLifeandcacheTag.revalidateTag(tag, profile)for background refresh;updateTag(tag)for read-your-writes in Server Actions.- Tag granularly (
post-${slug}) so invalidation stays cheap. - Stream with
<Suspense>and parallelise withPromise.all.
Next up — Module 7: forms, Server Actions and validation with Zod.
Continue the Course
- Course index: Next.js 16 Tutorial – all 12 modules
- Previous: Module 5: Server vs Client Components
- Next: Module 7: Forms & Server Actions
