You’ve just migrated a production application to Next.js 15. The build passes, linting is clean, and the app runs. But monitoring tools are signaling a critical anomaly: database connections have spiked, third-party API quotas are draining rapidly, and page load times (TTFB) have degraded.
The behavior of your application has inverted. Static pages are rendering dynamically, and data you expected to remain fresh for hours is being refetched on every single request.
This is not a bug. It is the result of a fundamental architectural shift in Next.js 15 regarding default caching strategies.
The Root Cause: Standardizing fetch Behavior
In Next.js 14, the framework extended the native Web fetch API to default to cache: 'force-cache'. This meant that unless you explicitly opted out, Next.js aggressively cached every GET request in the Data Cache. While performant, this was "magic" behavior that deviated from web standards and often led to the "stale data" problem during development.
In Next.js 15, fetch requests are no longer cached by default.
The default behavior for a fetch request is now effectively cache: 'no-store' (uncached), unless specific caching parameters are provided. If a component uses an uncached fetch, the rendering of that route segment switches from Static to Dynamic at runtime.
Consequently, if your v14 application relied on the implicit default to handle caching, your v15 application is now performing Dynamic Rendering for those routes, executing the data fetching logic on every incoming request.
The Solutions
To restore performance and caching behavior, you must move from implicit defaults to explicit configuration. Below are three architectural approaches to fixing this, depending on the granularity required.
Solution 1: Granular Control (Per-Request)
If you have specific external API calls that rarely change (e.g., fetching CMS content, navigation menus, or configuration data), you should explicitly apply caching at the fetch call site.
This is the most precise method and prevents accidental caching of user-specific data.
// src/lib/api/cms.ts
interface CMSPost {
id: string;
title: string;
content: string;
}
export async function getCMSPost(slug: string): Promise<CMSPost> {
// NEXT.JS 15 FIX: Explicitly add 'force-cache'
const res = await fetch(`https://api.headless-cms.com/posts/${slug}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.CMS_API_KEY}`,
},
// This restores the v14 default behavior
cache: 'force-cache',
});
if (!res.ok) {
throw new Error(`Failed to fetch post: ${res.statusText}`);
}
return res.json();
}
Solution 2: Time-Based Revalidation (ISR)
For data that needs to be fresh but not real-time (Incremental Static Regeneration), use the next.revalidate option. This persists the data in the Data Cache but expires it after a set duration.
// src/app/dashboard/analytics/page.tsx
interface AnalyticsData {
visitors: number;
revenue: number;
}
async function getAnalytics(): Promise<AnalyticsData> {
// NEXT.JS 15 FIX: Define a revalidation period (in seconds)
const res = await fetch('https://api.internal-service.com/stats', {
next: {
revalidate: 3600, // Cache for 1 hour
tags: ['analytics'] // Optional: allowing on-demand revalidation
}
});
return res.json();
}
export default async function AnalyticsPage() {
const data = await getAnalytics();
return (
<main>
<h1>Daily Revenue: ${data.revenue}</h1>
<p>Data cached for 1 hour.</p>
</main>
);
}
Solution 3: Segment-Level Configuration (Global Override)
If you are migrating a large application and want to enforce static behavior for an entire Layout or Page—regardless of the individual fetch calls inside it—use the Route Segment Config.
This is particularly useful if you are using an ORM (Prisma, Drizzle) or a database driver directly, as these do not use fetch and therefore do not integrate with the Data Cache automatically.
// src/app/blog/layout.tsx
// NEXT.JS 15 FIX: Force the entire route segment to be static.
// This effectively forces all data fetching within this subtree
// to be treated as static during build time, unless dynamic functions
// (headers(), cookies()) are used.
export const dynamic = 'force-static';
// Optional: Set a default revalidation time for the segment
export const revalidate = 900; // 15 minutes
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Engineering Blog',
description: 'Technical deep dives.',
};
export default function BlogLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<section className="blog-layout-container">
<nav>Blog Navigation</nav>
{children}
</section>
);
}
Why These Fixes Work
The mechanics of Next.js 15 caching rely on the interaction between the Data Cache and Request Memoization.
- Request Memoization (deduplicating requests within a single render pass) remains unchanged. It is active by default.
- Data Cache (persisting data across user requests/deployments) is now opt-in.
By adding cache: 'force-cache', you explicitly instruct Next.js to store the response in the persistent Data Cache. When you add next: { revalidate: N }, you tell Next.js to store it, but mark it as stale after $N$ seconds.
The export const dynamic = 'force-static' configuration works differently. It changes the rendering strategy of the route itself. When the builder encounters this, it attempts to prune dynamic data requirements. If a fetch request inside this route has no caching options, this segment config coerces the build output to be static, effectively mocking the v14 behavior for that specific tree.
Conclusion
Next.js 15 moves the ecosystem toward "Explicit over Implicit." While the loss of default caching feels like a regression during migration, it eliminates the class of bugs where stale data persists unintentionally.
To fix your cache misses:
- Audit all
fetchcalls. - Add
cache: 'force-cache'for permanent static data. - Add
next: { revalidate: <seconds> }for semi-static data. - Use
export const dynamic = 'force-static'for pages that utilize direct database connections or require blanket static enforcement.