Skip to content

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:

Next.js Cache Layers
┌─────────────────────────────────────────────────────────────┐
│ 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:

My broken page
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:

Force dynamic rendering
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:

Disable cache per fetch
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.

Server Action with revalidatePath
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

Fetch with tag
// When fetching data
const users = await fetch('https://api.example.com/users', {
next: { tags: ['users'] }
})
// When mutating data
async 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.

The cache mismatch problem
┌──────────────────┐ ┌──────────────────┐
│ 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:

Client-side refresh after mutation
'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:

TanStack Query approach
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?

Cache troubleshooting guide
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:

  1. Use revalidateTag for precise control
  2. Use cache: 'no-store' for dynamic data
  3. 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:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments