Skip to content
jsmanifest logojsmanifest

Next.js unstable_cache vs fetch Cache in 2026: Which One Actually Belongs in Your App

Next.js unstable_cache vs fetch Cache in 2026: Which One Actually Belongs in Your App

The caching story in Next.js has evolved dramatically. Learn when fetch cache fails, why unstable_cache exists, and how the use cache directive changes everything for production apps in 2026.

Correction (2026-11-02): fetch requests are not cached by Next.js by default. Caching is opt-in; you must set cache: 'force-cache' (or a next.revalidate value) on the fetch call to use the Data Cache.

Most Next.js caching problems stem from choosing the wrong abstraction for the wrong context. Teams reach for fetch cache because it's the lightest opt-in available, then discover too late that it only covers HTTP requests. Others adopt unstable_cache without understanding that Next.js treats it as legacy infrastructure. The pattern that teams overlook in 2026 is the use cache directive — the officially endorsed approach that subsumes both previous mechanisms.

This matters because caching decisions compound. A database query cached incorrectly will propagate stale data across your app. An API fetch cached too aggressively will show users outdated content. The failure mode here is subtle but expensive: your app appears fast in development, then collapses under load in production because the caching layer never aligned with your actual data flow.

Key Takeaways

  • fetch cache only applies to HTTP requests in Server Components and provides no control over database queries or third-party SDK calls.
  • unstable_cache wraps arbitrary async functions for server-side memoization but remains in legacy status with no guaranteed API stability.
  • The use cache directive is the official Next.js 16+ caching primitive that replaces unstable_cache and integrates with the framework's revalidation system.
  • Revalidation tags and path-based invalidation must be configured explicitly — Next.js does not automatically invalidate caches when data changes.
  • Production patterns require matching the caching strategy to the data source: fetch-level for REST APIs, function-level for database queries, and directive-level for entire component trees.

Understanding fetch Cache: The Default Next.js Caching Mechanism

By default, Next.js does not cache fetch requests in Server Components. Caching is opt-in: set cache: 'force-cache' (or a next.revalidate value) on the call to store the response in the Data Cache, a persistent layer separate from the Full Route Cache.

When a component calls fetch without a cache option, Next.js refetches on every request in development and once during next build for a statically prerendered route, falling back to per-request fetching if the route uses a Request-time API. Set cache: 'force-cache' to opt into caching that response indefinitely, or pass next: { revalidate: <seconds> } to cap how long it stays fresh. The framework serializes cached responses and stores them keyed by URL, method, headers, and body.

// app/products/page.tsx
export default async function ProductsPage() {
  // Opt in explicitly to cache this response indefinitely
  const res = await fetch('https://api.example.com/products', { cache: 'force-cache' });
  const products = await res.json();
 
  return (
    <div>
      {products.map((product) => (
        <ProductCard key={product.id} {...product} />
      ))}
    </div>
  );
}

This pattern works well for external APIs with stable data. The problem surfaces when teams assume this cache covers all data fetching. Database queries via ORMs, CMS SDKs, and serverless functions bypass the fetch mechanism entirely. Next.js has no visibility into those calls, so the Data Cache never activates.

%% alt: Next.js fetch cache flow showing request routing and cache storage
flowchart TD
    ServerComponent["Server Component calls fetch"]
    CheckCache{"Response in Data Cache?"}
    ReturnCached["Return cached response"]
    MakeRequest["Make HTTP request"]
    StoreCache["Store in Data Cache"]
    RenderComponent["Render component with data"]

    ServerComponent --> CheckCache
    CheckCache -->|Yes| ReturnCached
    CheckCache -->|No| MakeRequest
    ReturnCached --> RenderComponent
    MakeRequest --> StoreCache
    StoreCache --> RenderComponent

    classDef framework fill:#0b3b2e,stroke:#34d399,color:#d1fae5
    classDef dataStore fill:#3a2f0b,stroke:#fbbf24,color:#fef3c7
    class ServerComponent,MakeRequest framework
    class CheckCache,StoreCache,ReturnCached dataStore
Next.js fetch cache flow showing request routing and cache storage

The implication here is that fetch-level caching only solves one narrow use case. Most production apps fetch data from multiple sources — REST APIs, GraphQL endpoints, database connections, third-party SDKs. The fetch cache cannot intercept Prisma queries or Contentful SDK calls. Developers need a different primitive to cache those operations.

unstable_cache Deep Dive: When and Why You Need Server-Side Caching

The unstable_cache function wraps arbitrary async computations and memoizes their results on the server. fetch cache only covers HTTP requests, and even then only once you pass a cache or revalidate option; unstable_cache requires explicit wrapping of the function you want to cache, but it works for any async call, not just fetch.

Next.js introduced this API to solve the database query problem. When a Server Component calls a Prisma query or a CMS SDK, the framework has no built-in mechanism to cache the result. unstable_cache provides that mechanism by accepting a callback, a cache key, and optional tags for revalidation.

// lib/queries.ts
import { unstable_cache } from 'next/cache';
import prisma from '@/lib/prisma';
 
export const getCachedProducts = unstable_cache(
  async () => {
    return await prisma.product.findMany({
      where: { published: true },
      orderBy: { createdAt: 'desc' },
    });
  },
  ['products-list'],
  { revalidate: 3600, tags: ['products'] }
);

The function executes once, caches the result, and returns the cached value for subsequent calls until the revalidation time expires. The cache key (['products-list']) must be unique across the app — duplicate keys will collide and return incorrect data.

This distinction matters. The fetch cache operates at the request level with URL-based keys. The unstable_cache operates at the function level with developer-defined keys. The former needs only a cache or revalidate option on the call but is limited to fetch. The latter needs an explicit wrapper but covers any async function.

%% alt: unstable_cache execution flow showing function wrapping and cache storage
flowchart TD
    ComponentCall["Component invokes getCachedProducts"]
    CheckFnCache{"Result in function cache?"}
    ReturnCachedResult["Return cached result"]
    ExecuteQuery["Execute database query"]
    StoreFnCache["Store result with cache key"]
    ReturnResult["Return result to component"]

    ComponentCall --> CheckFnCache
    CheckFnCache -->|Yes| ReturnCachedResult
    CheckFnCache -->|No| ExecuteQuery
    ReturnCachedResult --> ReturnResult
    ExecuteQuery --> StoreFnCache
    StoreFnCache --> ReturnResult

    classDef framework fill:#0b3b2e,stroke:#34d399,color:#d1fae5
    classDef dataStore fill:#3a2f0b,stroke:#fbbf24,color:#fef3c7
    class ComponentCall,ExecuteQuery framework
    class CheckFnCache,StoreFnCache,ReturnCachedResult dataStore
unstable_cache execution flow showing function wrapping and cache storage

The API remains marked unstable_cache even in Next.js 16 because the framework team never finalized the interface. The function works in production, but the signature may change in future releases. This matters because production apps require API stability. A breaking change in a core caching primitive forces rewrites across the codebase.

Side-by-Side Code Comparison: fetch vs unstable_cache for Real-World Scenarios

The difference between fetch cache and unstable_cache becomes concrete when comparing real-world data access patterns. Consider a product listing page that needs to display items from a database and enrich them with external pricing data.

// Pattern 1: fetch cache for external API
async function getProductPricing(productIds: string[]) {
  const res = await fetch(
    `https://pricing-api.example.com/bulk?ids=${productIds.join(',')}`,
    { next: { revalidate: 300, tags: ['pricing'] } }
  );
  return res.json();
}
 
// Pattern 2: unstable_cache for database query
import { unstable_cache } from 'next/cache';
 
const getCachedProducts = unstable_cache(
  async () => {
    return await prisma.product.findMany({
      where: { published: true },
      include: { category: true },
    });
  },
  ['products-db'],
  { revalidate: 600, tags: ['products'] }
);
 
// Combined usage in Server Component
export default async function ProductListPage() {
  const products = await getCachedProducts();
  const productIds = products.map(p => p.id);
  const pricing = await getProductPricing(productIds);
 
  return (
    <div>
      {products.map(product => {
        const price = pricing[product.id];
        return <ProductCard key={product.id} {...product} price={price} />;
      })}
    </div>
  );
}

The fetch call handles the external pricing API via its own next.revalidate option. The unstable_cache wrapper handles the database query explicitly. Both use revalidation times and tags, but the invocation sites differ. The fetch version passes options as the second argument. The unstable_cache version passes configuration as the third argument after the cache key array.

The failure mode here surfaces when developers mix caching strategies incorrectly. Wrapping a fetch call inside unstable_cache creates double-caching — the fetch response caches at the request level, then the wrapper caches the entire result at the function level. This wastes memory and complicates invalidation.

The use cache Directive: The Future of Next.js Caching (And Why unstable_cache Is Legacy)

Next.js 16 introduced the use cache directive as the official successor to unstable_cache. The directive operates at the function or component level and provides the same memoization semantics with a cleaner API and better integration with React Server Components.

The use cache directive appears as the first line of a function or component. Next.js parses the directive during compilation and wraps the function with caching logic automatically. This eliminates the explicit unstable_cache wrapper and reduces boilerplate.

// New pattern with use cache directive
import { cacheLife, cacheTag } from 'next/cache';
 
export async function getCachedProducts() {
  'use cache';
  // Revalidation and tags are set inside the cached function
  cacheLife({ revalidate: 600 });
  cacheTag('products');
  return await prisma.product.findMany({
    where: { published: true },
    include: { category: true },
  });
}

The directive approach aligns with React's use client and use server conventions. Developers no longer need to import unstable_cache or manage cache keys manually. Next.js derives keys from the function signature and file path, reducing key collision risks.

%% alt: Comparison of unstable_cache pattern versus use cache directive pattern
flowchart LR
    subgraph LegacyApproach["unstable_cache: explicit wrapper"]
        Import["Import unstable_cache"]
        Wrap["Wrap function manually"]
        DefineKey["Define cache key array"]
        ConfigOptions["Pass revalidate/tags as object"]
    end

    subgraph DirectiveApproach["use cache: declarative directive"]
        AddDirective["Add 'use cache' as first line"]
        ExportConfig["Export revalidate/tags"]
        AutoKey["Framework derives key automatically"]
        IntegratedRevalidation["Integrated with revalidateTag/Path"]
    end

    Import --> Wrap
    Wrap --> DefineKey
    DefineKey --> ConfigOptions

    AddDirective --> ExportConfig
    ExportConfig --> AutoKey
    AutoKey --> IntegratedRevalidation

    style LegacyApproach fill:#450a0a,stroke:#ef4444,color:#fca5a5
    style DirectiveApproach fill:#0b3b2e,stroke:#34d399,color:#d1fae5
Comparison of unstable_cache pattern versus use cache directive pattern

This matters because the unstable_cache API will not receive new features or fixes. The Next.js team explicitly states that use cache builds on the legacy cache infrastructure but provides the path forward. Production apps should migrate to the directive pattern to ensure future compatibility.

The implication here is strategic. Teams building new features in 2026 should default to use cache for server-side memoization. Existing code using unstable_cache continues to work, but the migration path is clear. The directive reduces cognitive overhead and aligns caching with the broader React ecosystem.

Revalidation Strategies: Tags, Paths, and Time-Based Invalidation

Caching without invalidation creates stale data problems. Next.js provides three revalidation mechanisms: time-based expiration, tag-based invalidation, and path-based revalidation. Each mechanism targets different use cases and requires explicit configuration.

Time-based revalidation sets a maximum age for cached data. After the revalidate interval expires, Next.js regenerates the data on the next request. This pattern works for data that changes predictably — stock prices that update every minute, blog posts that publish daily.

Tag-based invalidation groups cached entries under semantic labels. When data changes, the app calls revalidateTag to purge all cache entries with that tag. This pattern works for data with complex dependencies — invalidating all product-related caches when a new product ships.

// Server Action that invalidates caches
'use server';
import { revalidateTag, revalidatePath } from 'next/cache';
 
export async function updateProduct(productId: string, data: ProductData) {
  await prisma.product.update({
    where: { id: productId },
    data,
  });
 
  // Tag-based: invalidate all caches tagged 'products'
  revalidateTag('products');
 
  // Path-based: invalidate specific route cache
  revalidatePath(`/products/${productId}`);
  revalidatePath('/products');
}

Path-based revalidation invalidates the Full Route Cache for specific URLs. This pattern works for page-level invalidation — purging the cached HTML for a product detail page when the product updates.

%% alt: Revalidation flow showing time-based, tag-based, and path-based invalidation
flowchart TD
    DataChange["Data mutation occurs"]
    ChooseStrategy{"Choose invalidation strategy"}
    TimeBasedWait["Wait for revalidate interval"]
    CallRevalidateTag["Call revalidateTag with label"]
    CallRevalidatePath["Call revalidatePath with URL"]
    PurgeDataCache["Purge Data Cache entries"]
    PurgeRouteCache["Purge Full Route Cache"]
    NextRequest["Next request regenerates data"]

    DataChange --> ChooseStrategy
    ChooseStrategy -->|Time-based| TimeBasedWait
    ChooseStrategy -->|Tag-based| CallRevalidateTag
    ChooseStrategy -->|Path-based| CallRevalidatePath

    TimeBasedWait --> NextRequest
    CallRevalidateTag --> PurgeDataCache
    CallRevalidatePath --> PurgeRouteCache
    PurgeDataCache --> NextRequest
    PurgeRouteCache --> NextRequest

    classDef userAction fill:#142544,stroke:#7c9cf0,color:#eaf2ff
    classDef framework fill:#0b3b2e,stroke:#34d399,color:#d1fae5
    class DataChange,CallRevalidateTag,CallRevalidatePath userAction
    class ChooseStrategy,PurgeDataCache,PurgeRouteCache,NextRequest framework
Revalidation flow showing time-based, tag-based, and path-based invalidation

The critical detail here is that revalidation must be triggered explicitly in Server Actions or Route Handlers. Next.js does not watch your database or API for changes. If a product updates via an external admin panel, the app must expose a webhook endpoint that calls revalidateTag to purge stale caches.

Production Patterns: Database Queries, CMS Fetches, and Edge Cases

Production caching patterns differ by data source. Database queries benefit from function-level caching via use cache or unstable_cache. CMS fetches benefit from fetch-level caching with revalidation tags. Edge cases — user-specific data, real-time feeds — benefit from no caching at all.

For database queries, wrap the query function with use cache and assign a unique tag. This pattern ensures the query executes once per revalidation interval, reducing database load.

// Database query caching pattern
export async function getCachedUserOrders(userId: string) {
  'use cache';
  cacheLife({ revalidate: 300 }); // 5 minutes
  cacheTag('orders', `user-${userId}`);
  return await prisma.order.findMany({
    where: { userId },
    include: { items: true },
    orderBy: { createdAt: 'desc' },
  });
}

For CMS fetches, use native fetch with a cache or revalidate option set explicitly. Once that option is in place the framework handles the caching, and the CMS webhook can call revalidateTag when content changes.

%% alt: Production caching flow for database queries, CMS fetches, and real-time data
flowchart TD
    IncomingRequest["Incoming request"]
    RouteRequest{"Data source type"}
    DatabaseQuery["Database query with use cache"]
    CMSFetch["CMS fetch with revalidate"]
    RealtimeData["Real-time data with cache: no-store"]
    CheckCache{"Cache hit?"}
    ReturnCached["Return cached data"]
    ExecuteFetch["Execute fresh fetch"]
    RenderResponse["Render response"]

    IncomingRequest --> RouteRequest
    RouteRequest -->|Database| DatabaseQuery
    RouteRequest -->|CMS| CMSFetch
    RouteRequest -->|Real-time| RealtimeData

    DatabaseQuery --> CheckCache
    CMSFetch --> CheckCache
    RealtimeData --> ExecuteFetch

    CheckCache -->|Yes| ReturnCached
    CheckCache -->|No| ExecuteFetch
    ReturnCached --> RenderResponse
    ExecuteFetch --> RenderResponse

    classDef framework fill:#0b3b2e,stroke:#34d399,color:#d1fae5
    classDef dataStore fill:#3a2f0b,stroke:#fbbf24,color:#fef3c7
    class IncomingRequest,DatabaseQuery,CMSFetch,RealtimeData framework
    class CheckCache,ReturnCached dataStore
Production caching flow for database queries, CMS fetches, and real-time data

For user-specific data or real-time feeds, disable caching entirely. Use fetch with cache: 'no-store' or wrap the component with export const dynamic = 'force-dynamic'. This ensures every request executes fresh queries.

The edge case that teams miss involves partial personalization. A product listing page might cache the product data but personalize the "Add to Cart" button based on the user's cart state. The solution here is to split the component: cache the product list in a Server Component, then pass it to a Client Component that handles personalization.

Decision Framework: Choosing the Right Caching Strategy for Your App

Choosing the correct caching strategy requires matching the mechanism to the data source and staleness tolerance. Start by categorizing data sources: external HTTP APIs, database queries, CMS content, third-party SDKs, and user-specific data.

For external HTTP APIs, use native fetch with revalidation options. Caching is opt-in, so set cache: 'force-cache' or next.revalidate on the call to match the data's acceptable staleness — 60 seconds for product pricing, 3600 seconds for blog posts.

For database queries and third-party SDKs, use the use cache directive with exported revalidation config. This pattern provides explicit control over cache keys and invalidation tags. Assign semantic tags like products, users, orders to enable granular invalidation via revalidateTag.

For user-specific data, disable caching. Use cache: 'no-store' in fetch calls or export const dynamic = 'force-dynamic' in components. This ensures personalized content always reflects the current user state.

For real-time data — live dashboards, chat messages, collaborative editing — use polling or WebSockets instead of caching. The latency requirement makes caching counterproductive.

The failure mode here is over-caching. Teams apply caching to everything, then discover that user-specific data leaks across sessions or that real-time feeds display stale information. The principle is to cache aggressively for static content and conservatively for dynamic content.

That covers the essential patterns for Next.js caching in 2026. The use cache directive replaces unstable_cache as the primary server-side caching mechanism. The fetch cache remains effective for HTTP APIs but does not cover database queries or SDK calls. Revalidation requires explicit configuration via tags, paths, or time intervals. Apply these patterns in production and the difference will be immediate.

Frequently Asked Questions

Should I migrate from unstable_cache to use cache immediately?

Migrate new features to use cache immediately, but existing unstable_cache code can remain in place. The legacy API continues to work, but the directive pattern provides better integration with Next.js 16+ features and aligns with the framework's long-term direction.

Does fetch cache work with GraphQL clients like Apollo or Relay?

No, fetch cache only applies to native fetch calls. GraphQL clients use their own HTTP layers that bypass Next.js caching. Wrap GraphQL queries with use cache or use the client's built-in caching mechanism instead.

How do I cache data that depends on user authentication state?

Do not cache user-specific data at the framework level. Use cache: 'no-store' for the fetch or export const dynamic = 'force-dynamic' for the component. Let the Client Component handle user-specific logic with React state or a client-side cache like SWR.

Can I use both fetch cache and use cache in the same component?

Yes, the two mechanisms operate independently. Use fetch for external HTTP APIs and use cache for database queries in the same Server Component. Ensure revalidation tags align across both mechanisms to maintain consistency.

What happens if two functions use the same cache key in unstable_cache?

Cache key collisions cause incorrect data returns. The second function overwrites the first function's cache entry, and subsequent calls to either function return the wrong result. Always use unique, namespaced keys like ['user-profile', userId] to prevent collisions.