Next.js Caching Is So Confusing: A Developer's Guide to Cache Invalidation
I updated a user’s name in my database, but the dashboard still showed the old name. I refreshed the page. Still old. I cleared my browser cache. Still old.
What was happening? My data wasn’t updating, and I had no idea why.
The Problem: Next.js Has Four Caches
I thought there was just one cache. Wrong. Next.js App Router has four separate caching mechanisms:
┌─────────────────────────────────────────────────────────────┐│ YOUR REQUEST │└─────────────────────────────────────────────────────────────┘ │ ▼┌─────────────────────────────────────────────────────────────┐│ 1. FULL ROUTE CACHE ││ Caches rendered HTML of static routes ││ Scope: Server-side │└─────────────────────────────────────────────────────────────┘ │ ▼┌─────────────────────────────────────────────────────────────┐│ 2. DATA CACHE ││ Caches fetch() results on server ││ Scope: Server-side │└─────────────────────────────────────────────────────────────┘ │ ▼┌─────────────────────────────────────────────────────────────┐│ 3. ROUTER CACHE ││ Caches route segments in browser ││ Scope: Client-side (30s dev, 5min prod) │└─────────────────────────────────────────────────────────────┘ │ ▼┌─────────────────────────────────────────────────────────────┐│ 4. REQUEST MEMOIZATION ││ Deduplicates identical fetch() in same render ││ Scope: Single request │└─────────────────────────────────────────────────────────────┘Each cache has different invalidation rules. When my data wasn’t updating, I had to figure out which cache was holding onto the stale data.
My First Mistake: Not Understanding Static vs Dynamic
I wrote a simple page that fetched user data:
export default async function UsersPage() { const res = await fetch('https://api.example.com/users') const users = await res.json() return <UserList users={users} />}This looks fine. But Next.js caches this statically by default. The page is rendered once at build time and served from cache forever.
I updated a user. The cache didn’t know. The page still showed old data.
The Fix
I needed to tell Next.js this page should be dynamic:
export const dynamic = 'force-dynamic'
export default async function UsersPage() { const res = await fetch('https://api.example.com/users') const users = await res.json() return <UserList users={users} />}Or disable caching for specific fetches:
const res = await fetch('https://api.example.com/users', { cache: 'no-store'})My Second Mistake: Using revalidatePath Wrong
I added a delete button. When clicked, the user should disappear from the list.
async function deleteUser(formData: FormData) { 'use server' const id = formData.get('id') await fetch(`https://api.example.com/users/${id}`, { method: 'DELETE' }) revalidatePath('/users')}The user was deleted from the database. But the list didn’t update. What?
The problem: revalidatePath invalidates the Full Route Cache and Data Cache for that path. But the Router Cache on the client side still held old data.
The Fix: Use revalidateTag Instead
// When fetching dataconst users = await fetch('https://api.example.com/users', { next: { tags: ['users'] }})
// When mutating dataasync function deleteUser(formData: FormData) { 'use server' const id = formData.get('id') await fetch(`https://api.example.com/users/${id}`, { method: 'DELETE' }) revalidateTag('users') // More precise invalidation}Tags give you granular control. Instead of invalidating an entire path (and potentially other data on that page), you invalidate only the data with that tag.
My Third Mistake: Confusing Server and Client Cache
I called revalidatePath on the server. But the client’s browser still had cached route segments.
┌──────────────────┐ ┌──────────────────┐│ SERVER │ │ CLIENT ││ │ │ ││ Data Cache: │ │ Router Cache: ││ INVALIDATED ✓ │ │ STILL OLD ✗ ││ │ │ │└──────────────────┘ └──────────────────┘The Router Cache expires after 30 seconds in development, 5 minutes in production. So the user would eventually see updated data. But “eventually” isn’t good enough.
The Fix: Force Client Refresh
On the client side, after a mutation, I needed to refresh the router cache:
'use client'import { useRouter } from 'next/navigation'
function DeleteButton({ id }: { id: string }) { const router = useRouter()
async function handleDelete() { await fetch(`/api/users/${id}`, { method: 'DELETE' }) router.refresh() // Forces Router Cache update }
return <button onClick={handleDelete}>Delete</button>}When to Give Up and Use an SPA
After fighting caches for days, I asked myself:
- Is SEO important for this page? No, it’s a dashboard.
- Do I need server-side rendering? Not really.
- Am I building an internal tool? Yes.
For internal dashboards and CRUD apps, Next.js caching adds complexity without benefit.
The Simpler Alternative
I switched to a Vite + TanStack Query setup:
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
function UsersList() { const queryClient = useQueryClient()
const { data: users } = useQuery({ queryKey: ['users'], queryFn: fetchUsers, refetchInterval: 5000 // Auto-refresh every 5 seconds })
const deleteMutation = useMutation({ mutationFn: deleteUser, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['users'] }) } })
return ( <div> {users?.map(user => ( <div key={user.id}> {user.name} <button onClick={() => deleteMutation.mutate(user.id)}> Delete </button> </div> ))} </div> )}No cache surprises. Predictable behavior. Real-time updates.
Quick Reference: Which Cache Is Your Problem?
PROBLEM │ LIKELY CULPRIT │ SOLUTION─────────────────────────────────┼───────────────────────┼─────────────────────Page shows old data after deploy │ Full Route Cache │ revalidatePath() │ │ or set revalidate time─────────────────────────────────┼───────────────────────┼─────────────────────fetch() returns stale data │ Data Cache │ cache: 'no-store' │ │ or revalidateTag()─────────────────────────────────┼───────────────────────┼─────────────────────Client navigation shows old data │ Router Cache │ router.refresh() │ │ or wait 5 min─────────────────────────────────┼───────────────────────┼─────────────────────Same fetch called multiple times │ Request Memoization │ This is fine! │ │ (Actually helps)Summary
Next.js caching is powerful for public content sites. The four-cache architecture optimizes for performance and SEO. But for internal tools, dashboards, and real-time apps, the complexity often isn’t worth it.
If you need Next.js caching, remember:
- Use
revalidateTagfor precise control - Use
cache: 'no-store'for dynamic data - Call
router.refresh()after client mutations
If you don’t need SSR, consider TanStack Query with Vite. Your future self will thank you.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- 👨💻 Next.js Caching Documentation
- 👨💻 Reddit: Next.js / SPA Reality Check
- 👨💻 TanStack Query Documentation
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments