Complete Guide to Next.js Caching: Master When to Use revalidate

1 AM. I’m staring at that damn “stale data” on the screen, refreshing for the 20th time. I manually updated the title in the database 10 minutes ago, but the page won’t budge. Opening the code, revalidate: 60 is right there, crystal clear. Furiously deleted it, changed to revalidate: 10. Restarted the server. Refreshed. Still old.
You know that feeling?
Honestly, Next.js’s caching mechanism might be the most frustrating part of the entire framework. Four layers of caching, three revalidate methods, and a breaking change from version 14 to 15. You think setting revalidate: 60 is enough? Nope - could be Router Cache messing around, could be Full Route Cache not invalidating, or you might be testing in dev mode.
This article won’t lecture you about “caching is an important means to improve performance” - that’s technically correct but totally useless. I’ll tell you straight up:
- What are the four layers of cache in Next.js and what does each do
- What’s the difference between revalidatePath, revalidateTag, and updateTag - which one should you use
- How to troubleshoot layer by layer when data won’t update
If you’ve ever faced data not updating, revalidate not working, or confusion about which API to use, the next 12 minutes might save you several all-nighters.
Why is Next.js Caching So Complex?
Why so many layers?
To be honest, when I first saw Next.js has four layers of caching, my expression was probably the same as yours right now. Request Memoization? Full Route Cache? What are these? Can’t it be simpler?
But think about it calmly - each layer of caching solves performance problems in different scenarios:
- In your component tree, 10 components all need user info - you can’t make 10 requests, right?
- Your blog post list doesn’t update that often - you can’t re-render on every visit, right?
- User hits the back button - you can’t make them wait for page reload, right?
Each layer has its responsibility. The problem is, they interact with each other. That’s why when you update data, you don’t know which cache to clear.
Next.js 14 vs 15: A Caching Revolution
End of 2024, Next.js 15 dropped a bomb: no more default caching for fetch requests.
Before (14):
fetch(url) // Default cached, equals cache: 'force-cache'Now (15):
fetch(url) // Default not cached, equals cache: 'no-store'This change caused many people to see performance tank after upgrading, because previously auto-cached data now isn’t cached at all. Vercel forums were flooded with complaints, but their reasoning was: “Explicit is better than implicit, caching should be a developer’s conscious choice, not default behavior.”
Sounds reasonable. But for existing projects, this is a breaking change.
Four-Layer Cache Overview
Simply put, data goes through these four layers from server to user browser:
Request Memoization (Request memoization)
Scope: Single request render cycle
Manager: ReactData Cache (Data cache)
Scope: Server-side, persists across requests
Manager: Next.jsFull Route Cache (Full route cache)
Scope: Server-side, static routes
Manager: Next.jsRouter Cache (Router cache)
Scope: Client browser memory
Manager: Next.js
The data flow looks roughly like this:
User Visit → Router Cache (Client) → Full Route Cache (Server)
↓
Data Cache → Request Memoization → Data SourceYour revalidate mainly affects Data Cache and Full Route Cache. Router Cache needs router.refresh() or hard refresh to clear.
This is why sometimes you’ve clearly revalidated, but the client still shows old data on refresh - because Router Cache is still there.
Next chapter, I’ll break down these four caches layer by layer, telling you what each does, when it activates, when it expires.
Four-Layer Cache Mechanism Explained
Request Memoization
What is it?
This is actually a React 18 feature, not something Next.js specifically created. Simply put, during one render cycle, if multiple components make the same GET request, React automatically merges them into one.
For example:
// app/page.tsx
async function UserProfile() {
const user = await fetch('https://api.example.com/user/123')
return <div>{user.name}</div>
}
async function UserAvatar() {
const user = await fetch('https://api.example.com/user/123') // Same request
return <img src={user.avatar} />
}
export default function Page() {
return (
<>
<UserProfile />
<UserAvatar />
</>
)
}Both components request the same URL, but actually only one request is made. React remembers the first result and uses the cache for the second.
Very Short Lifetime
This cache only lasts during a single render process. When rendering ends, the cache clears. Next time a user refreshes the page, it’s a fresh request.
Notes
- Only works for Server Components
- Only works for GET requests, POST/PUT won’t be memoized
- Might not see the effect in dev environment, because every code change triggers re-render
When would you use it?
You probably don’t need to care about it at all. This is an optimization React automatically does for you, can’t manually control. I mention it just so you know: if you write the same fetch in multiple components, don’t worry about multiple requests.
Data Cache
This is the key
Data Cache is the core of Next.js caching mechanism. It stores fetch request results on the server’s file system, persisting across requests, users, and deployments.
Next.js 14 vs 15 - Worlds Apart
Next.js 14:
fetch('https://api.example.com/posts')
// Equivalent to
fetch('https://api.example.com/posts', { cache: 'force-cache' })
// Result: Data cached permanently unless manually revalidatedNext.js 15:
fetch('https://api.example.com/posts')
// Equivalent to
fetch('https://api.example.com/posts', { cache: 'no-store' })
// Result: Re-fetch every time, no cachingIf you want to cache data (Next.js 15)
Method 1: Per-request setting
fetch('https://api.example.com/posts', {
cache: 'force-cache',
next: { revalidate: 3600 } // Revalidate after 1 hour
})Method 2: Entire route setting
// app/blog/page.tsx
export const revalidate = 3600
export default async function BlogPage() {
const posts = await fetch('https://api.example.com/posts')
// ...
}When does it expire?
Data Cache expires at three moments:
- When the set
revalidatetime is up (like 3600 seconds) - Manually calling
revalidatePath()orrevalidateTag() - Redeploying the app
What if multiple fetches have different revalidate times?
If a page has multiple fetches with different revalidate times set, Next.js will use the shortest one as the revalidation time for the entire page.
async function Page() {
const posts = await fetch('...', { next: { revalidate: 60 } }) // 60 seconds
const user = await fetch('...', { next: { revalidate: 3600 } }) // 1 hour
// Actually, entire page revalidates every 60 seconds
}Full Route Cache
HTML-level caching
If Data Cache caches data, then Full Route Cache caches the entire page’s HTML and RSC Payload (serialized data from React Server Component).
When is it cached?
Only statically rendered routes are cached. What is static rendering? Pages whose content can be determined at build time.
Conversely, if your page uses these things, it becomes dynamically rendered and won’t be cached:
cookies()headers()searchParams- Unstable functions (like
Math.random(),Date.now())
How to tell if a page is static or dynamic?
Run npm run build, terminal will show:
Route (app) Size First Load JS
┌ ○ / 5 kB 87 kB
├ ● /blog 1 kB 88 kB
└ ƒ /api/user 0 kB 87 kB
○ (Static) Auto-rendered as static HTML (using no dynamic data)
● (SSG) Auto-generated as static HTML + JSON (using getStaticProps)
ƒ (Dynamic) Server-rendered on demandSee ○ or ● = static, will be cached. See ƒ = dynamic, won’t be cached.
Force static or dynamic
// Force static
export const dynamic = 'force-static'
// Force dynamic
export const dynamic = 'force-dynamic'When does it expire?
Full Route Cache expires at:
- When Data Cache expires, Full Route Cache also expires (because data changed, page needs re-render)
- Calling
revalidatePath('/blog') - Redeploying the app
Router Cache
Client-side tricks
Router Cache is stored in user browser memory. After a user visits a page, Next.js caches the page content on the client, next time the user clicks “back” or navigates to this page, it uses the cache directly without requesting the server.
There’s also this cool trick: prefetching
If you use <Link href="/about">, when this link appears in the user’s viewport, Next.js will automatically prefetch the /about page content and store it in Router Cache. When the user actually clicks, instant navigation.
Lifetime (Next.js 14)
- Static routes: cached 5 minutes
- Dynamic routes: cached 30 seconds
Changes in Next.js 15
Next.js 15 by default doesn’t enable Router Cache (or has very short cache time). If you want to enable it, you need to configure in next.config.js:
// next.config.js
module.exports = {
experimental: {
staleTimes: {
dynamic: 30, // Dynamic routes cached 30 seconds
static: 180, // Static routes cached 180 seconds
},
},
}When does it expire?
- Cache time is up
- User hard refreshes (Ctrl+Shift+R)
- Calling
router.refresh()
Why does your revalidate seem not to work?
Many times, you called revalidatePath on the server, data actually updated, but users refreshing the page still see old data. Nine out of ten times it’s because Router Cache hasn’t expired yet.
Solutions:
- User hard refresh (but you can’t require users to do this)
- After updating data, call
router.refresh()(if it’s a client component) - Shorten Router Cache time
Complete Guide to revalidate Methods
Alright, covered the theory of four layers of caching. Now for the most practical part: how to invalidate cache.
Next.js provides several revalidate methods, each for different scenarios. Understanding their differences can save you tons of debugging time.
Time-based revalidate
Most common approach
Time-based revalidate means “automatically refetch data every X seconds”. This is the core mechanism of ISR (Incremental Static Regeneration).
Two ways to write it
Method 1: Set in fetch request
const res = await fetch('https://api.example.com/posts', {
next: { revalidate: 3600 } // 3600 seconds = 1 hour
})Method 2: Set at route file top level
// app/blog/page.tsx
export const revalidate = 3600
export default async function BlogPage() {
const posts = await fetch('https://api.example.com/posts')
return <PostList posts={posts} />
}How it works (ISR)
Suppose you set revalidate: 3600:
- First user visits page → generates static HTML, caches for 1 hour
- For the next hour, all users see this cached HTML (super fast)
- After 1 hour, next user visits → still returns old HTML (won’t wait)
- But meanwhile, Next.js regenerates new HTML in the background
- After new HTML is generated, subsequent users see new content
This mechanism is called stale-while-revalidate (return stale content while revalidating). Advantage is users never wait, disadvantage is there’s always one user seeing expired data.
Use cases
- Blog post list (updates a few times per hour)
- News homepage (every 30 minutes)
- Product catalog (once a day)
Common Problem 1: Doesn’t work in dev environment
Many people complain revalidate doesn’t work after setting. First thing to check: are you testing in dev or production environment?
In dev environment (npm run dev), Next.js disables most caching, re-renders on every request. You must test in production:
npm run build
npm startCommon Problem 2: Multiple fetches with inconsistent times
Mentioned before, if a page has multiple fetches with different revalidate times, Next.js takes the minimum. But there’s a detail: Data Cache still respects each fetch’s own time.
async function Page() {
// This request revalidates every 60 seconds
const posts = await fetch('...', { next: { revalidate: 60 } })
// This request revalidates every 3600 seconds
const user = await fetch('...', { next: { revalidate: 3600 } })
}Page will re-render every 60 seconds, but the user request’s data cache stays for 1 hour. Meaning, for the first hour, page re-renders every 60 seconds but user data doesn’t change; after 1 hour, user data updates.
Sounds a bit convoluted, but it’s actually a reasonable design.
On-demand revalidate: revalidatePath
User-triggered updates
Time-based revalidate is a timer, revalidatePath is a button - when a specific event happens (like user publishes a new post), you manually trigger cache invalidation.
Usage
// app/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
export async function publishPost(formData) {
// Post publishing logic
await db.posts.create({ ... })
// Invalidate blog list page cache
revalidatePath('/blog')
}Then call in client component:
// app/components/PublishButton.tsx
'use client'
import { publishPost } from '@/app/actions'
export function PublishButton() {
return (
<form action={publishPost}>
<button type="submit">Publish Post</button>
</form>
)
}Different path types
revalidatePath can specify path type:
// Only revalidate /blog this one page
revalidatePath('/blog', 'page')
// Revalidate all pages under /blog (including /blog/post-1, /blog/post-2)
revalidatePath('/blog', 'layout')Important: Not immediate regeneration
Many think after calling revalidatePath, the page regenerates immediately. Not true.
revalidatePath only marks cache as invalid. Actual regeneration happens next time a user visits this page.
This means:
- You call
revalidatePath('/blog') - Cache is cleared
- Next user visits
/blog→ Only now HTML regenerates (user waits) - Subsequent users see new content
Use cases
- User publishes new content, refresh list
- Admin modifies config, refresh related pages
- User submits form, refresh current page
On-demand revalidate: revalidateTag
More flexible invalidation control
revalidatePath invalidates by path, revalidateTag invalidates by tag. You can tag data, then batch invalidate all caches using that tag.
Usage
Step 1: Tag fetch requests
const posts = await fetch('https://api.example.com/posts', {
next: {
revalidate: 3600,
tags: ['posts'] // Tag with 'posts'
}
})
const authors = await fetch('https://api.example.com/authors', {
next: {
revalidate: 3600,
tags: ['posts', 'authors'] // Can have multiple tags
}
})Step 2: Invalidate specific tag cache
'use server'
import { revalidateTag } from 'next/cache'
export async function publishPost() {
await db.posts.create({ ... })
// All data tagged with 'posts' will be invalidated
revalidateTag('posts')
}profile=“max” stale-while-revalidate strategy
Next.js 15 recommends using profile="max":
revalidateTag('posts', { profile: 'max' })When using profile="max":
- Marked as expired, but doesn’t immediately delete cache
- Next visit → returns old data (fast!)
- Meanwhile fetches new data in background
- After new data is ready, subsequent requests return new data
This is better than default behavior, because users don’t have to wait.
Difference between revalidatePath and revalidateTag
| Dimension | revalidatePath | revalidateTag |
|---|---|---|
| Invalidation granularity | By path | By data tag |
| Cross-page | Can only specify specific path | Can cross multiple pages |
| Precision | Coarse | Fine |
| Usage complexity | Simple | Need to plan tags in advance |
When to use Tag?
When your data is used by multiple pages, Tag is more suitable.
For example, a blog system:
- Post list page
/blog - Post detail page
/blog/[slug] - Author page
/author/[id] - Homepage “Latest Posts” section
All four pages use “post data”. If you use revalidatePath, you’d need:
revalidatePath('/blog')
revalidatePath('/blog/[slug]')
revalidatePath('/author/[id]')
revalidatePath('/')But if you tag post data with posts, you only need:
revalidateTag('posts')All pages using this tag will be invalidated. Much easier.
New: updateTag (Next.js 15)
Immediate invalidation, not delayed
updateTag is a new API in Next.js 15, difference from revalidateTag is: immediately deletes cache, not marks as expired.
'use server'
import { updateTag } from 'next/cache'
export async function updateUserProfile(userId, newData) {
await db.users.update({ where: { id: userId }, data: newData })
// Immediately invalidate user data cache
updateTag(`user-${userId}`)
}Difference from revalidateTag
| Dimension | revalidateTag | updateTag |
|---|---|---|
| Invalidation method | Mark as expired, update in background on next visit | Immediately delete cache |
| Next visit | Return old data, fetch new in background | Blocking wait, fetch new data |
| Usage restriction | Can use anywhere | Only in Server Actions |
| Use cases | General scenarios, pursue speed | ”Read your own writes” scenarios |
What is “read your own writes”?
Suppose user modifies nickname on profile page, after clicking save, page should immediately show new nickname, not still show old (then wait for background update).
This scenario uses updateTag:
export async function updateProfile(formData) {
const userId = getCurrentUserId()
await db.users.update({
where: { id: userId },
data: { nickname: formData.get('nickname') }
})
// Immediately invalidate, ensure next read sees latest data
updateTag(`user-${userId}`)
revalidatePath('/profile')
}New: use cache directive (Next.js 15)
Explicitly declare caching
Next.js 15 introduces new 'use cache' directive, can explicitly mark which functions should be cached.
Usage
'use cache'
export async function getPopularPosts() {
const posts = await db.posts.findMany({
orderBy: { views: 'desc' },
take: 10
})
return posts
}Use with cacheTag
import { unstable_cacheTag as cacheTag } from 'next/cache'
'use cache'
export async function getPostsByAuthor(authorId) {
cacheTag('posts', `author-${authorId}`)
return await db.posts.findMany({
where: { authorId }
})
}Then can invalidate with revalidateTag:
revalidateTag(`author-${authorId}`)Why is this needed?
In Next.js 15, since fetch defaults to no caching, if you don’t want to re-query database every time, you need to explicitly declare caching. use cache makes your intent clearer.
Common Issues Troubleshooting Guide
Theory covered. Now for the most practical part: how to troubleshoot when cache goes wrong.
I’ll list the four most common problems, each with troubleshooting steps and solutions.
Problem 1: revalidate set but doesn’t work
Symptoms
You set export const revalidate = 60, but 10 minutes later the page still shows old data.
Troubleshooting steps
1. Confirm testing in production environment
This is the most common misconception. Dev environment (npm run dev) disables most caching.
Must test like this:
npm run build
npm start2. Check if route is dynamically rendered
Run npm run build, look at terminal output:
Route (app) Size
├ ○ /blog 1 kB ← Static, will cache
└ ƒ /profile 2 kB ← Dynamic, won't cacheIf your route is ƒ (dynamic), then revalidate won’t work at all, because dynamic routes don’t cache.
Code that might cause dynamic rendering:
// These will make route dynamically rendered
import { cookies } from 'next/headers'
import { headers } from 'next/headers'
export default function Page({ searchParams }) { // Uses searchParams
const cookieStore = cookies() // Uses cookies
// ...
}Solutions:
- If don’t need dynamic rendering, remove this code
- If needed, don’t expect
revalidateto work, use on-demand revalidate
3. Check Next.js version
Next.js 14 and 15 have different default behaviors. If you just upgraded to 15, many things that were cached before now aren’t.
Solutions (Next.js 15):
// Explicitly enable caching
fetch(url, {
cache: 'force-cache',
next: { revalidate: 60 }
})
// Or use use cache directive
'use cache'
export async function getData() {
// ...
}4. Check if interfered by Router Cache
Even if server data updated, client’s Router Cache might still have old data cached.
Solutions:
- Hard refresh (Ctrl+Shift+R)
- Or configure shorter
staleTimesin Next.js 15
Problem 2: Data updated but page still shows old data
Symptoms
You manually changed data in database, or called revalidatePath, but refreshing page still shows old data.
Troubleshooting approach: Check four cache layers
Layer 1: Router Cache (Client)
Easiest to overlook is client cache.
Quick test:
- Press Ctrl+Shift+R for hard refresh
- If data updates, it’s Router Cache problem
Solutions:
'use client'
import { useRouter } from 'next/navigation'
export function RefreshButton() {
const router = useRouter()
return (
<button onClick={() => router.refresh()}>
Refresh
</button>
)
}Or configure shorter cache time in next.config.js:
module.exports = {
experimental: {
staleTimes: {
dynamic: 0, // Disable dynamic route cache
static: 30, // Static route cache 30 seconds
},
},
}Layer 2: Full Route Cache (Server)
Check if route is static. If static route, entire HTML is cached.
Quick test:
# Open new incognito window to visit page
# If still old data, it's server cacheSolutions:
'use server'
import { revalidatePath } from 'next/cache'
export async function updateData() {
await db.update({ ... })
// Clear route cache
revalidatePath('/your-page')
}Layer 3: Data Cache (Server)
Check fetch request configuration.
Quick test:
Add timestamp log in fetch:
const data = await fetch(url)
console.log('Fetched at:', new Date().toISOString())Refresh page, if timestamp doesn’t change, using cache.
Solutions:
Method 1: Tag data, then invalidate
// When fetching data
const data = await fetch(url, {
next: { tags: ['my-data'] }
})
// When updating data
revalidateTag('my-data')Method 2: Temporarily disable cache for testing
const data = await fetch(url, {
cache: 'no-store' // Don't cache
})Layer 4: Request Memoization (Server)
This layer usually isn’t a problem, because it only lasts for a single request. If first three layers are fine, it’s not a cache problem, might be the data source itself.
Problem 3: revalidatePath vs revalidateTag - which to use?
Decision tree
Need to invalidate cache
|
├─ Only affects one page
| → Use revalidatePath('/specific-page')
|
├─ Affects all pages under a path
| → Use revalidatePath('/blog', 'layout')
|
├─ Data used by multiple pages at different paths
| → Use revalidateTag('your-tag')
|
└─ Need immediate invalidation (user sees update right after edit)
→ Use updateTag('your-tag') (Next.js 15)Real-world case: Blog system
Suppose your blog system has these pages:
- Homepage: Shows latest 3 posts
- Blog list page
/blog: All posts - Post detail page
/blog/[slug]: Single post - Author page
/author/[id]: Author’s posts
Tagging strategy:
// Tag when fetching data
async function getPosts() {
return fetch('https://api.example.com/posts', {
next: {
revalidate: 3600,
tags: ['posts'] // Tag all post-related data
}
})
}
async function getPostBySlug(slug) {
return fetch(`https://api.example.com/posts/${slug}`, {
next: {
revalidate: 3600,
tags: ['posts', `post-${slug}`] // Both general and specific tags
}
})
}
async function getPostsByAuthor(authorId) {
return fetch(`https://api.example.com/posts?author=${authorId}`, {
next: {
revalidate: 3600,
tags: ['posts', `author-${authorId}-posts`]
}
})
}When publishing new post:
export async function publishPost(formData) {
await db.posts.create({ ... })
// Only need to invalidate 'posts' tag
// All pages using this tag will update
revalidateTag('posts')
}When modifying specific post:
export async function updatePost(slug, newData) {
await db.posts.update({ where: { slug }, data: newData })
// Only invalidate this post's related cache
revalidateTag(`post-${slug}`)
// Or, if you want to update list page too
revalidateTag('posts')
}Problem 4: Cache stops working after upgrading from Next.js 14 to 15
Symptoms
After upgrading to Next.js 15, data that was nicely cached before now refetches every time, performance tanks.
Reasons
Next.js 15’s three major caching default behavior changes:
- fetch default changed from
force-cachetono-store - GET route handlers default no caching
- Router Cache default disabled
Migration solutions
Solution 1: Explicitly enable caching (recommended)
// Before (Next.js 14)
const data = await fetch(url)
// Now (Next.js 15) - need explicit declaration
const data = await fetch(url, {
cache: 'force-cache',
next: { revalidate: 3600 }
})Solution 2: Use use cache directive
'use cache'
export async function getPostList() {
const posts = await db.posts.findMany()
return posts
}Solution 3: Enable Router Cache
// next.config.js
module.exports = {
experimental: {
staleTimes: {
dynamic: 30,
static: 180,
},
},
}Compare before and after migration:
// Next.js 14 - Implicit caching
export default async function BlogPage() {
const posts = await fetch('https://api.example.com/posts')
// Auto cached
}
// Next.js 15 - Explicit caching
'use cache' // Add this line
export default async function BlogPage() {
const posts = await fetch('https://api.example.com/posts', {
cache: 'force-cache', // Or add this
next: { revalidate: 3600 }
})
}My suggestion:
Don’t expect one-click migration. Carefully review every data request, consciously decide what needs caching and what doesn’t. It’s tedious, but healthier long-term - you’ll know exactly what data in your system is cached and what isn’t.
Best Practices and Selection Strategy
After all that, let’s summarize: when to use which caching strategy.
Caching Strategy Selection Flowchart
How often does your data update?
|
├─ Almost never changes (like about page, help docs)
| → Static generation, don't set revalidate
| → Redeploy manually when changed
|
├─ Regular updates (like hourly, daily)
| → ISR + time-based revalidate
| → export const revalidate = 3600
|
├─ Irregular updates (like user-published content)
| → On-demand revalidate
| → revalidatePath or revalidateTag
|
└─ Real-time updates (like chat, real-time data)
→ Dynamic rendering + cache: 'no-store'
→ Don't cacheTag Naming Strategy
If you choose to use revalidateTag, recommended naming convention:
Granularity design
Coarse-grained (suitable for batch invalidation)
posts- All postsproducts- All productsusers- All users
Medium-grained (by category/status)
posts:published- Published postsposts:draft- Draftsproducts:category:electronics- Electronics products
Fine-grained (specific resources)
post:id:123- Post with ID 123user:profile:456- User profile with ID 456
Naming convention recommendation
Use namespacing, format as entity:type:id:
// When fetching data
const post = await fetch(`/api/posts/${id}`, {
next: {
tags: [
'posts', // Coarse-grained
'posts:published', // Medium-grained
`post:id:${id}` // Fine-grained
]
}
})
// Can choose different granularities when invalidating
revalidateTag('posts') // Invalidate all posts
revalidateTag('posts:published') // Only invalidate published
revalidateTag(`post:id:${id}`) // Only invalidate specific postPerformance Optimization Tips
1. Don’t over-cache
Caching isn’t the more the better. Too much caching brings these problems:
- Data inconsistency
- Difficult debugging
- Storage space waste
Rule of thumb:
- User personalized data (like cart, personal settings) → Don’t cache
- Public data (like product lists, post lists) → Cache
- Real-time data (like inventory, online users) → Don’t cache or very short cache
2. Reasonable revalidate time
Don’t set too short a time. If you set revalidate: 1, meaning page might regenerate every second, this doesn’t serve caching purpose at all.
Recommended settings:
- News: 30-60 minutes
- Blog posts: 1-2 hours
- Product catalog: 2-4 hours
- Static pages: 24 hours or longer
3. Use stale-while-revalidate
Next.js 15’s profile="max" is this strategy:
revalidateTag('posts', { profile: 'max' })Users always see cached content (fast!), system quietly updates cache in background. This is optimal user experience.
4. Monitor cache hit rate
Add to .env.local:
NEXT_PRIVATE_DEBUG_CACHE=1Then run production server, console shows cache hit status:
○ GET /blog 200 in 45ms (cache: HIT)
○ GET /about 200 in 12ms (cache: SKIP)Regularly check to see if your caching strategy is effective.
Dev vs Production Environment Differences
Key reminder:
Dev environment (npm run dev) and production environment (npm start) have completely different caching behaviors.
| Feature | Dev Environment | Production Environment |
|---|---|---|
| Data Cache | Mostly disabled | Fully enabled |
| Full Route Cache | Disabled | Static routes enabled |
| Request Memoization | Enabled | Enabled |
| Router Cache | Enabled but very short | Full duration |
Correct way to test caching:
# 1. Build production version
npm run build
# 2. Check terminal output, confirm route type
# ○ = Static, will cache
# ƒ = Dynamic, won't cache
# 3. Start production server
npm start
# 4. Test caching behavior
# Open browser, visit page, then modify data source
# Refresh page, check if still old data
# 5. Test revalidate
# Wait for revalidate time to expire, visit again, see if updatedDon’t debug caching problems in dev environment. This is the most common trap, I’ve seen too many people mess around in dev environment for hours, then complain revalidate doesn’t work.
Conclusion
After all that, the core is just a few points:
1. Understand four-layer cache responsibilities
Don’t confuse them. Router Cache is client-side, the other three are server-side. revalidate mainly affects Data Cache and Full Route Cache.
2. Choose appropriate revalidate method
- Timed updates →
export const revalidate = 3600 - User-triggered, single page →
revalidatePath('/page') - User-triggered, multiple pages →
revalidateTag('tag') - Immediate invalidation →
updateTag('tag')(Next.js 15)
3. Test in production environment
Always remember: dev environment caching behavior is inaccurate. To test caching, must npm run build && npm start.
4. Troubleshoot layer by layer
When data won’t update, check in this order:
- Router Cache (client) → Try hard refresh
- Full Route Cache (server) → revalidatePath
- Data Cache (server) → revalidateTag
- Data source itself
5. Explicit over implicit (Next.js 15 philosophy)
Next.js 15 changed caching from default enabled to default disabled. This means you need to actively think about what data needs caching. It’s tedious, but healthier long-term - your code is clearer and easier to maintain.
Finally, if you’ve read this far, you now have a complete understanding of Next.js’s caching mechanism. Next time you encounter data not updating, you should know where to start.
Caching is complex, but once you understand it, you’ll find it’s one of Next.js’s most powerful features. Use caching wisely and your app will be lightning fast; cache randomly and you’re digging your own grave.
May your app’s performance be off the charts and bugs be zero!
FAQ
What are the four layers of Next.js caching?
• Deduplicates same fetch in one request
• Only lasts for the request duration
2. Data Cache:
• Caches fetch responses
• Persists across requests
3. Full Route Cache:
• Caches entire rendered page
• For static generation
4. Router Cache:
• Client-side route cache
• Temporary cache for navigation
What's the difference between revalidatePath, revalidateTag, and updateTag?
• Invalidates cache by path
• Use when you know the exact path
revalidateTag:
• Invalidates cache by tag
• Use when multiple paths share same tag
updateTag:
• Updates tag without revalidation
• Use to mark data as stale without immediate revalidation
Why doesn't revalidate work?
• Testing in dev mode (caching disabled)
• Router Cache interfering
• Wrong cache layer being targeted
• revalidate time hasn't passed yet
Solutions:
• Test in production mode
• Use revalidatePath/revalidateTag
• Clear Router Cache
• Check which cache layer is causing issue
How do I troubleshoot data not updating?
1) Check if testing in dev mode (caching disabled)
2) Check which cache layer is causing issue
3) Use revalidatePath to invalidate specific path
4) Use revalidateTag if using tags
5) Clear Router Cache if client-side issue
6) Check Full Route Cache if static generation
Use Next.js build output to see which pages are cached.
What changed in Next.js 15 caching?
Changes:
• Need to explicitly enable caching with force-cache or revalidate
• Makes code clearer and easier to maintain
• Requires active thinking about what needs caching
This is a breaking change from Next.js 14.
How do I use revalidateTag?
1) Add tag to fetch: fetch(url, { next: { tags: ['posts'] } })
2) Revalidate by tag: revalidateTag('posts')
This invalidates all fetches with that tag, useful when multiple paths use same data source.
When should I use each revalidate method?
• When you know exact path to invalidate
• Simple and direct
revalidateTag:
• When multiple paths share same data
• More flexible
updateTag:
• When you want to mark as stale without immediate revalidation
• For background updates
16 min read · Published on: Dec 19, 2025 · Modified on: Jan 22, 2026
Related Posts
Next.js E-commerce in Practice: Complete Guide to Shopping Cart and Stripe Payment Implementation

Next.js E-commerce in Practice: Complete Guide to Shopping Cart and Stripe Payment Implementation
Complete Guide to Next.js File Upload: S3/Qiniu Cloud Presigned URL Direct Upload

Complete Guide to Next.js File Upload: S3/Qiniu Cloud Presigned URL Direct Upload
Next.js Unit Testing Guide: Complete Jest + React Testing Library Setup


Comments
Sign in with GitHub to leave a comment