Next.js

Next.js 15 Caching Explained: Why Data Shows 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.

Mahdi Ben RhoumaMahdi Ben Rhouma
Updated
11 min read

If you upgraded from Next.js 14 to 15 and your pages suddenly stopped refreshing — or started hitting your database on every request — you are not alone. The caching model changed fundamentally. This guide maps each cache layer to the behaviour you are seeing.

What Changed in Next.js 15#

In Next.js 14, fetch() inside Server Components was cached by default:

js
// Next.js 14: this was CACHED (force-cache)
const data = await fetch('https://api.example.com/posts');

In Next.js 15, the default flipped:

js
// Next.js 15: this is NOT cached (no-store)
const data = await fetch('https://api.example.com/posts');

This single change affects every Server Component in your app that uses fetch(). If you were relying on the default cache behavior, your routes are now fully dynamic — which means a database call on every render.

The Next.js team made this change because force-cache silently caused stale data bugs that were hard to debug. The new default is safer but more explicit.

The Four Caching Layers You Need to Understand#

Next.js 15 has four distinct caching mechanisms, documented in full in the official Next.js caching guide. Understanding which one applies to your situation is everything.

1. Request Memoization (per-render)#

The same fetch() URL called multiple times in a single render tree is deduplicated automatically. This is not persistent — it resets on every request.

js
// Both calls return the same data, only one HTTP request is made
async function UserName() {
  const res = await fetch('https://api.example.com/user/123');
  const user = await res.json();
  return user.name;
}
 
async function UserAvatar() {
  const res = await fetch('https://api.example.com/user/123'); // deduplicated
  const user = await res.json();
  return <img src={user.avatar} />;
}

You cannot configure this. It is always on, always per-request. The examples use an absolute URL on purpose: in a Server Component, a relative path such as /api/user/123 has no origin to resolve against and throws, as explained in Error: only absolute URLs are supported.

2. Data Cache (persistent, fetch-level)#

This is what changed in Next.js 15. Control it with the cache option on fetch():

js
// Never cache — always fetch live data
const res = await fetch('https://api.example.com/posts', { cache: 'no-store' });
 
// Cache indefinitely until manually revalidated
const res = await fetch('https://api.example.com/posts', { cache: 'force-cache' });
 
// Cache for 60 seconds, then revalidate in the background
const res = await fetch('https://api.example.com/posts', { next: { revalidate: 60 } });
 
// Tag this cache entry so you can invalidate it by name
const res = await fetch('https://api.example.com/posts', { next: { tags: ['posts'] } });

3. Full Route Cache (build-time static rendering)#

If Next.js can determine at build time that a route has no dynamic data, it renders it once and serves the HTML statically. This is ISR (Incremental Static Regeneration) in the App Router.

Control it at the route level:

js
// page.js
 
// Revalidate this entire route every 5 minutes
export const revalidate = 300;
 
// Never cache this route (always server-render)
export const dynamic = 'force-dynamic';
 
// Always use cached version (opt in to static)
export const dynamic = 'force-static';

4. Router Cache (client-side, per-session)#

Next.js caches visited routes in the browser for the duration of the session. This is why navigating back to a page feels instant — but also why a user might see stale data after you push an update.

Since Next.js 15.0.0, dynamic page segments are not reused from this cache by default (staleTimes.dynamic is 0 seconds; it was 30 seconds in Next.js 14), while statically generated or fully prefetched pages are reused for 5 minutes. Both values can be changed with the experimental staleTimes option. You can invalidate the cache with router.refresh() from a Client Component.

The Real Problem: Supabase and Database Queries#

Here is where most indie hackers get burned. When you use Supabase (or any database client), you are not using fetch(). You are calling the Supabase SDK directly — which means none of the fetch() caching rules apply at all.

js
// This is NOT cached by Next.js — it runs on every render
const { data } = await supabase.from('posts').select('*');

If you want to cache a Supabase query, use unstable_cache:

js
import { unstable_cache } from 'next/cache';
import { createClient } from '@/lib/supabase/server';
 
const getCachedPosts = unstable_cache(
  async () => {
    const supabase = createClient();
    const { data } = await supabase.from('posts').select('*');
    return data;
  },
  ['all-posts'],          // cache key
  {
    revalidate: 300,      // revalidate every 5 minutes
    tags: ['posts'],      // tag for manual invalidation
  }
);
 
export default async function PostsPage() {
  const posts = await getCachedPosts();
  return <PostList posts={posts} />;
}

The first argument is the function. The second is the cache key array (like React Query's queryKey). The third is the options object.

Invalidating Cache After Mutations#

The most important pattern: when a user creates, updates, or deletes data, you need to invalidate the relevant cache so the next page load sees fresh data.

js
// app/actions.ts
'use server';
 
import { revalidatePath, revalidateTag } from 'next/cache';
import { createClient } from '@/lib/supabase/server';
 
export async function createPost(formData: FormData) {
  const supabase = createClient();
  
  const { error } = await supabase.from('posts').insert({
    title: formData.get('title'),
    content: formData.get('content'),
  });
 
  if (error) throw new Error(error.message);
 
  // Invalidate the posts tag — all unstable_cache entries tagged 'posts' are cleared
  revalidateTag('posts');
  
  // Also invalidate the posts listing page URL
  revalidatePath('/posts');
}

Use revalidateTag when the same data appears on multiple pages. Use revalidatePath when you know exactly which URL to clear.

One detail that's easy to miss: revalidatePath only busts the page by default. If a layout (not the page itself) renders data that needs to refresh too — a todo count in a sidebar, for example — pass 'layout' as the second argument:

js
// Revalidate the layout and every nested page under it
revalidatePath('/todos', 'layout');
 
// Revalidate just the page (the default)
revalidatePath('/todos', 'page');

A Decision Tree for Picking a Strategy#

plaintext
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 to react to?
├─ Yes → Tag the fetch and call revalidateTag()
└─ No → Use time-based revalidate alone
 
Does the data appear on multiple pages?
├─ Yes → revalidateTag()
└─ No → revalidatePath()

Debugging Cache Issues#

When you are not sure what is being cached, add this to any Server Component:

js
export const dynamic = 'force-dynamic';

This forces the route to always server-render with no caching. If your data problem goes away, it was a cache issue. If not, your problem is elsewhere (wrong query, wrong env variable, etc.).

To see what Next.js is caching during development, run:

bash
next build && next start

The build output shows which routes are static (○), dynamic (λ), or ISR (~). Development mode (next dev) always runs dynamically — you will not see caching behavior in dev.

Quick Reference#

SituationSolution
Page shows stale data after DB updateCall revalidateTag() or revalidatePath() in your Server Action
Supabase query not cachingWrap with unstable_cache()
Route hits DB on every requestAdd export const revalidate = 60 to the page
Need real-time live dataUse export const dynamic = 'force-dynamic'
Data cached too longLower revalidate value or add cache tags
After upgrading from Next.js 14Audit all fetch() calls — add explicit cache options

The Pattern That Works in Production#

For a typical Next.js 15 + Supabase app:

js
// lib/queries.ts
import { unstable_cache } from 'next/cache';
import { createClient } from '@/lib/supabase/server';
 
export const getPosts = unstable_cache(
  async (limit = 10) => {
    const supabase = createClient();
    const { data, error } = await supabase
      .from('posts')
      .select('id, title, slug, excerpt, created_at')
      .order('created_at', { ascending: false })
      .limit(limit);
 
    if (error) throw error;
    return data ?? [];
  },
  ['posts-list'],
  { revalidate: 60, tags: ['posts'] }
);
 
export const getPostBySlug = unstable_cache(
  async (slug: string) => {
    const supabase = createClient();
    const { data, error } = await supabase
      .from('posts')
      .select('*')
      .eq('slug', slug)
      .single();
 
    if (error) return null;
    return data;
  },
  ['post-by-slug'],
  { revalidate: 300, tags: ['posts'] }
);

Then in your Server Actions, always call revalidateTag('posts') after any write. One tag clears all related cached queries at once.

This pattern gives you fast page loads (cached data), fresh data after mutations (tag invalidation), and automatic background refresh every 60–300 seconds as a safety net.

Further Reading#

Further reading: Next.js 15 vs 14: Real Benchmarks + Upgrade Verdict (2026), Next.js Scroll Resets When searchParams Change.

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Written by

Mahdi Ben Rhouma
Mahdi Ben Rhouma

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.