Skip to content

Fix: Next.js App Router Fetch Not Caching or Always Stale

FixDevs ·

Quick Answer

How to fix Next.js App Router fetch caching issues — understanding cache behavior, revalidation with next.revalidate, opting out with no-store, cache tags, and debugging stale data.

The Error

Data in a Next.js App Router page is always stale — showing old content even after the source changes:

// This always shows data from build time, never updates
async function Page() {
  const data = await fetch('https://api.example.com/posts').then(r => r.json());
  return <PostList posts={data} />;
}

Or the opposite — fetch is never cached, causing a new request on every page visit:

// Slow TTFB because API is called on every request
// Expected: cached for 60 seconds

Or revalidatePath / revalidateTag calls don’t seem to work:

// After calling this in a Server Action, the page still shows old data
revalidatePath('/posts');

Why This Happens

Next.js App Router extends the native fetch API with caching behavior that doesn’t exist in standard browsers or Node.js. The caching rules differ from the Pages Router and are often surprising:

  • Default behavior changed in Next.js 15 — in Next.js 13-14, fetch cached by default (force-cache). In Next.js 15, the default changed to no-store (no caching). Code written for one version behaves differently on the other.
  • no-store or dynamic functions used — calling cookies(), headers(), searchParams, or using noStore() anywhere in the rendering chain opts the entire route segment into dynamic rendering, bypassing cache.
  • Missing next.revalidate — without a revalidation interval, cached data never updates (in Next.js 13-14) or is never cached (in Next.js 15).
  • revalidatePath not matching the right path — the path must exactly match the route, including dynamic segments.
  • Development mode behavior — Next.js disables some caching in development. Behavior in npm run dev differs from npm run build && npm run start.

Fix 1: Understand the Default Behavior by Version

Next.js 13-14 defaults:

// Cached indefinitely (force-cache) — data from build time, never updates
const data = await fetch('https://api.example.com/posts').then(r => r.json());

// Revalidate every 60 seconds (ISR)
const data = await fetch('https://api.example.com/posts', {
  next: { revalidate: 60 },
}).then(r => r.json());

// No cache — fresh on every request
const data = await fetch('https://api.example.com/posts', {
  cache: 'no-store',
}).then(r => r.json());

Next.js 15 defaults (changed):

// NOT cached by default in Next.js 15 — equivalent to no-store
const data = await fetch('https://api.example.com/posts').then(r => r.json());

// Add explicit caching in Next.js 15
const data = await fetch('https://api.example.com/posts', {
  next: { revalidate: 3600 },  // Cache for 1 hour
}).then(r => r.json());

Check your Next.js version:

cat package.json | grep '"next"'
# "next": "^15.0.0"  → no caching by default
# "next": "^14.0.0"  → cached by default

Fix 2: Set Revalidation for Time-Based Freshness

Use next.revalidate to control how long data is cached (ISR — Incremental Static Regeneration):

// app/posts/page.tsx
async function PostsPage() {
  // Cache for 60 seconds — revalidate at most once per minute
  const posts = await fetch('https://api.example.com/posts', {
    next: { revalidate: 60 },
  }).then(r => r.json());

  return <PostList posts={posts} />;
}

Or set revalidation at the route segment level — applies to all fetches in the segment:

// app/posts/page.tsx
export const revalidate = 60;  // Revalidate this whole route every 60 seconds

async function PostsPage() {
  // This fetch inherits the route's revalidate setting
  const posts = await fetch('https://api.example.com/posts').then(r => r.json());
  return <PostList posts={posts} />;
}

Available revalidate values:

export const revalidate = 0;       // Always fresh (dynamic)
export const revalidate = 60;      // Revalidate every 60 seconds
export const revalidate = 3600;    // Revalidate every hour
export const revalidate = false;   // Cache indefinitely (default in Next.js 13-14)
export const dynamic = 'force-dynamic';  // Always server-render, never cache
export const dynamic = 'force-static';   // Always static, error if dynamic data used

Fix 3: Use Cache Tags for On-Demand Revalidation

Tag fetches so you can invalidate specific data without rebuilding:

// app/posts/[id]/page.tsx
async function PostPage({ params }: { params: { id: string } }) {
  const post = await fetch(`https://api.example.com/posts/${params.id}`, {
    next: {
      revalidate: 3600,
      tags: [`post-${params.id}`, 'posts'],  // Tag this fetch
    },
  }).then(r => r.json());

  return <Post post={post} />;
}
// app/actions/revalidate.ts — Server Action
'use server';
import { revalidateTag } from 'next/cache';

export async function invalidatePost(postId: string) {
  revalidateTag(`post-${postId}`);  // Invalidate specific post
  // revalidateTag('posts');        // Invalidate all posts
}

In a webhook handler (API route that receives CMS events):

// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  const secret = request.headers.get('x-revalidate-secret');
  if (secret !== process.env.REVALIDATE_SECRET) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const { tag } = await request.json();
  revalidateTag(tag);

  return NextResponse.json({ revalidated: true });
}

Cache tags are global. If two different routes fetch the same URL with the same tag, revalidating that tag clears the cache for both routes. Design tags to match your data invalidation boundaries.

Fix 4: Opt Out of Caching for Dynamic Data

For data that must always be fresh (user-specific data, real-time prices):

// Method 1: cache: 'no-store' on the fetch
const userData = await fetch(`https://api.example.com/me`, {
  cache: 'no-store',
  headers: { Authorization: `Bearer ${token}` },
}).then(r => r.json());

// Method 2: unstable_noStore() — marks the component as dynamic
import { unstable_noStore as noStore } from 'next/cache';

async function UserDashboard() {
  noStore();  // Opts this component out of static rendering
  const data = await fetch('https://api.example.com/dashboard').then(r => r.json());
  return <Dashboard data={data} />;
}

// Method 3: Use dynamic functions (automatically makes route dynamic)
import { cookies } from 'next/headers';

async function UserPage() {
  const cookieStore = cookies();  // Using cookies() makes this dynamic
  const session = cookieStore.get('session');
  const data = await fetch(`https://api.example.com/user/${session?.value}`).then(r => r.json());
  return <UserProfile data={data} />;
}

Fix 5: Fix revalidatePath Not Working

revalidatePath must be called from a Server Action or Route Handler, and the path must exactly match:

'use server';
import { revalidatePath } from 'next/cache';

export async function updatePost(postId: string, data: PostData) {
  await db.post.update({ where: { id: postId }, data });

  // Revalidate specific paths
  revalidatePath(`/posts/${postId}`);         // Dynamic segment
  revalidatePath('/posts');                   // List page
  revalidatePath('/', 'layout');              // Root layout (clears everything)
  revalidatePath('/posts', 'page');           // Only the page, not layout
}

Common mistakes:

// WRONG — revalidatePath only works in Server Actions and Route Handlers
// NOT in regular async functions called from client components

// WRONG — path must start with /
revalidatePath('posts');          // Missing leading slash

// WRONG — must use the actual route path, not the file path
revalidatePath('/app/posts');     // File path, not URL path

// CORRECT
revalidatePath('/posts');         // URL path

Verify the Server Action runs after the data mutation:

'use server';
export async function deletePost(postId: string) {
  // Mutation must complete before revalidate
  await db.post.delete({ where: { id: postId } });

  // Revalidate AFTER the mutation
  revalidatePath('/posts');
  // redirect('/posts');  // Optional: redirect after action
}

Fix 6: Debug Caching in Development vs Production

Next.js caching behaves differently in development:

# Development — partial caching, hot reload overrides
npm run dev

# Production build — full caching behavior
npm run build && npm run start

Test production caching locally:

npm run build
npm run start
# Now fetch behavior matches production Cloudflare/Vercel deployment

Enable verbose cache logging:

# Next.js 14+
NEXT_PRIVATE_DEBUG_CACHE=1 npm run build

# Shows which routes are static, dynamic, or ISR
# Output example:
# ○ /posts - static
# ƒ /dashboard - dynamic
# ◐ /posts/[id] - ISR (60s)

Check the build output — the route type tells you the caching behavior:

Route (app)                    Size     First Load JS
┌ ○ /                          5.2 kB        90.1 kB
├ ○ /about                     1.1 kB        86.0 kB
├ ƒ /api/revalidate             0 B           85.0 kB
├ ● /posts                     2.3 kB        87.2 kB    (ISR: 60 Seconds)
└ ƒ /dashboard                 3.1 kB        88.1 kB

○  (Static)   prerendered as static content
●  (SSG)      prerendered as static HTML (uses generateStaticParams)
ƒ  (Dynamic)  server-rendered on demand

Fix 7: Non-fetch Data Sources

next.revalidate and cache tags only work with the extended fetch. For other data sources (databases, ORMs), use unstable_cache:

import { unstable_cache } from 'next/cache';
import { db } from '@/lib/db';

// Wrap database calls with unstable_cache
const getCachedPosts = unstable_cache(
  async () => {
    return db.post.findMany({ orderBy: { createdAt: 'desc' } });
  },
  ['posts-list'],       // Cache key
  {
    tags: ['posts'],    // Tag for revalidation
    revalidate: 3600,   // Cache for 1 hour
  }
);

async function PostsPage() {
  const posts = await getCachedPosts();
  return <PostList posts={posts} />;
}

Revalidate the unstable_cache tag:

'use server';
import { revalidateTag } from 'next/cache';

export async function createPost(data: PostData) {
  await db.post.create({ data });
  revalidateTag('posts');  // Clears the unstable_cache with tag 'posts'
}

Still Not Working?

Check if dynamic functions prevent caching. If any component in the render tree calls cookies(), headers(), or searchParams, the entire route becomes dynamic and fetch caching is bypassed:

// This makes the ENTIRE route dynamic, even if cookies() result isn't used
import { cookies } from 'next/headers';

async function Sidebar() {
  const c = cookies();  // Just importing and calling this is enough
  // ...
}

Move dynamic function calls as close to where they’re used as possible to minimize the dynamic rendering scope.

Verify Cloudflare/CDN isn’t caching stale pages. If revalidatePath works locally but not in production, a CDN cache layer might be serving stale pages. Add cache-control headers or purge the CDN cache after revalidation.

Check for export const dynamic conflicts. If a layout has export const dynamic = 'force-static' and a page tries to be dynamic, Next.js will throw an error or silently ignore the dynamic behavior.

For related Next.js issues, see Fix: Next.js Environment Variables Not Working and Fix: Next.js Hydration Failed.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles