Next.js Caching Strategies Explained: SSR, SSG, ISR, Route Cache, Data Cache and Revalidation
Caching in Next.js App Router is powerful but genuinely confusing until you build a mental model of its four distinct layers. After shipping multiple production Next.js 15 applications, here is how I think about and apply each layer.
Why Next.js Caching Is Confusing — And How to Think About It
Caching in Next.js App Router is not one system — it is four overlapping systems that each operate at a different layer of the request lifecycle. Treating them as one is why most engineers end up either over-caching stale data or disabling caching entirely and losing all performance gains.
After shipping multiple production Next.js 15 applications — including a multi-tenant SaaS ERP platform at V For Technology — here is the mental model I use. Each layer has a distinct job. Once you understand the job, the API decisions follow naturally.
The Four Caching Layers at a Glance
- Request Memoization — deduplicates identical
fetch()calls within a single render pass - Data Cache — persists fetch results across requests and deployments on the server
- Full Route Cache — caches the complete rendered HTML and RSC payload of a route at build time
- Router Cache — client-side in-memory cache of visited route segments for the current browser session
These layers compose. A request may be served entirely from the Router Cache, skip the network, and never touch the Data Cache at all. Understanding which layer is serving a given request — and which layer to invalidate — is the core skill.
1. Request Memoization
This is the most invisible layer. During a single React render tree, Next.js automatically deduplicates identical fetch() calls to the same URL with the same options. You can call fetch('/api/user/me') in five different Server Components in the same render and only one actual HTTP request goes out.
// UserProfile.tsx — Server Component
const user = await fetch('https://api.example.com/user/me').then(r => r.json());
// UserHeader.tsx — different Server Component file, same render tree
const user = await fetch('https://api.example.com/user/me').then(r => r.json());
// This fetch is deduplicated. One network request total.
Scope: single render pass only. This cache is discarded after the render completes. It does not persist across requests. No configuration is needed — it is fully automatic.
2. Data Cache
This is the persistent server-side cache. When a fetch() call is cached here, its response survives across multiple user requests and across deployments until you explicitly revalidate it.
In Next.js 15, fetch() is not cached by default — you must opt in. This reversed from Next.js 13 and 14 where caching was opt-out. Keep this in mind if you are migrating an existing application.
Opting In to the Data Cache
// Cache indefinitely until manually revalidated
const data = await fetch('https://api.example.com/products', {
cache: 'force-cache',
});
// Cache for 60 seconds, then revalidate in the background (stale-while-revalidate)
const data = await fetch('https://api.example.com/products', {
next: { revalidate: 60 },
});
// Tag this cache entry for targeted on-demand invalidation
const data = await fetch('https://api.example.com/products', {
next: { revalidate: 3600, tags: ['products'] },
});
Opting Out of the Data Cache
// Always fetch fresh data — bypasses the Data Cache entirely
const data = await fetch('https://api.example.com/live-prices', {
cache: 'no-store',
});
Cache Invalidation with revalidateTag and revalidatePath
Tag-based invalidation is the most powerful caching tool in Next.js. You tag fetch calls at the data layer, then purge those tags from a Server Action or Route Handler when the underlying data changes. The invalidation is immediate and surgical.
// app/actions/products.ts
'use server';
import { revalidateTag } from 'next/cache';
export async function updateProduct(id: string, data: UpdateProductDTO) {
await db.product.update({ where: { id }, data });
// Instantly invalidates all fetch() calls tagged 'products'
// across the entire application — all routes that used this data
revalidateTag('products');
}
// Use revalidatePath when you want to invalidate a specific route's Full Route Cache
import { revalidatePath } from 'next/cache';
export async function updateUserProfile(userId: string, data: ProfileDTO) {
await db.user.update({ where: { id: userId }, data });
revalidatePath(`/users/${userId}`);
revalidatePath('/dashboard'); // also purge the dashboard which shows user info
}
In the Ackura ERP platform, every fetch call is tagged by module and entity: ['inventory', 'product-list'], ['hr', 'employee-123']. When a mutation occurs in a Server Action, we invalidate the specific tags. No blanket cache clearing, no stale inventory counts showing to other users of the same tenant.
3. Full Route Cache
At build time, Next.js renders static routes — those with no dynamic data dependencies — and stores the complete HTML and RSC payload on disk. Requests to these routes are served instantly from this cache without touching your server or database at all.
A route is statically rendered and eligible for Full Route Cache when:
- It contains no dynamic functions:
cookies(),headers(), orsearchParamsaccess - All its
fetch()calls useforce-cacheor arevalidateinterval - It is not explicitly marked
dynamic = 'force-dynamic'
Controlling Route Rendering Mode
// Force this route to always render dynamically — opts out of Full Route Cache
export const dynamic = 'force-dynamic';
// Using no-store fetch also makes the whole route dynamic automatically
const data = await fetch(url, { cache: 'no-store' });
// Force static even if Next.js would otherwise make it dynamic
export const dynamic = 'force-static';
// ISR — static HTML with time-based background regeneration
export const revalidate = 3600; // regenerate this route every hour
ISR: Incremental Static Regeneration
ISR is the pattern of generating static pages at build time, then regenerating them in the background after a revalidation window expires — serving the cached version to users while the new one builds. This is the sweet spot for content that changes infrequently but must be fast globally.
// app/blog/[slug]/page.tsx
export const revalidate = 86400; // revalidate every 24 hours in the background
// Pre-render known slugs at build time for instant delivery
export async function generateStaticParams() {
const posts = await getAllPostSlugs();
return posts.map(slug => ({ slug }));
}
// New slugs not listed in generateStaticParams are rendered on-demand
// then cached — subsequent requests are served from cache
export const dynamicParams = true;
This portfolio's blog pages use exactly this pattern: statically generated at build time for instant global delivery via CDN, with a 24-hour background revalidation window.
4. Router Cache (Client-Side)
The Router Cache lives in the browser. When you navigate between routes, Next.js stores the RSC payload in memory so that navigating back is instant — no server round-trip needed. This cache is automatic and session-scoped. It clears when the browser tab closes.
Static route segments are cached for 5 minutes in the Router Cache. Dynamic segments are cached for 30 seconds by default.
The important implication: after a Server Action mutates data and calls revalidatePath() on the server, the Router Cache on the client is also cleared for that path. The next navigation to that route fetches fresh RSC payload.
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
export async function createInvoice(formData: FormData) {
await db.invoice.create({ data: parseInvoiceForm(formData) });
revalidatePath('/invoices'); // clears Data Cache + Full Route Cache
redirect('/invoices'); // Router Cache for /invoices also gets cleared
}
Caching Strategy Decision Framework
Here is how I decide the caching strategy for any data in a production application:
- Never changes — config, static copy:
force-cachewith no revalidation. Served from Full Route Cache indefinitely. - Changes infrequently — blog, product catalog:
next: { revalidate: N }for time-based, or tag-based on mutation via Server Actions. - Changes on user action — orders, profiles:
revalidateTagtriggered from Server Actions after each mutation. - Real-time data — live prices, notifications:
no-storeon fetch,force-dynamicon the route. Or use WebSockets or Server-Sent Events. - Per-user data — any authenticated content: Always
no-store. The Data Cache is shared across all users. Never cache user-specific responses there.
Common Mistakes
- Caching per-user data in the Data Cache. It is shared across all users at the server level. Caching
/api/user/profilewithoutno-storewill serve one user's profile to another user. This is both a performance anti-pattern and a security issue. - Using
force-dynamicas a default. It disables all caching for that route. Reasonable for authenticated dashboards. Wrong for marketing pages that could be fully static. - Not tagging fetches. Without tags, your only invalidation option is
revalidatePath, which nukes the entire route's cache. Tags give you surgical precision. - Forgetting the Router Cache. You call
revalidateTagserver-side and expect the UI to update immediately, but the user's browser is still showing a cached route segment. Pair server-side invalidation withrouter.refresh()on the client or a redirect when the UX requires immediate reflection.
Production Architecture Pattern
// Canonical pattern for shared, mutable data in Next.js App Router
// 1. Fetch with tags at the data layer
async function getProducts(categoryId: string) {
const res = await fetch(`${API_URL}/products?category=${categoryId}`, {
next: {
revalidate: 300, // 5-min background revalidation
tags: ['products', `category-${categoryId}`], // fine-grained invalidation tags
},
});
if (!res.ok) throw new Error('Failed to fetch products');
return res.json() as Promise<Product[]>;
}
// 2. Invalidate precisely from the mutation Server Action
'use server';
export async function deleteProduct(id: string) {
await db.product.delete({ where: { id } });
revalidateTag('products'); // purges all product-tagged caches across all routes
}
Conclusion
Next.js caching is a spectrum between performance and data freshness. The App Router gives you precise control at every layer — but that control requires intentional decisions. Engineers who get it wrong either disable caching entirely and build slow applications, or enable it globally and ship stale data to users.
The right approach is data-driven: understand what each piece of data looks like, how often it changes, whether it is user-specific, and what freshness guarantees your users actually need. Apply the appropriate strategy to each fetch call. Tags make this maintainable at scale. For more on how these patterns were applied in a real production ERP, see the Ackura ERP project.
Frequently Asked Questions
What is the difference between revalidateTag and revalidatePath?
revalidateTag invalidates all cached fetch responses that were tagged with a specific string, across every route that used that data. revalidatePath invalidates the Full Route Cache for a specific URL. Use tags for data-level invalidation — more precise. Use revalidatePath when you need to purge an entire page's cache regardless of which data it used.
Does ISR still work in Next.js 15 App Router?
Yes. Export revalidate from a route segment file and Next.js applies stale-while-revalidate semantics: serve the cached version to the current request while regenerating in the background. You can also trigger on-demand ISR programmatically via revalidatePath or revalidateTag from a Route Handler or Server Action.
When should I use force-dynamic?
Use force-dynamic for routes that must always show the latest data and where the cost of a full server render per request is acceptable — authenticated dashboards, admin panels, and live data feeds. For everything else, prefer time-based or tag-based revalidation so the Full Route Cache continues doing its job.
Is per-user data safe to cache in Next.js?
Not in the shared Data Cache. Use cache: 'no-store' on any user-specific fetch and handle client-side caching with React Query or SWR using user-scoped query keys. If you need server-side caching scoped to a user, use the unstable_cache utility with the user ID as part of the cache key.