Skip to main content

Next.js 15 Caching: Why Your Data Is Not Caching and How to Fix It

 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.

  1. Request Memoization (deduplicating requests within a single render pass) remains unchanged. It is active by default.
  2. 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:

  1. Audit all fetch calls.
  2. Add cache: 'force-cache' for permanent static data.
  3. Add next: { revalidate: <seconds> } for semi-static data.
  4. Use export const dynamic = 'force-static' for pages that utilize direct database connections or require blanket static enforcement.

Popular posts from this blog

Restricting Jetpack Compose TextField to Numeric Input Only

Jetpack Compose has revolutionized Android development with its declarative approach, enabling developers to build modern, responsive UIs more efficiently. Among the many components provided by Compose, TextField is a critical building block for user input. However, ensuring that a TextField accepts only numeric input can pose challenges, especially when considering edge cases like empty fields, invalid characters, or localization nuances. In this blog post, we'll explore how to restrict a Jetpack Compose TextField to numeric input only, discussing both basic and advanced implementations. Why Restricting Input Matters Restricting user input to numeric values is a common requirement in apps dealing with forms, payment entries, age verifications, or any data where only numbers are valid. Properly validating input at the UI level enhances user experience, reduces backend validation overhead, and minimizes errors during data processing. Compose provides the flexibility to implement ...

jetpack compose - TextField remove underline

Compose TextField Remove Underline The TextField is the text input widget of android jetpack compose library. TextField is an equivalent widget of the android view system’s EditText widget. TextField is used to enter and modify text. The following jetpack compose tutorial will demonstrate to us how we can remove (actually hide) the underline from a TextField widget in an android application. We have to apply a simple trick to remove (hide) the underline from the TextField. The TextField constructor’s ‘colors’ argument allows us to set or change colors for TextField’s various components such as text color, cursor color, label color, error color, background color, focused and unfocused indicator color, etc. Jetpack developers can pass a TextFieldDefaults.textFieldColors() function with arguments value for the TextField ‘colors’ argument. There are many arguments for this ‘TextFieldDefaults.textFieldColors()’function such as textColor, disabledTextColor, backgroundColor, cursorC...

jetpack compose - Image clickable

Compose Image Clickable The Image widget allows android developers to display an image object to the app user interface using the jetpack compose library. Android app developers can show image objects to the Image widget from various sources such as painter resources, vector resources, bitmap, etc. Image is a very essential component of the jetpack compose library. Android app developers can change many properties of an Image widget by its modifiers such as size, shape, etc. We also can specify the Image object scaling algorithm, content description, etc. But how can we set a click event to an Image widget in a jetpack compose application? There is no built-in property/parameter/argument to set up an onClick event directly to the Image widget. This android application development tutorial will demonstrate to us how we can add a click event to the Image widget and make it clickable. Click event of a widget allow app users to execute a task such as showing a toast message by cli...