Next.js 15 Caching Explained: Why Your Data Keeps Showing as Stale
technology

Next.js 15 Caching Explained: Why Your Data Keeps Showing as 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.

2026-04-15
11 min read
Next.js 15 Caching Explained: Why Your Data Keeps Showing as Stale

Next.js 15 Caching Explained: Why Your Data Keeps Showing as Stale#

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 is the guide I wish existed when I spent a day debugging it.

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. 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 user = await fetch('/api/user/123');
  return user.name;
}

async function UserAvatar() {
  const user = await fetch('/api/user/123'); // deduplicated
  return <img src={user.avatar} />;
}

You cannot configure this. It is always on, always per-request.

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('/api/posts', { cache: 'no-store' });

// Cache indefinitely until manually revalidated
const res = await fetch('/api/posts', { cache: 'force-cache' });

// Cache for 60 seconds, then revalidate in the background
const res = await fetch('/api/posts', { next: { revalidate: 60 } });

// Tag this cache entry so you can invalidate it by name
const res = await fetch('/api/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.

This cache has a 30-second TTL for dynamic routes and 5 minutes for static routes. You can invalidate it 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.

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 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#

| 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.

Frequently Asked Questions

|

Have more questions? Contact us