Next.js Data Fetching, Caching, and Revalidation: The Full Picture
Four caching layers, three revalidation APIs, and the Next.js 16 rename that turns everything you learned in 15 into the "previous model". Here's the complete mental model for data fetching in the App Router.
Next.js Data Fetching, Caching, and Revalidation: The Full Picture
Three months into your App Router migration, your monitoring dashboard shows something weird. Some pages are serving data that's hours out of date. Others are hitting the database on every request for data that barely ever changes. You add { cache: 'no-store' } to one fetch call and suddenly an unrelated page goes static. The caching behavior feels random.
It's not random. Next.js runs four distinct caching layers, each with a different lifetime, different scope, and different invalidation APIs. Once you have a mental model for how they stack, the behavior makes complete sense — and you can design exactly the freshness characteristics you need.
The Four Caching Layers
Before diving into patterns, you need to know what you're working with:
Request Memoization — Within a single render pass, React deduplicates identical fetch() calls automatically. Call fetch('/api/user/123') in three different Server Components and you get one network request. No configuration needed, resets between requests.
Data Cache — A persistent server-side store. fetch() results and unstable_cache() results land here. Survives across requests, which is the part that bites people. Whether it survives a redeploy depends on where it lives: on a managed platform it persists across deployments, while a default self-hosted setup keeps it on disk under .next/cache and a fresh build wipes it unless you deliberately persist that directory between deploys.
Full Route Cache — Cached HTML and RSC payload for statically-generated routes. Built at next build, rebuilt when the underlying Data Cache entries get invalidated.
Router Cache — In-memory cache in the browser. Stores RSC payloads for routes you've visited. Cleared by revalidatePath and revalidateTag.
The reuse window is worth pinning down, because the number most articles quote is from Next.js 14. In 16.1.6 the defaults live in experimental.staleTimes and are { dynamic: 0, static: 300 } — seconds. So a static page segment is reused for 5 minutes, and a dynamic one is not reused at all; revisiting it refetches. If you were counting on the old 30-second dynamic window, it's gone. You can raise it explicitly:
// next.config.ts
const nextConfig: NextConfig = {
experimental: {
staleTimes: { dynamic: 30, static: 300 },
},
};The Breaking Change From 14 to 15 (Still In Force on 16)
If you're upgrading from Next.js 14, one change explains a lot of mystery incidents: fetch() is no longer cached by default.
In Next.js 14, fetch requests were force-cache by default — every request was stored in the Data Cache unless you explicitly opted out. In Next.js 15, the default flipped to effectively no-store. Fetch hits the origin on every request unless you tell it not to.
// Next.js 14 — this was cached by default, no config needed
const res = await fetch('https://cms.example.com/api/posts');
// Next.js 15 — this hits the origin on every request
const res = await fetch('https://cms.example.com/api/posts');
// Next.js 15 — opt in to caching explicitly
const res = await fetch('https://cms.example.com/api/posts', {
next: { revalidate: 3600 }, // ISR: cache for 1 hour
});GET Route Handlers also lost their automatic caching in Next.js 15. The change was intentional — the old defaults were causing too many incidents where developers shipped stale data without knowing caching had kicked in. Explicit is better.
Pattern 1: fetch() for External APIs
For external HTTP APIs, fetch() with the next option is still the right tool. The three modes you'll actually use:
// lib/dal/posts.ts
// Always fresh — user-specific, real-time, or just shouldn't be cached
export async function getUserFeed(userId: string) {
const res = await fetch(`https://api.example.com/feed/${userId}`, {
cache: 'no-store',
});
if (!res.ok) throw new Error('Failed to fetch feed');
return res.json();
}
// Time-based ISR — content that changes occasionally
export async function getAllPosts() {
const res = await fetch('https://cms.example.com/api/posts', {
next: { revalidate: 3600 }, // rebuild cache every hour
});
return res.json();
}
// Static until explicitly invalidated — use tags for on-demand invalidation
export async function getPostBySlug(slug: string) {
const res = await fetch(`https://cms.example.com/api/posts/${slug}`, {
next: {
revalidate: false, // never expire automatically
tags: ['posts', `post-${slug}`],
},
});
return res.json();
}The tags array is what makes on-demand invalidation possible. You assign tags at fetch time, then call revalidateTag('posts') in a Server Action or webhook handler to purge every cached response with that tag.
Pattern 2: React.cache() for Database Deduplication
When you're querying a database directly — Prisma, Drizzle, any ORM — fetch() isn't involved, so request memoization doesn't apply. React.cache() fills that gap. It memoizes a function for the lifetime of one server request.
// lib/dal/users.ts
import { cache } from 'react';
import { prisma } from '@/lib/prisma';
// Memoized per request — multiple callers, one DB query
export const getCurrentUser = cache(async (userId: string) => {
return prisma.user.findUnique({
where: { id: userId },
select: { id: true, name: true, email: true, role: true },
});
});// app/(student)/layout.tsx
const layoutUser = await getCurrentUser(session.userId);
// app/(student)/dashboard/page.tsx — same render, returns memoized result
const pageUser = await getCurrentUser(session.userId);
// One DB query, two consumers. No coordination needed.React.cache() is not persistent. The memoized result lives for one request and gets discarded. If you need data to survive across requests, reach for unstable_cache() instead.
A common pattern in the DAL is to layer both: React.cache() wraps the inner call for deduplication, and unstable_cache() provides the persistent layer:
import { cache } from 'react';
import { unstable_cache } from 'next/cache';
import { prisma } from '@/lib/prisma';
// The persistent layer
const getCachedCourse = unstable_cache(
async (slug: string) => {
return prisma.course.findUnique({
where: { slug },
include: { instructor: { select: { name: true, avatar: true } } },
});
},
['course-by-slug'],
{ tags: ['courses'], revalidate: 3600 }
);
// Wraps with per-request deduplication
export const getCourseBySlug = cache(getCachedCourse);Pattern 3: unstable_cache() for Persistent DB Caching
unstable_cache() is the Data Cache equivalent for non-fetch calls. Query results get stored in the persistent Data Cache and survive across requests until invalidated by tag or time.
// lib/dal/courses.ts
import { unstable_cache } from 'next/cache';
import { prisma } from '@/lib/prisma';
export const getCoursesByCategory = unstable_cache(
async (category: string) => {
return prisma.course.findMany({
where: { category, status: 'PUBLISHED' },
orderBy: { createdAt: 'desc' },
include: {
_count: { select: { enrollments: true } },
},
});
},
['courses-by-category'], // key prefix — args are appended automatically
{
tags: ['courses'], // revalidateTag('courses') will purge this
revalidate: 60 * 60, // also revalidate after 1 hour regardless
}
);The cache key is important. unstable_cache uses the key prefix array plus the serialized function arguments to build a unique key per call. getCoursesByCategory('react') and getCoursesByCategory('typescript') get separate cache entries automatically.
unstable_cache is still exported from next/cache in 16.1.6 — the name has never been cleaned up and plenty of production apps depend on it. But it belongs to the previous caching model now: the Next.js docs page describing it is titled "Caching and Revalidating (Previous Model)". Treat it as the thing you keep working, not the thing you reach for in new code. 'use cache' is below.
Pattern 4: 'use cache' and Cache Components (Next.js 16)
Everything above is now, officially, the previous model — Next.js retitled that documentation page "Caching and Revalidating (Previous Model)" when 16 shipped. The current model is Cache Components, and its entry point is the 'use cache' directive.
If you followed this feature through the 15 canaries you probably have experimental: { dynamicIO: true } written down somewhere. That key no longer exists. On 16.1.6 it does not error — it warns and does nothing, which is the worst of both worlds:
⚠ Invalid next.config.ts options detected:
⚠ Unrecognized key(s) in object: 'dynamicIO' at "experimental"
? dynamicIO (invalid experimental key)The build then completes with caching quietly not enabled. The flag is now top-level and called cacheComponents:
// next.config.ts
const nextConfig: NextConfig = {
cacheComponents: true,
};You can tell it took effect from the build banner, which gains a third item:
▲ Next.js 16.1.6 (Turbopack, Cache Components)With that enabled, you can add 'use cache' to any async function or component:
// lib/dal/courses.ts
import { cacheTag, cacheLife } from 'next/cache';
export async function getFeaturedCourses() {
'use cache';
cacheTag('courses', 'featured'); // assign multiple tags
cacheLife('hours'); // built-in lifetime profile
return prisma.course.findMany({
where: { featured: true, status: 'PUBLISHED' },
take: 6,
orderBy: { enrollments: { _count: 'desc' } },
});
}You can also put 'use cache' at the component level:
// components/CourseSidebar.tsx
import { cacheTag, cacheLife } from 'next/cache';
export async function CourseSidebar({ courseId }: { courseId: string }) {
'use cache';
cacheTag(`course-${courseId}`, 'courses');
cacheLife('days');
const course = await prisma.course.findUnique({
where: { id: courseId },
select: { title: true, description: true, instructor: true },
});
return (
<aside>
<h2>{course?.title}</h2>
<p>{course?.description}</p>
</aside>
);
}The cacheLife profiles ('default', 'seconds', 'minutes', 'hours', 'days', 'weeks', 'max') have sensible defaults and can be customized in next.config.ts if you need different values.
A nice side effect: with cacheComponents on, next build prints the resolved lifetimes per route, so you can check the profile you picked is the profile you got. Building the two functions above gives:
Route (app) Revalidate Expire
├ ○ /cached 1h 1d
└ ○ /cached2 1d 1wcacheLife('hours') resolved to revalidate-after-1h / expire-after-1d, cacheLife('days') to 1d / 1w.
cacheComponents also tightens what's allowed elsewhere. Route segment configs are rejected outright rather than ignored — a leftover export const dynamic = 'force-dynamic' fails the build with Route segment config "dynamic" is not compatible with nextConfig.cacheComponents. Please remove it. Budget for that when you flip the flag on an existing app.
Revalidation: revalidateTag vs revalidatePath
Once data is cached, you need a way to invalidate it when something changes. Two APIs handle this:
revalidateTag('tag') — Marks all cached data with that tag as stale, across every route that uses it, then clears the Full Route Cache and Router Cache for those pages. Surgical and precise.
revalidatePath('/some/path') — Invalidates the cached data and the rendered output for one path. It's scoped by route, not by tag: after revalidatePath('/blog'), a /dashboard page reading the same ['posts']-tagged data still serves its stale copy.
In practice, reach for revalidateTag first. It's more precise and doesn't over-invalidate.
Next.js 16 adds a third primitive, updateTag, alongside these two. revalidateTag marks tagged data stale; updateTag expires it. Both are exported from next/cache. Note also that neither revalidatePath nor revalidateTag can be called from a proxy/middleware file — they only work in Server Functions and Route Handlers.
// actions/courses.ts
'use server';
import { revalidateTag } from 'next/cache';
import { prisma } from '@/lib/prisma';
import { redirect } from 'next/navigation';
export async function publishCourse(courseId: string) {
await prisma.course.update({
where: { id: courseId },
data: { status: 'PUBLISHED', publishedAt: new Date() },
});
// Purge all cache entries tagged 'courses' and 'featured'
revalidateTag('courses');
revalidateTag('featured');
// Purge any course-specific cache entry
revalidateTag(`course-${courseId}`);
redirect('/admin/courses');
}// app/api/webhooks/cms/route.ts — external CMS notifies on content change
export async function POST(request: Request) {
const secret = request.headers.get('x-webhook-secret');
if (secret !== process.env.CMS_WEBHOOK_SECRET) {
return new Response('Unauthorized', { status: 401 });
}
const { type, slug } = await request.json();
if (type === 'post.published' || type === 'post.updated') {
revalidateTag('posts');
revalidateTag(`post-${slug}`);
}
return new Response('ok');
}When revalidatePath makes sense:
// After a layout-level change — nav items, sidebar, anything shared
revalidatePath('/admin', 'layout'); // invalidates from this layout downward
// When you need a specific URL purged and you don't have tags set up
revalidatePath(`/blog/${slug}`);The 'layout' second argument tells Next.js to invalidate from that segment downward, useful for changes that affect a shared layout rather than just one page.
When NOT to Cache
Caching the wrong data is worse than not caching at all.
User-specific data. Anything that varies per user — profile, subscription status, permissions — should never land in the shared Data Cache. If one user's request populates the cache, the next user gets someone else's data.
// ❌ This caches the current user's profile globally
export const getMyProfileBad = unstable_cache(
async () => {
const session = await auth(); // varies per user!
return prisma.user.findUnique({ where: { id: session.user.id } });
},
['my-profile'], // ← doesn't include user ID — WRONG
);
// ✅ Use React.cache() for per-request deduplication only
export const getMyProfile = cache(async (userId: string) => {
return prisma.user.findUnique({ where: { id: userId } });
});Financial and transactional data. Account balances, order status, payment confirmations — always cache: 'no-store'. Stale financial data is a support ticket at best, a compliance issue at worst.
Data that changes faster than your revalidation window. If you set revalidate: 3600 but the data updates every 5 minutes, you're serving stale data 95% of the time and adding complexity for nothing. Skip the cache or use revalidateTag on mutations instead.
Cookie-dependent data. Next.js automatically opts routes out of static rendering when they read cookies or headers. But if you wrap cookie-dependent logic in unstable_cache() and the function ignores that, you've introduced a subtle cross-user data leak.
Quick Reference
| Scenario | Tool | Persists? |
|---|---|---|
| Same fetch call in multiple components | Built-in memoization | No |
| Same DB query in multiple components, one request | React.cache() | No |
| External API with time-based freshness | fetch + next.revalidate | Yes |
| External API with on-demand invalidation | fetch + next.tags + revalidateTag | Yes |
| DB query, persistent, time-based | unstable_cache() + revalidate | Yes |
| DB query, persistent, on-demand | unstable_cache() + tags + revalidateTag | Yes |
| Any async function, current model | 'use cache' + cacheTag + cacheLife (needs cacheComponents) | Yes |
| Invalidate by tag after a mutation | revalidateTag() / updateTag() | — |
| Invalidate a specific URL | revalidatePath() | — |
The mental model that ties this together: caching in the App Router is opt-in, layered, and tag-driven. React.cache() deduplicates within a request. unstable_cache() and fetch with next.revalidate persist across requests. Tags give you surgical invalidation when data changes. If you find yourself calling revalidatePath() for everything, you're probably over-invalidating — revalidateTag() is almost always the right call when you have tags in place.
Get the tagging strategy right from the start and the rest of the caching model falls into place.
Comments (0)
No comments yet. Be the first to share your thoughts!