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.
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:
// Next.js 14: this was CACHED (force-cache)
const data = await fetch('https://api.example.com/posts');In Next.js 15, the default flipped:
// 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.
// 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():
// 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:
// 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.
// 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:
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.
// 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:
// 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#
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:
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:
next build && next startThe 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#
| Situation | Solution |
|---|---|
| Page shows stale data after DB update | Call revalidateTag() or revalidatePath() in your Server Action |
| Supabase query not caching | Wrap with unstable_cache() |
| Route hits DB on every request | Add export const revalidate = 60 to the page |
| Need real-time live data | Use export const dynamic = 'force-dynamic' |
| Data cached too long | Lower revalidate value or add cache tags |
| After upgrading from Next.js 14 | Audit all fetch() calls — add explicit cache options |
The Pattern That Works in Production#
For a typical Next.js 15 + Supabase app:
// 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#
- Next.js + Supabase performance hub
- Next.js Performance Optimization for Indie Developers
- Fix 'Hydration failed' in Next.js: 8 Root Causes
Further reading: Next.js 15 vs 14: Real Benchmarks + Upgrade Verdict (2026), Next.js Scroll Resets When searchParams Change.
Frequently Asked Questions
Still stuck? Paste your error.
Paste the error message or stack trace — we check it against every fix and tool on the site.
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.
Continue Reading
Next.js 15 vs 14: Real Benchmarks + Upgrade Verdict (2026)
What Next.js's own release notes actually say changed between 14 and 15 — caching defaults, async APIs, and Turbopack's real (and still-partial) stability status — plus the breaking changes that hurt on upgrade.
Next.js + Supabase Performance: 10 Real Fixes
Ten fixes for slow Next.js + Supabase apps, each grounded in the official docs rather than a single unverifiable case-study number — RLS-indexed columns, N+1 joins, the connection-pooler trap on serverless, image transforms, and caching layers.
Fix next/image Layout Shift (CLS) in Next.js
next/image only prevents layout shift when it can reserve space. Eight fixes for CLS and LCP problems caused by images, each checked against the Next.js and web.dev documentation.


