Fix: Next.js App Router Fetch Not Caching or Always Stale
Part of: React & Frontend Errors
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 secondsOr 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.
The root of confusion is that the default changed between major versions. In Next.js 13 and 14, fetch() inside a Server Component cached by default (force-cache), meaning data fetched at build time stayed frozen until you explicitly set a revalidation interval or used cache: 'no-store'. In Next.js 15, the default flipped to no-store — every fetch makes a new request unless you opt into caching. Code written for one version behaves opposite on the other, and the error is completely silent. There is no runtime warning; the data is simply stale or simply uncached.
Beyond the default flip, several other mechanisms interact with caching in non-obvious ways. Calling any dynamic function — cookies(), headers(), searchParams, or noStore() — anywhere in the component tree opts the entire route segment into dynamic rendering. This bypasses fetch caching for every fetch in that segment, even if those fetches have explicit next: { revalidate: 3600 } set. The dynamic function “poisons” the entire segment because Next.js cannot prerender a page that depends on per-request data.
Development mode adds a third layer of confusion. npm run dev disables or partially disables caching to support hot reload. A page that appears correctly cached during development may behave differently in production, or vice versa. The only reliable way to test caching behavior is npm run build && npm run start. Finally, CDN layers (Vercel, Cloudflare) add their own cache on top of Next.js, creating a situation where revalidatePath clears the Next.js cache but the CDN continues serving a stale response.
How Other Tools Handle This
Next.js is unusually opinionated about fetch caching. Most other meta-frameworks don’t modify fetch at all, which avoids the confusion but pushes caching responsibility to the developer.
Remix uses loader() functions that run on every request by default — no caching. You control caching entirely through standard HTTP Cache-Control headers returned from the loader. Remix doesn’t intercept fetch; whatever caching the browser or CDN provides is what you get. This is simpler to reason about but means you must set up caching manually for every route that needs it:
// Remix — loader runs on every request; caching via HTTP headers
export function headers() {
return { "Cache-Control": "public, max-age=60, s-maxage=3600" };
}
export async function loader() {
const posts = await fetch("https://api.example.com/posts").then(r => r.json());
return json(posts);
}Astro takes a different approach entirely: pages are static HTML by default (zero JavaScript shipped to the client). There is no runtime fetch caching because there is no runtime — pages are built at build time. For dynamic data, you opt into server-side rendering per-route with export const prerender = false, and then caching is your responsibility via HTTP headers. Astro’s model eliminates the caching problem by eliminating the runtime.
SvelteKit uses load() functions similar to Remix’s loaders. Data is fetched on the server and passed to components. SvelteKit’s fetch wrapper deduplicates requests within a single render but does not cache across requests by default. You can return Cache-Control headers from +page.server.ts to enable HTTP-level caching, or use SvelteKit’s depends() and invalidate() for client-side cache busting — but this is opt-in, not default.
Nuxt 3 provides useFetch() and useAsyncData() composables that cache data on the client side during navigation (avoiding refetches when navigating back to a page) but make a fresh server request on each full page load. Nuxt also supports ISR-like behavior with routeRules in nuxt.config.ts, letting you set per-route revalidation intervals similar to Next.js — but the caching is configured at the route level, not on individual fetch calls.
The comparison highlights Next.js’s unique design: it caches at the individual fetch level rather than the route level, which gives fine-grained control but creates more surface area for bugs. If you find Next.js fetch caching too complex, consider whether route-level caching (like Remix or SvelteKit) would suit your use case better.
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 defaultFix 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 usedFix 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 pathVerify 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 startTest production caching locally:
npm run build
npm run start
# Now fetch behavior matches production Cloudflare/Vercel deploymentEnable 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 demandFix 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.
Check for parallel route segments with different caching. If a layout renders multiple parallel routes (using @slot conventions) and one slot is dynamic while another is static, the entire layout becomes dynamic. The most restrictive caching mode wins at the layout level, which can silently disable caching for slots you expected to be static.
Verify generateStaticParams is returning the expected values. For dynamic routes like /posts/[id], if generateStaticParams returns an empty array or doesn’t include a specific ID, that page won’t be prerendered. At request time, the behavior depends on dynamicParams — if set to false, unmatched IDs return a 404. If true (default), the page is rendered on-demand. This can look like a caching bug when it’s actually a static generation gap.
Check if middleware is modifying the response. Next.js middleware runs before route rendering. If middleware rewrites, redirects, or sets headers that conflict with the route’s caching configuration, the final behavior can differ from what the route segment config specifies. Add console.log statements in middleware to verify it isn’t interfering.
For related Next.js issues, see Fix: Next.js Hydration Failed, Fix: Next.js Environment Variables Not Working, Fix: Next.js Build Failed, and Fix: Next.js Server Action Not Working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: React Hydration Error — Text Content Does Not Match
How to fix React hydration errors — server/client HTML mismatches, useEffect for client-only code, suppressHydrationWarning, dynamic content, and Next.js specific hydration issues.
Fix: Next.js Middleware Not Running (middleware.ts Not Intercepting Requests)
How to fix Next.js middleware not executing — wrong file location, matcher config errors, middleware not intercepting API routes, and how to debug middleware execution in Next.js 13 and 14.
Fix: Next.js Build Failed (next build Errors and How to Fix Them)
How to fix Next.js build failures — TypeScript errors blocking production builds, module resolution failures, missing environment variables, static generation errors, and common next build crash causes.
Fix: CodeMirror Not Working — Editor Not Rendering, Extensions Not Loading, or React State Out of Sync
How to fix CodeMirror 6 issues — basic setup, language and theme extensions, React integration, vim mode, collaborative editing, custom keybindings, and read-only mode.