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 was what was killing me. In Next.js 13 and 14, a plain fetch with no options was cached indefinitely in production (force-cache was the default). That is no longer true.
Default Caching Behavior
// Next.js 15+: this is NOT cached by default. The default is no longer
// `force-cache` — Next.js calls it "auto no cache" (a bare fetch is not
// cached; it runs fresh at request time on dynamic routes and once at
// build time on static ones). (On Next 13/14 this same line WAS cached
// indefinitely — which is the trap this post is about.)
const data = await fetch('https://api.example.com/todos')
// ✅ Explicitly cache and revalidate every 60 seconds
const data = await fetch('https://api.example.com/todos', {
next: { revalidate: 60 }
})
// ✅ Explicitly opt out of caching entirely (stronger than the Next 15+
// "auto no cache" default — this forces a fresh fetch on every request,
// even on statically-prerendered routes)
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:
If your stale UI isn't a revalidation bug at all —
revalidatePathruns butcookies()is undefined, the action fires twice, orNEXT_REDIRECTgets caught as an error — the production fix matrix maps each Server Action symptom to its root cause.
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
Related Articles
- Next.js Server Actions + Supabase Complete Guide
- I Fixed Next.js Hydration Errors After 3 Days of Debugging
- Next.js + Supabase Data Fetching Patterns
- Next.js 15 vs 14: Real Benchmarks and Whether to Upgrade (2026)
- I Tanked My Core Web Vitals Score With Next.js Images Here's How I Fixed It
- use client vs use server in Next.js: The Real Difference
- Next.js 16 "use cache" Errors: Why Cache Components Break ## 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.
Originally published at https://www.iloveblogs.blog
Top comments (0)