DEV Community

Cover image for Next.js 15 Caching Explained: Why Your Data Keeps Showing as Stale
Mahdi BEN RHOUMA
Mahdi BEN RHOUMA

Posted on • Originally published at iloveblogs.blog

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

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

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

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

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

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

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

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

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

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

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

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.


Originally published at https://iloveblogs.blog

Top comments (0)