Next.js App Router Caching: What Changed, What Breaks, and How I Handle It
Next.js caching in the App Router is powerful but confusing — especially after v15 flipped the defaults. Here's what the four caches actually do, what breaks silently, and the patterns I use in every production app.
If you've built anything with the Next.js App Router, you've hit this: you update data in the database, refresh the page, and nothing changes. Or the opposite — you expect a page to be fast and cached, but it's slow and rendering fresh on every request.
App Router caching is the most powerful feature in Next.js and also the most misunderstood. Part of the confusion is that v15 flipped the defaults entirely. What was opt-out before is now opt-in. If you migrated without auditing, you probably have pages that are either stale or unexpectedly slow.
This is what I've learned running it across seven production sites.
The Four Caches (Quick Map)#
Next.js App Router has four distinct caches. They operate at different layers, and conflating them is where most bugs come from.
Request Memoization — deduplicates identical fetch calls within a single render tree. If two Server Components both call fetch('/api/user/123'), the request only fires once. This is automatic and you don't control it.
Data Cache — persists fetch results across requests and deployments. In v13/v14, this was on by default. In v15+, it's opt-in.
Full Route Cache — stores the rendered HTML and RSC payload of a route on the server. Static routes are fully cached here until you revalidate.
Router Cache — a client-side in-memory cache of RSC payloads. In v14, this cached Page components aggressively. In v15+, it no longer does by default.
You'll mostly interact with the Data Cache and Full Route Cache. The others are automatic.
What v15 Changed#
Before v15, fetch was cached by default. Every fetch inside a Server Component behaved like this:
// v13/v14 implicit default — you never wrote this, Next.js applied it
fetch('https://api.example.com/data', {
cache: 'force-cache',
});
This made pages fast out of the box, but caused the most common bug in the ecosystem: stale data that refused to update no matter how many times you refreshed.
Starting in v15, the default flipped:
// v15+ implicit default
fetch('https://api.example.com/data', {
cache: 'no-store',
});
GET Route Handlers also stopped being cached by default. And cookies(), headers(), and params became async.
The practical result: pages that used to be cached and fast are now dynamic and slower after migration, unless you explicitly opt them back in.
What Breaks Silently#
Stale data after mutations (v14 and earlier). You update a row in your database via a Server Action. The page re-renders, but shows the old value. The Data Cache is still serving the previous fetch result. Without calling revalidatePath or revalidateTag after the mutation, the cache never knows anything changed.
'use server';
import { revalidatePath } from 'next/cache';
export async function updatePost(id: string, title: string) {
await supabase.from('blog_posts').update({ title }).eq('id', id);
revalidatePath('/blog');
}
Pages going dynamic unexpectedly. If any Server Component in a route uses cookies(), headers(), or reads searchParams, Next.js opts the entire route into dynamic rendering — bypassing the Full Route Cache, even if 95% of the page has nothing dynamic in it.
// This one line makes the whole route dynamic
const cookieStore = await cookies();
If you only need the cookie value in one small part of the layout, push that logic into a child Server Component. The parent route stays static; only the leaf that actually reads the cookie goes dynamic.
Tag mismatches. If you tag a fetch but the string doesn't match exactly when calling revalidateTag, nothing gets invalidated and you'll spend an hour wondering why your mutation isn't working.
// Tagging the fetch
const data = await fetch('/api/posts', {
next: { tags: ['posts'] },
});
revalidateTag('posts'); // ✓ works
revalidateTag('post'); // ✗ does nothing — exact match required
How I Handle It#
For data that changes infrequently, I use a revalidate value on the fetch or at the route segment level:
// On a specific fetch
const data = await fetch('/api/config', {
next: { revalidate: 3600 },
});
// Or applied to all fetches in a route segment
export const revalidate = 3600;
For data that changes on user action, I tag fetches and invalidate the tag in the Server Action. If the data source is Supabase (not a native fetch), I wrap it with unstable_cache:
import { unstable_cache } from 'next/cache';
const getCachedPosts = unstable_cache(
async () => {
const { data } = await supabase
.from('blog_posts')
.select('*')
.eq('published', true);
return data;
},
['published-posts'],
{ tags: ['posts'], revalidate: 60 },
);
'use server';
import { revalidateTag } from 'next/cache';
export async function publishPost(id: string) {
await supabase.from('blog_posts').update({ published: true }).eq('id', id);
revalidateTag('posts');
}
For truly dynamic data — dashboards, user-specific pages, anything that must always be fresh — I don't fight the cache at all:
export const dynamic = 'force-dynamic';
This bypasses the Full Route Cache entirely. Use it for authenticated sections where per-user data makes caching useless anyway.
The Pattern I Use in Every App#
Across all seven sites, the same structure holds:
- Marketing and content pages (
/,/blog,/about):revalidate = 3600or tag-based invalidation. Fast on first load, updates when content changes. - Dynamic content routes (
/blog/[slug]):generateStaticParamsto pre-render known paths at build time,revalidatefor new posts. - Authenticated routes (
/dashboard,/settings):force-dynamic. No point caching per-user data server-side. - All Server Actions that mutate: always call
revalidatePathorrevalidateTagat the end, without exception. Skipping this makes mutations look broken from the user's perspective even when they succeed.
The mental shift that made this click: caching in the App Router isn't magic you turn on — it's a contract between your data layer and your routes. You define how fresh data needs to be, and you honor that contract when data changes.
Once you think of it that way, the four caches stop feeling like gotchas and start feeling like knobs you actually want to turn.
Freelance
Besoin d'aide sur ce sujet ?
Je peux aider sur les migrations, nouveaux produits et performances web.
Me contacter →