DEV Community

Cover image for Next.js Caching Evolution: From v14 to v15 and the Cache Components Era
Abdul Halim
Abdul Halim

Posted on

Next.js Caching Evolution: From v14 to v15 and the Cache Components Era

Next.js has undergone significant caching philosophy changes. Version 14 aggressively cached by default, v15 made everything dynamic by default, and now with Cache Components (introduced in v15 canary and expanded in v16), we have explicit opt-in caching with the use cache directive. Understanding this evolution is crucial for modern Next.js development.

The Three Eras of Next.js Caching

v14 Era: Aggressive caching by default

v15 Era: Dynamic by default, opt into caching

Cache Components Era: Explicit caching with use cache directive

Part 1: The v14 → v15 Shift

v14: Cache by Default

In Next.js 14, fetch requests were automatically cached with force-cache as the default behavior, meaning data was cached indefinitely unless explicitly opted out.

// Next.js 14 - Cached by default
export default async function Page() {
  const res = await fetch('https://api.example.com/products');
  const products = await res.json();
  // This was CACHED indefinitely
  return <ProductList products={products} />;
}

// v14: Opting out of cache
const res = await fetch('https://api.example.com/products', {
  cache: 'no-store' // Explicitly disable caching
});
Enter fullscreen mode Exit fullscreen mode

v15: Dynamic by Default

Next.js 15 changed the default behavior so fetch requests are not cached by default. You must explicitly opt into caching.

// Next.js 15 - NOT cached by default
export default async function Page() {
  const res = await fetch('https://api.example.com/products');
  const products = await res.json();
  // Fresh data on every request
  return <ProductList products={products} />;
}

// v15: Opting into cache
const res = await fetch('https://api.example.com/products', {
  cache: 'force-cache' // Explicitly enable caching
});
Enter fullscreen mode Exit fullscreen mode

Part 2: Understanding the Four Caching Layers

Next.js operates with four distinct caching mechanisms that work together. Understanding their interaction is essential.

Layer 1: Request Memoization

Request memoization happens automatically within a single render pass, deduplicating identical fetch requests during rendering.

// Multiple components making the same request
async function Header() {
  const user = await fetch('/api/user'); // Request 1
  return <div>{user.name}</div>;
}

async function Sidebar() {
  const user = await fetch('/api/user'); // Deduplicated automatically
  return <nav>{user.email}</nav>;
}

export default function Page() {
  return (
    <>
      <Header />
      <Sidebar />
    </> // Only ONE actual network request
  );
}
Enter fullscreen mode Exit fullscreen mode

Scope: Single request/render cycle

Duration: Until response is sent

Control: Automatic (React optimization)

Version changes: No change between v14, v15, or v16

Layer 2: Data Cache (The Major Change)

The Data Cache persists fetch results across incoming server requests and deployments. This is where v14 and v15 differ fundamentally.

v14 behavior:

// Cached indefinitely by default
const res = await fetch('/api/products');
// To make it dynamic, add:
const res = await fetch('/api/products', { cache: 'no-store' });
Enter fullscreen mode Exit fullscreen mode

v15 behavior:

// Dynamic by default
const res = await fetch('/api/products');

// To cache it, add:
const res = await fetch('/api/products', { 
  cache: 'force-cache' 
});

// Or with time-based revalidation:
const res = await fetch('/api/products', {
  next: { revalidate: 3600 } // Revalidate every hour
});
Enter fullscreen mode Exit fullscreen mode

Layer 3: Full Route Cache

The Full Route Cache stores rendered HTML and RSC payload for static routes. Routes are statically rendered at build time or dynamically at request time.

// v15: Routes are dynamic by default
export default async function Page() {
  const data = await getData();
  return <div>{data}</div>;
  // Rendered on every request
}

// Opt into static generation
export const dynamic = 'force-static';

export default async function Page() {
  const data = await getData();
  return <div>{data}</div>;
  // Rendered once at build time
}
Enter fullscreen mode Exit fullscreen mode

Configuration options:

  • dynamic = 'auto' - Automatic (default in v15)
  • dynamic = 'force-static' - Force static generation
  • dynamic = 'force-dynamic' - Force dynamic rendering
  • dynamic = 'error' - Error if dynamic functions used

Layer 4: Router Cache (Client-Side)

The Router Cache stores RSC payload in the browser during navigation, and as of Next.js 15, page segments are opted out by default.

v14: Cached for 30 seconds (dynamic), 5 minutes (static)

v15: 0 seconds by default (effectively disabled for dynamic routes)

// next.config.js - Configure client cache in v15
module.exports = {
  experimental: {
    staleTimes: {
      dynamic: 30,  // 30 seconds for dynamic routes
      static: 180,  // 3 minutes for static routes
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

Part 3: Cache Components with use cache

Cache Components center around the new use cache directive, making caching entirely opt-in and more explicit than previous versions.

Enabling Cache Components

// next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  cacheComponents: true,
};

export default nextConfig;
Enter fullscreen mode Exit fullscreen mode

Basic Usage

The use cache directive can be applied at the page, component, or function level:

// Page-level caching
export default async function Page() {
  'use cache';

  const random = Math.random();
  const now = Date.now();

  return (
    <div>
      <p>{random}</p>
      <p>{now}</p>
    </div>
  );
  // All requests will see the same values until revalidated
}

// Component-level caching
export async function ProductList() {
  'use cache';

  const products = await fetch('/api/products').then(r => r.json());
  return <ul>{products.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}

// Function-level caching
export async function getProducts() {
  'use cache';

  const res = await fetch('/api/products');
  return res.json();
}
Enter fullscreen mode Exit fullscreen mode

Using cacheLife Profiles

By default, use cache uses the default profile with 5 minutes client-side stale time, 15 minutes server-side revalidate, and never expires.

import { cacheLife } from 'next/cache';

export async function getData() {
  'use cache';
  cacheLife('hours'); // Use built-in 'hours' profile

  return fetch('/api/data').then(r => r.json());
}

// Define custom profiles in next.config.ts
const nextConfig: NextConfig = {
  cacheComponents: true,
  cacheLife: {
    biweekly: {
      stale: 60 * 60 * 24 * 14,    // 14 days
      revalidate: 60 * 60 * 24,    // 1 day  
      expire: 60 * 60 * 24 * 14,   // 14 days
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

Cache Invalidation

Use cacheTag to tag cached data and revalidateTag to invalidate it on-demand:

import { cacheTag, updateTag, revalidateTag } from 'next/cache';

// Tag the cache
export async function getProducts() {
  'use cache';
  cacheTag('products');

  return db.query('SELECT * FROM products');
}

// Invalidate immediately (Server Actions)
export async function updateProduct(id: string) {
  'use server';

  await db.products.update({ id });
  updateTag('products'); // Immediate cache expiration
}

// Invalidate with stale-while-revalidate
export async function refreshProducts() {
  await someUpdate();
  revalidateTag('products', 'max'); // Recommended approach
}
Enter fullscreen mode Exit fullscreen mode

Migration Guide

From v14 to v15

Step 1: Identify and remove unnecessary cache configurations

// v14 - Remove these in v15 (now default):
{ cache: 'no-store' }
{ next: { revalidate: 0 } }
export const revalidate = 0;

// v15 - Keep/add these:
{ cache: 'force-cache' }
{ next: { revalidate: 60 } }
export const dynamic = 'force-static';
Enter fullscreen mode Exit fullscreen mode

Step 2: Add caching where beneficial

// Static data - aggressive caching
const config = await fetch('/api/config', {
  cache: 'force-cache'
});

// Semi-dynamic - time-based revalidation
const products = await fetch('/api/products', {
  next: { revalidate: 300 } // 5 minutes
});

// User-specific - keep dynamic (no config needed)
const userData = await fetch('/api/user');
Enter fullscreen mode Exit fullscreen mode

Migrating to Cache Components

// Before: v15 with fetch caching
export default async function Page() {
  const data = await fetch('https://api.example.com/data', {
    next: { revalidate: 3600 }
  }).then(r => r.json());

  return <div>{data}</div>;
}

// After: Cache Components approach
import { cacheLife } from 'next/cache';

export default async function Page() {
  'use cache';
  cacheLife('hours');

  const data = await fetch('https://api.example.com/data')
    .then(r => r.json());

  return <div>{data}</div>;
}
Enter fullscreen mode Exit fullscreen mode

Real-World Patterns

Pattern 1: Hybrid E-commerce Page

export default async function ProductPage({ params }) {
  // Categories rarely change - cache aggressively
  const categories = await fetch('/api/categories', {
    next: { revalidate: 86400 } // 24 hours
  }).then(r => r.json());

  // Product data changes moderately
  const product = await fetch(`/api/products/${params.id}`, {
    next: { revalidate: 300 } // 5 minutes
  }).then(r => r.json());

  // Stock levels change frequently - keep dynamic
  const stock = await fetch(`/api/stock/${params.id}`)
    .then(r => r.json());

  return <ProductView product={product} stock={stock} categories={categories} />;
}
Enter fullscreen mode Exit fullscreen mode

Pattern 2: Static Marketing with Dynamic Pricing

export default async function LandingPage() {
  'use cache';
  cacheLife('weeks');

  // Static content cached for weeks
  return (
    <>
      <Hero />
      <Features />
      <Suspense fallback={<div>Loading pricing...</div>}>
        <DynamicPricing />
      </Suspense>
    </>
  );
}

// Dynamic pricing without suspense wrapper
async function DynamicPricing() {
  const prices = await fetch('/api/pricing').then(r => r.json());
  return <PricingTable prices={prices} />;
}
Enter fullscreen mode Exit fullscreen mode

Decision Matrix

Data Type v15 Strategy Cache Components
Config/Settings cache: 'force-cache' 'use cache' + cacheLife('max')
Marketing Content dynamic = 'force-static' 'use cache' at page level
Product Catalog revalidate: 300 'use cache' + cacheLife('minutes')
User Dashboards Default (dynamic) Wrap in <Suspense>
Real-time Data Default (dynamic) No caching

Performance Considerations

Although fetch requests are not cached by default in v15, Next.js will pre-render routes and cache the HTML for improved performance.

When to use each approach:

  1. Use v15 fetch caching for simple, straightforward caching needs
  2. Use Cache Components when you need:
    • Caching beyond fetch (database queries, file operations)
    • Fine-grained cache invalidation with tags
    • Partial prerendering with dynamic sections
    • Complex cache lifetime management

Key Takeaways

  1. v14 → v15: Default changed from cached to dynamic for fetch requests
  2. Four layers: Request Memoization, Data Cache, Full Route Cache, Router Cache
  3. Cache Components: New explicit opt-in model with use cache directive
  4. Migration path: Remove cache: 'no-store', add cache: 'force-cache' where needed
  5. Modern approach: Cache strategically, not by default

The evolution from v14's aggressive caching to v15's dynamic defaults and now Cache Components represents Next.js maturing toward explicit, predictable caching behavior that developers can reason about clearly.

Top comments (0)