DEV Community

Cover image for Next.js Stale Data: How I Fixed Cache Revalidation
Mahdi BEN RHOUMA
Mahdi BEN RHOUMA

Posted on Originally published at iloveblogs.blog

Next.js Stale Data: How 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
}
Enter fullscreen mode Exit fullscreen mode

What happened:

  1. User visits /todos - Next.js fetches and caches the todo list
  2. User creates a new todo - Database updates successfully
  3. User refreshes /todos - Still sees old cached data (no new todo)
  4. 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

  1. Request Memoization - Deduplicates identical requests during a single render
  2. Data Cache - Caches fetch responses across requests (this is the problem)
  3. Full Route Cache - Caches entire rendered pages
  4. 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'
})
Enter fullscreen mode Exit fullscreen mode

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')
}
Enter fullscreen mode Exit fullscreen mode

Now when a user creates a todo:

  1. Database updates
  2. revalidatePath('/todos') clears the cache for that page
  3. Next request fetches fresh data
  4. 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')
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

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')
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
})
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

Option 3: Per-Layout

// app/layout.tsx
export const dynamic = 'force-dynamic'

// All pages in this layout are uncached
Enter fullscreen mode Exit fullscreen mode

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 }
})
Enter fullscreen mode Exit fullscreen mode

How it works:

  1. First request: Fetch from API, cache for 60 seconds
  2. Requests within 60 seconds: Serve from cache (fast)
  3. After 60 seconds: Serve stale cache, fetch fresh data in background
  4. 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' })
Enter fullscreen mode Exit fullscreen mode

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')
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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' }
  }
}
Enter fullscreen mode Exit fullscreen mode

This pattern:

  1. Validates input
  2. Performs mutation
  3. Revalidates cache (both tag and path)
  4. Returns result or redirects
  5. Handles errors gracefully

Common Mistakes I Made

  1. Forgetting to revalidate - Mutations worked but UI showed stale data. Always revalidate after mutations.

  2. Using wrong path in revalidatePath - revalidatePath('todos') doesn't work. Must be revalidatePath('/todos') with leading slash.

  3. Not revalidating layouts - Layout data wasn't updating. Use revalidatePath('/path', 'layout').

  4. Revalidating too much - Revalidating entire site after every mutation. Be specific with tags.

  5. Mixing caching strategies - Using both cache: 'no-store' and revalidate. Pick one strategy.

  6. Not testing in production - Caching behaves differently in dev. Always test production builds.

  7. Forgetting to import from 'next/cache' - revalidatePath and revalidateTag must 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 — revalidatePath runs but cookies() is undefined, the action fires twice, or NEXT_REDIRECT gets 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?
}
Enter fullscreen mode Exit fullscreen mode

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')
}
Enter fullscreen mode Exit fullscreen mode

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()
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 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 revalidatePath for simple cases
  • Use revalidateTag for 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)