Understand all Next.js 15 caching layers: fetch cache options, Route Segment Config, full-route cache, data cache, router cache, and on-demand revalidation with revalidatePath and revalidateTag. This practical guide covers step-by-step setup, real-world code snippets, security hardening, and common production edge cases.
Caching is one of the most powerful — and frequently misunderstood — features in Next.js 15's App Router. When wielded correctly, it transforms a slow, database-hammering application into a blazing-fast experience that serves millions of requests per second from the edge. When misunderstood, it leads to stale data, confusing bugs, and frustrated developers wondering why their changes aren't appearing in production.
Next.js 15 ships with a multi-layered caching system that operates at several independent levels: the Request Memoization layer (per-render deduplication), the Data Cache (persistent server-side fetch storage), the Full Route Cache (pre-rendered HTML and RSC payloads on disk), and the Router Cache (client-side in-memory navigation cache). Each layer has its own lifecycle, invalidation mechanism, and configuration surface — and they all interact with each other in subtle ways.
In this article, we will dissect every caching layer in Next.js 15, show you the exact APIs and options available, explain when each cache kicks in, and demonstrate real-world patterns for on-demand revalidation so you always have full control over what your users see and when they see it.
Prerequisites
Before diving in, you should be comfortable with the following:
- Next.js App Router fundamentals — Server Components, Client Components, layouts, and pages.
- TypeScript — all examples are written in TypeScript.
- Basic HTTP caching concepts — understanding terms like TTL, stale-while-revalidate, and cache invalidation.
- Node.js 18+ — required by Next.js 15.
You should have a Next.js 15 project scaffolded. If not, run:
npx create-next-app@latest my-app --typescript --app
1. Understanding Next.js Caching Architecture Overview
Next.js 15 operates across four distinct caching layers. Understanding which layer does what — and their order of precedence — is the foundation for everything else.
Request → Router Cache (client) → Full Route Cache (server) → Data Cache (server) → Origin / DB
| Cache Layer | Location | Default | Scope |
|---|---|---|---|
| Request Memoization | Server (per-render) | Automatic | Single render tree |
| Data Cache | Server (persistent) | force-cache | Across requests & deploys |
| Full Route Cache | Server (persistent) | Enabled for static routes | Across requests & deploys |
| Router Cache | Client (in-memory) | Automatic | Single browser session |
Request Memoization automatically deduplicates identical fetch() calls made during the same render cycle. If two Server Components both fetch https://api.example.com/user/1, Next.js will only make one actual HTTP request. This is built-in React behavior extended by Next.js and requires no configuration.
2. fetch() Cache Options — force-cache, no-store, revalidate
The primary way to control the Data Cache is via the cache and next.revalidate options on the native fetch() call. Next.js extends the browser fetch API on the server to intercept and cache responses.
// app/products/page.tsx
// force-cache: Cache indefinitely (until manual revalidation)
async function getProducts() {
const res = await fetch("https://api.example.com/products", {
cache: "force-cache", // This is the DEFAULT in Next.js 15
});
if (!res.ok) throw new Error("Failed to fetch products");
return res.json();
}
// no-store: Never cache — always fetch fresh data
async function getLiveStockPrice() {
const res = await fetch("https://api.example.com/stock/price", {
cache: "no-store",
});
return res.json();
}
// Time-based revalidation: cache for 60 seconds, then revalidate
async function getNewsFeed() {
const res = await fetch("https://api.example.com/news", {
next: { revalidate: 60 }, // seconds
});
return res.json();
}
export default async function ProductsPage() {
const products = await getProducts();
const news = await getNewsFeed();
return (
<main>
<ul>
{products.map((p: { id: string; name: string }) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
</main>
);
}
next: { revalidate: 0 } is equivalent to cache: "no-store" — both opt out of caching entirely for that specific request.
Important: In Next.js 15,
fetchwithcache: "force-cache"is still the default, but any singleno-storeor dynamic API usage in a route segment causes the entire segment to become dynamic unless overridden by Route Segment Config.
3. Route Segment Config — dynamic, revalidate, fetchCache
Route Segment Config lets you control caching behavior at the page, layout, or route handler level by exporting special constants from the file itself. These act as overrides that apply to all fetch() calls and rendering within the segment.
// app/dashboard/page.tsx
// Force the entire segment to render dynamically on every request
export const dynamic = "force-dynamic";
// OR: Force static rendering, cache for 120 seconds
export const revalidate = 120;
// OR: Control how all fetch() calls in this segment behave by default
export const fetchCache = "force-no-store"; // Options: 'auto' | 'default-cache' | 'only-cache' | 'force-cache' | 'force-no-store' | 'default-no-store' | 'only-no-store'
export default async function DashboardPage() {
// This fetch will respect the segment-level config above
const data = await fetch("https://api.example.com/dashboard");
const json = await data.json();
return <div>{JSON.stringify(json)}</div>;
}
// app/blog/[slug]/page.tsx
// generateStaticParams + revalidate = ISR (Incremental Static Regeneration)
export const revalidate = 3600; // Re-render at most once per hour
export async function generateStaticParams() {
const posts = await fetch("https://api.example.com/posts").then((r) =>
r.json()
);
return posts.map((post: { slug: string }) => ({ slug: post.slug }));
}
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await fetch(`https://api.example.com/posts/${slug}`).then((r) =>
r.json()
);
return <article>{post.content}</article>;
}
4. Full-Route Cache (Static vs Dynamic Rendering)
The Full Route Cache stores the rendered HTML and React Server Component (RSC) payload on the server's filesystem (or CDN edge) for statically rendered routes. When a user requests a statically cached route, Next.js serves the pre-built output instantly without touching your database or running any server code.
A route is rendered statically (and therefore full-route cached) when:
- It doesn't use any Dynamic APIs (
cookies(),headers(),searchParams, etc.) - All
fetch()calls useforce-cacheor time-basedrevalidate
A route becomes dynamic when:
export const dynamic = "force-dynamic"is set- Any
fetch()usescache: "no-store" - Dynamic APIs like
cookies()orheaders()are called at render time
// app/static-page/page.tsx — Will be Full-Route Cached
export default async function StaticPage() {
const data = await fetch("https://api.example.com/config", {
cache: "force-cache",
}).then((r) => r.json());
return <div>{data.message}</div>;
}
// app/user-profile/page.tsx — Will NOT be Full-Route Cached (dynamic)
import { cookies } from "next/headers";
export default async function UserProfilePage() {
const cookieStore = await cookies(); // Dynamic API — opts out of Full Route Cache
const token = cookieStore.get("auth_token")?.value;
const profile = await fetch("https://api.example.com/me", {
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
}).then((r) => r.json());
return <div>Welcome, {profile.name}</div>;
}
5. Data Cache — How fetch Results Are Cached Server-Side
The Data Cache is a persistent, server-side key-value store where Next.js stores fetch() responses. Unlike Request Memoization (which resets per render), the Data Cache persists across requests and across deployments (on Vercel; self-hosted environments persist to the filesystem).
The cache key is derived from the full URL, headers, body, and next options of the fetch call. Two identical fetch calls in different components will share the same cached entry, making the Data Cache critical for performance in high-traffic applications.
// lib/api.ts — Reusable data-fetching utilities
export async function getPost(id: string) {
const res = await fetch(`https://api.example.com/posts/${id}`, {
next: {
revalidate: 300, // 5 minutes TTL in the Data Cache
tags: ["posts", `post-${id}`], // Tags for on-demand invalidation
},
});
if (!res.ok) throw new Error(`Failed to fetch post ${id}`);
return res.json() as Promise<{ id: string; title: string; content: string }>;
}
export async function getAllPosts() {
const res = await fetch("https://api.example.com/posts", {
next: {
revalidate: 60,
tags: ["posts"], // Broad tag — invalidating "posts" clears all cached posts
},
});
return res.json() as Promise<Array<{ id: string; title: string }>>;
}
6. Router Cache (Client-Side Cache)
The Router Cache lives entirely in the browser's memory and caches RSC payloads for routes the user has visited or prefetched. When the user navigates back to a previously visited page, Next.js can serve the cached RSC payload instantly — zero round-trips to the server.
The Router Cache is automatic and not directly configurable from route files, but you can influence it:
- Static segments are cached for 5 minutes by default.
- Dynamic segments are cached for 30 seconds by default.
- Calling
router.refresh()invalidates the cache for the current route. - Server Actions that call
revalidatePathorrevalidateTagalso clear relevant Router Cache entries.
// app/components/RefreshButton.tsx — Force router cache invalidation
"use client";
import { useRouter } from "next/navigation";
export function RefreshButton() {
const router = useRouter();
return (
<button
onClick={() => router.refresh()}
className="px-4 py-2 bg-blue-600 text-white rounded"
>
Refresh Data
</button>
);
}
7. On-Demand Revalidation with revalidatePath
revalidatePath purges the Full Route Cache and marks affected Data Cache entries as stale for a specific URL path. It is most commonly called inside Server Actions after a mutation.
// app/actions/post-actions.ts
"use server";
import { revalidatePath } from "next/cache";
export async function createPost(formData: FormData) {
const title = formData.get("title") as string;
const content = formData.get("content") as string;
// Save to database
await fetch("https://api.example.com/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, content }),
cache: "no-store",
});
// Invalidate the blog listing and any cached post pages
revalidatePath("/blog"); // Invalidates the /blog page
revalidatePath("/blog/[slug]", "page"); // Invalidates all /blog/[slug] pages
revalidatePath("/", "layout"); // Invalidates all pages sharing root layout
}
// app/admin/new-post/page.tsx
import { createPost } from "@/app/actions/post-actions";
export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" placeholder="Post title" required />
<textarea name="content" placeholder="Post content" required />
<button type="submit">Publish Post</button>
</form>
);
}
8. Tag-Based Revalidation with revalidateTag
revalidateTag is more surgical than revalidatePath — it invalidates all Data Cache entries that were tagged with a specific string, regardless of which URL path they belong to. This is ideal for content models where a single piece of data is referenced across many pages.
// app/actions/product-actions.ts
"use server";
import { revalidateTag } from "next/cache";
export async function updateProductPrice(
productId: string,
newPrice: number
): Promise<{ success: boolean }> {
const res = await fetch(`https://api.example.com/products/${productId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ price: newPrice }),
cache: "no-store",
});
if (!res.ok) return { success: false };
// Invalidate all fetches tagged with "products" or the specific product tag
revalidateTag("products");
revalidateTag(`product-${productId}`);
return { success: true };
}
// lib/products.ts — Tag your fetches for targeted invalidation
export async function getProduct(id: string) {
return fetch(`https://api.example.com/products/${id}`, {
next: {
tags: ["products", `product-${id}`],
revalidate: 3600,
},
}).then((r) => r.json());
}
export async function getRelatedProducts(categoryId: string) {
return fetch(`https://api.example.com/products?category=${categoryId}`, {
next: {
tags: ["products", `category-${categoryId}`],
revalidate: 3600,
},
}).then((r) => r.json());
}
9. unstable_cache for Custom Caching
unstable_cache lets you cache the result of any async function — not just fetch() calls — inside the Data Cache. This is invaluable for caching database query results, complex computations, or third-party SDK calls that don't use fetch.
// lib/db-cache.ts
import { unstable_cache } from "next/cache";
import { db } from "@/lib/database"; // Your DB client (e.g., Prisma, Drizzle)
// Cache a Prisma query result for 5 minutes with a tag for invalidation
export const getCachedUserById = unstable_cache(
async (userId: string) => {
// This could be any async operation — DB query, SDK call, etc.
const user = await db.user.findUnique({
where: { id: userId },
select: { id: true, name: true, email: true, avatarUrl: true },
});
return user;
},
["user-by-id"], // Cache key prefix — must be unique per function
{
revalidate: 300, // 5-minute TTL
tags: ["users"], // Tag for revalidateTag("users") invalidation
}
);
// Usage in a Server Component
export async function UserCard({ userId }: { userId: string }) {
// Results are stored in the Data Cache — DB is only hit on cache miss
const user = await getCachedUserById(userId);
if (!user) return <div>User not found</div>;
return (
<div className="card">
<img src={user.avatarUrl ?? ""} alt={user.name} />
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
// app/actions/user-actions.ts — Invalidate the unstable_cache via tags
"use server";
import { revalidateTag } from "next/cache";
export async function updateUserProfile(userId: string, name: string) {
await db.user.update({ where: { id: userId }, data: { name } });
revalidateTag("users"); // Clears all unstable_cache entries tagged "users"
}
Caching Options Comparison Table
| Method | Cache Type | Persistence | Invalidation | Best For |
|---|---|---|---|---|
fetch({ cache: "force-cache" }) | Data Cache | Cross-request | Manual / time-based | Static API data |
fetch({ cache: "no-store" }) | None | None | N/A | Real-time data |
fetch({ next: { revalidate: N } }) | Data Cache | Cross-request | Time-based (ISR) | Semi-static content |
fetch({ next: { tags: [...] } }) | Data Cache | Cross-request | revalidateTag() | Tag-based invalidation |
export const revalidate = N | Full Route Cache | Cross-request | Time-based | Entire route TTL |
export const dynamic = "force-dynamic" | None | None | N/A | Always-fresh pages |
unstable_cache(fn, key, opts) | Data Cache | Cross-request | revalidateTag() / time | Non-fetch async ops |
revalidatePath(path) | Full Route Cache | N/A (clears) | On-demand | Post-mutation refresh |
revalidateTag(tag) | Data Cache | N/A (clears) | On-demand | Targeted invalidation |
router.refresh() | Router Cache | N/A (clears) | On-demand (client) | Client-side refresh |
Conclusion
Next.js 15's caching system is a layered, composable architecture that gives you granular control over performance and data freshness. Here are the key takeaways:
-
Understand the four layers. Request Memoization, Data Cache, Full Route Cache, and Router Cache each serve a distinct purpose and operate independently. Confusion usually stems from conflating them.
-
Tag everything you cache. Using
next: { tags: [...] }onfetch()and inunstable_cacheoptions costs nothing but gives you surgicalrevalidateTag()precision when data changes. -
Prefer Server Actions for mutations. Calling
revalidatePathorrevalidateTaginside a Server Action is the idiomatic Next.js 15 pattern — it keeps your cache invalidation logic co-located with your mutation logic. -
Use
unstable_cachefor non-fetch async work. Database queries, ORM calls, and third-party SDK operations don't automatically benefit from the Data Cache — wrap them withunstable_cacheto get the same ISR-style behavior. -
force-dynamicis your escape hatch, not your default. Opting every page out of caching eliminates the performance advantages that make Next.js compelling. Reserve dynamic rendering for routes that genuinely require real-time data on every request. -
Monitor and audit your cache behavior. Use
next buildoutput and Next.js logging in development to confirm which routes are statically rendered versus dynamic, and adjust your caching strategy accordingly.
Mastering Next.js 15 caching turns what appears to be a complex system into a powerful toolkit. Start with sensible defaults — time-based revalidation and tags — and layer in on-demand invalidation as your content model demands it.
Written by Dev Pattern LabsCore Engineering
Dev Pattern Labs is an engineering lab and digital consultancy specializing in custom Next.js web applications, WooCommerce REST API integrations, and workflow automation for growing businesses.
Recommended Companion Articles

Building REST APIs with Next.js Route Handlers
Learn to build production-ready REST APIs with Next.js Route Handlers. Covers GET, POST, PUT, DELETE methods, NextRequest/NextResponse, query params, CORS, streaming responses, and dynamic segments.

Next.js Middleware: Auth Guards, Redirects & Edge Logic
Master Next.js Middleware for authentication guards, redirects, rewrites, and edge logic. Learn matcher config, cookie-based auth, geolocation redirects, and A/B testing patterns.

Server Components vs Client Components in Next.js: When to Use Each
Learn the fundamental differences between React Server Components and Client Components in Next.js. Understand bundle size impact, data access patterns, composition strategies, and the 'use client' boundary.
