My Next.js App Showed Stale Data for Hours Until I Fixed Cache Revalidation
My mutations worked but the UI showed stale data. Took me a week to understand Next.js App Router caching. Here are the 6 fixes that made my data fresh again.
My Next.js App Showed Stale Data for Hours Until I Fixed Cache Revalidation#
I built a todo app with Next.js 15 App Router. Users could create, update, and delete todos. Everything worked perfectly in development.
Then I deployed to production.
Users started complaining: "I deleted a todo but it's still showing." "I updated the title but it's not changing." "I added a new todo but I don't see it."
The mutations were working. The database was updating. But the UI was showing stale data for hours.
Welcome to Next.js App Router caching. It's aggressive, it's confusing, and it will bite you if you don't understand it.
After a week of debugging, reading docs, and testing every caching strategy, I finally figured it out. Here's everything I learned about cache revalidation in Next.js.
This article is part of our comprehensive Next.js + Supabase Caching Strategies guide.
The Problem: What Was Happening#
Here's my original code (this is broken):
// app/todos/page.tsx
export default async function TodosPage() {
const todos = await fetch('https://api.example.com/todos').then(r => r.json())
return (
<div>
{todos.map(todo => (
<TodoItem key={todo.id} todo={todo} />
))}
</div>
)
}
// app/actions.ts
'use server'
export async function createTodo(formData: FormData) {
const title = formData.get('title')
await fetch('https://api.example.com/todos', {
method: 'POST',
body: JSON.stringify({ title }),
})
// ❌ No revalidation! Cache still shows old data
}
What happened:
- User visits
/todos- Next.js fetches and caches the todo list - User creates a new todo - Database updates successfully
- User refreshes
/todos- Still sees old cached data (no new todo) - Cache eventually expires after hours - New todo finally appears
This is Next.js working as designed. The cache is aggressive for performance. But you need to tell it when to invalidate.
Understanding Next.js Caching (The Mental Model)#
Next.js has multiple cache layers. This confused me for days.
The Four Caches#
- Request Memoization - Deduplicates identical requests during a single render
- Data Cache - Caches fetch responses across requests (this is the problem)
- Full Route Cache - Caches entire rendered pages
- Router Cache - Client-side cache of visited routes
The Data Cache is what was killing me. It caches fetch responses indefinitely in production.
Default Caching Behavior#
// ❌ This is cached FOREVER in production
const data = await fetch('https://api.example.com/todos')
// ✅ This is cached for 60 seconds
const data = await fetch('https://api.example.com/todos', {
next: { revalidate: 60 }
})
// ✅ This is never cached
const data = await fetch('https://api.example.com/todos', {
cache: 'no-store'
})
I didn't know this. My fetches had no options, so they cached forever.
Fix #1: Use revalidatePath (The Quick Fix)#
This is what I should have done from the start.
// app/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
export async function createTodo(formData: FormData) {
const title = formData.get('title')
await fetch('https://api.example.com/todos', {
method: 'POST',
body: JSON.stringify({ title }),
})
// ✅ Revalidate the todos page
revalidatePath('/todos')
}
Now when a user creates a todo:
- Database updates
revalidatePath('/todos')clears the cache for that page- Next request fetches fresh data
- User sees the new todo
This fixed 90% of my issues.
When to Use revalidatePath#
Use it after any mutation that affects a specific page:
// After creating
revalidatePath('/todos')
// After updating
revalidatePath(`/todos/${id}`)
// After deleting
revalidatePath('/todos')
// Revalidate multiple paths
revalidatePath('/todos')
revalidatePath('/dashboard')
Revalidate Layouts Too#
This caught me. I had a layout showing todo count. It wasn't updating.
// ✅ Revalidate layout and all nested pages
revalidatePath('/todos', 'layout')
// ✅ Revalidate just the page (default)
revalidatePath('/todos', 'page')
Use 'layout' when your layout has data that needs to update.
Fix #2: Use revalidateTag (The Scalable Fix)#
revalidatePath works, but it's not granular enough. If todos appear on multiple pages, you need to revalidate all of them.
The Problem#
// Todos appear on multiple pages
// /todos - main list
// /dashboard - todo count
// /profile - user's todos
// ❌ Have to revalidate everything
revalidatePath('/todos')
revalidatePath('/dashboard')
revalidatePath('/profile')
This is tedious and error-prone.
The Solution: Cache Tags#
// Tag your fetches
const todos = await fetch('https://api.example.com/todos', {
next: { tags: ['todos'] }
})
const userTodos = await fetch(`https://api.example.com/users/${userId}/todos`, {
next: { tags: ['todos', `user-${userId}`] }
})
// Revalidate by tag
import { revalidateTag } from 'next/cache'
export async function createTodo(formData: FormData) {
await fetch('https://api.example.com/todos', {
method: 'POST',
body: JSON.stringify({ title: formData.get('title') }),
})
// ✅ Revalidates ALL fetches tagged with 'todos'
revalidateTag('todos')
}
Now every page that fetches todos gets fresh data. One line of code.
My Tagging Strategy#
// Tag by resource type
fetch(url, { next: { tags: ['todos'] } })
fetch(url, { next: { tags: ['users'] } })
fetch(url, { next: { tags: ['posts'] } })
// Tag by specific resource
fetch(url, { next: { tags: ['todos', `todo-${id}`] } })
// Tag by user
fetch(url, { next: { tags: ['todos', `user-${userId}-todos`] } })
// Then revalidate granularly
revalidateTag('todos') // All todos
revalidateTag(`todo-${id}`) // Specific todo
revalidateTag(`user-${userId}-todos`) // User's todos
Fix #3: Opt Out of Caching (The Nuclear Option)#
Sometimes you just want fresh data every time. No caching.
Option 1: Per-Request#
// ✅ Never cache this request
const todos = await fetch('https://api.example.com/todos', {
cache: 'no-store'
})
Option 2: Per-Page#
// app/todos/page.tsx
export const dynamic = 'force-dynamic'
export default async function TodosPage() {
// All fetches on this page are uncached
const todos = await fetch('https://api.example.com/todos').then(r => r.json())
return <div>{/* ... */}</div>
}
Option 3: Per-Layout#
// app/layout.tsx
export const dynamic = 'force-dynamic'
// All pages in this layout are uncached
When to Opt Out#
Use cache: 'no-store' for:
- Real-time data (stock prices, live scores)
- User-specific data (notifications, cart)
- Data that changes frequently
Don't use it for:
- Static content (blog posts, product listings)
- Data that rarely changes
- High-traffic pages (kills performance)
I was tempted to use force-dynamic everywhere. Don't. You lose all caching benefits. Use proper revalidation instead.
Fix #4: Time-Based Revalidation (The ISR Way)#
Sometimes you want caching but with automatic refresh.
// ✅ Cache for 60 seconds, then revalidate
const todos = await fetch('https://api.example.com/todos', {
next: { revalidate: 60 }
})
How it works:
- First request: Fetch from API, cache for 60 seconds
- Requests within 60 seconds: Serve from cache (fast)
- After 60 seconds: Serve stale cache, fetch fresh data in background
- Next request: Serve fresh data
This is Incremental Static Regeneration (ISR). Great for data that changes occasionally.
My Revalidation Times#
// Blog posts: 1 hour
fetch(url, { next: { revalidate: 3600 } })
// Product listings: 5 minutes
fetch(url, { next: { revalidate: 300 } })
// User profile: 1 minute
fetch(url, { next: { revalidate: 60 } })
// Real-time data: no cache
fetch(url, { cache: 'no-store' })
Fix #5: Combine Strategies (What I Actually Use)#
In production, I use a combination:
// app/todos/page.tsx
export default async function TodosPage() {
// Cache for 60 seconds, tagged for manual revalidation
const todos = await fetch('https://api.example.com/todos', {
next: {
revalidate: 60,
tags: ['todos']
}
}).then(r => r.json())
return <div>{/* ... */}</div>
}
// app/actions.ts
'use server'
import { revalidateTag } from 'next/cache'
export async function createTodo(formData: FormData) {
await fetch('https://api.example.com/todos', {
method: 'POST',
body: JSON.stringify({ title: formData.get('title') }),
})
// Immediately revalidate
revalidateTag('todos')
}
Benefits:
- Automatic revalidation every 60 seconds (ISR)
- Manual revalidation after mutations (instant updates)
- Tagged for granular control
Best of both worlds.
Fix #6: Handle Supabase Caching (The Gotcha)#
I use Supabase. Their client has its own caching that conflicts with Next.js.
The Problem#
// ❌ Supabase client caches internally
import { createClient } from '@/lib/supabase/server'
const supabase = createClient()
const { data } = await supabase.from('todos').select('*')
// revalidatePath doesn't work! Supabase has its own cache
The Solution#
// ✅ Wrap Supabase calls in fetch with tags
export async function getTodos() {
const supabase = createClient()
// Use fetch to leverage Next.js caching
return fetch('https://your-project.supabase.co/rest/v1/todos', {
headers: {
apikey: process.env.SUPABASE_ANON_KEY!,
Authorization: `Bearer ${(await supabase.auth.getSession()).data.session?.access_token}`,
},
next: { tags: ['todos'] }
}).then(r => r.json())
}
// Or use Supabase client with cache: 'no-store'
export async function getTodos() {
const supabase = createClient()
// Force fresh data
const { data } = await supabase
.from('todos')
.select('*')
return data
}
I ended up using Supabase client with revalidatePath in Server Actions. Works well enough.
My Complete Server Action Pattern#
After all this, here's my standard pattern:
// app/actions.ts
'use server'
import { revalidatePath, revalidateTag } from 'next/cache'
import { redirect } from 'next/navigation'
export async function createTodo(formData: FormData) {
try {
const title = formData.get('title') as string
// Validate
if (!title || title.length < 3) {
return { error: 'Title must be at least 3 characters' }
}
// Mutate
const response = await fetch('https://api.example.com/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
})
if (!response.ok) {
return { error: 'Failed to create todo' }
}
const todo = await response.json()
// Revalidate
revalidateTag('todos')
revalidatePath('/todos')
// Optionally redirect
// redirect(`/todos/${todo.id}`)
return { success: true, todo }
} catch (error) {
console.error('Create todo error:', error)
return { error: 'Something went wrong' }
}
}
This pattern:
- Validates input
- Performs mutation
- Revalidates cache (both tag and path)
- Returns result or redirects
- Handles errors gracefully
Common Mistakes I Made#
-
Forgetting to revalidate - Mutations worked but UI showed stale data. Always revalidate after mutations.
-
Using wrong path in revalidatePath -
revalidatePath('todos')doesn't work. Must berevalidatePath('/todos')with leading slash. -
Not revalidating layouts - Layout data wasn't updating. Use
revalidatePath('/path', 'layout'). -
Revalidating too much - Revalidating entire site after every mutation. Be specific with tags.
-
Mixing caching strategies - Using both
cache: 'no-store'andrevalidate. Pick one strategy. -
Not testing in production - Caching behaves differently in dev. Always test production builds.
-
Forgetting to import from 'next/cache' -
revalidatePathandrevalidateTagmust be imported from'next/cache'.
Debugging Cache Issues#
When data is stale, here's my debugging process:
Step 1: Check if mutation is working#
export async function createTodo(formData: FormData) {
console.log('Creating todo:', formData.get('title'))
const response = await fetch(url, { method: 'POST', body })
console.log('Response:', response.status)
// Is this logging? Is response 200?
}
Step 2: Check if revalidation is called#
export async function createTodo(formData: FormData) {
await fetch(url, { method: 'POST', body })
console.log('Revalidating /todos')
revalidatePath('/todos')
console.log('Revalidation complete')
}
Step 3: Check fetch caching#
// Add logging to your fetch
const todos = await fetch(url, {
next: { tags: ['todos'] }
}).then(r => {
console.log('Fetched todos:', r.status)
return r.json()
})
Step 4: Disable caching temporarily#
// Test with no caching
const todos = await fetch(url, { cache: 'no-store' })
// Does it work now? Then it's a caching issue
My Caching Decision Tree#
Here's how I decide what caching strategy to use:
Is the data user-specific?
├─ Yes → cache: 'no-store'
└─ No → Continue
Does it change frequently (< 1 minute)?
├─ Yes → cache: 'no-store' or revalidate: 60
└─ No → Continue
Do you have mutations?
├─ Yes → Use tags + revalidateTag
└─ No → Use time-based revalidation
Does it appear on multiple pages?
├─ Yes → Use revalidateTag
└─ No → Use revalidatePath
Testing Checklist#
Before deploying, I test:
□ Create item → See it immediately
□ Update item → See changes immediately
□ Delete item → It disappears immediately
□ Refresh page → Changes persist
□ Open in new tab → See latest data
□ Test in production build (npm run build && npm run start)
□ Check server logs for revalidation calls
□ Test with slow network (throttle in DevTools)
□ Test with multiple users simultaneously
The Results#
Before understanding caching:
- Users saw stale data for hours
- Support tickets about "broken" features
- Had to manually restart server to clear cache
- Considered switching back to Pages Router
After implementing proper revalidation:
- Data updates instantly after mutations
- Zero stale data complaints
- Proper caching improves performance
- Users happy, I'm happy
FAQ#
Why does my Next.js app show stale data after mutations?#
Next.js App Router caches fetch requests and Server Components aggressively for performance. After a mutation, you need to explicitly revalidate the cache using revalidatePath or revalidateTag, otherwise the old cached data continues to be served. This is by design for optimal performance.
When should I use revalidatePath vs revalidateTag?#
Use revalidatePath when you want to revalidate all data on a specific page or route (simple, works for most cases). Use revalidateTag for more granular control when you want to revalidate specific data across multiple pages that share the same cache tag (better for complex apps).
How do I disable caching completely in Next.js?#
Use cache: 'no-store' in fetch options, or export const dynamic = 'force-dynamic' from your page. However, this disables all caching benefits and hurts performance. Better to use proper revalidation strategies with revalidatePath or revalidateTag instead.
Why does revalidatePath not work in my Server Action?#
Make sure you're importing revalidatePath from 'next/cache', calling it after your mutation completes, using the correct path string with a leading slash ('/todos' not 'todos'), and your Server Action is marked with 'use server' directive.
How long does Next.js cache data by default?#
Fetch requests are cached indefinitely by default in production (until you redeploy). You can set revalidate option to specify time-based revalidation (e.g., 60 seconds), or use cache: 'no-store' to disable caching for specific requests.
Related Articles#
- Next.js + Supabase Caching Strategies
- Next.js Server Actions + Supabase Complete Guide
- I Fixed Next.js Hydration Errors After 3 Days of Debugging
- Next.js + Supabase Data Fetching Patterns
Conclusion: Caching Is Your Friend (Once You Understand It)#
Next.js App Router caching confused me for weeks. It felt like fighting the framework. But once I understood it, everything clicked.
The key insights:
- Caching is aggressive by default (good for performance)
- You must explicitly revalidate after mutations
- Use
revalidatePathfor simple cases - Use
revalidateTagfor complex apps - Combine time-based and manual revalidation
My app is faster now than it was with no caching. And users see fresh data instantly after mutations.
Caching is not the enemy. Lack of understanding is.
Now go fix that stale data. Your users are waiting.
Frequently Asked Questions
Continue Reading
Next.js 15 Caching Explained: Why Your Data Keeps Showing as Stale
Upgraded to Next.js 15 and suddenly your data is stale — or refreshing too often? The caching model changed completely. Here is what actually happens and how to control it.
Next.js 15 vs Next.js 14: Performance Comparison and Migration Guide 2026
Comprehensive comparison of Next.js 15 and 14 performance improvements, new features, breaking changes, and whether you should upgrade your production app.
I Tanked My Core Web Vitals Score With Next.js Images Here's How I Fixed It
Added images to my Next.js app and watched my Core Web Vitals tank. After debugging for days, here are the 7 fixes that brought my CLS score back to green.