Next.js 15 Caching Explained: unstable_cache, React cache, and fetch() in 2026

typescript dev.to

Next.js 15 Caching Explained: unstable_cache, React cache, and fetch() in 2026

Next.js caching has three APIs that look similar but behave completely differently. Here's the mental model that finally made it click for me.

The Three Caching Primitives

1. fetch() — Network-Level Cache

// Cached by URL + options fingerprint
// Revalidates on time or tag
const data = await fetch('https://api.example.com/data', {
  next: { revalidate: 60 }, // ISR-style TTL
});

// Or tag-based invalidation
const data = await fetch('https://api.example.com/products', {
  next: { tags: ['products'] },
});
Enter fullscreen mode Exit fullscreen mode

What it does: Caches HTTP responses at the Next.js data cache layer. Survives across requests, survives deploys (unless you invalidate). This is your CDN-equivalent for server-side data fetching.

When to use: External APIs that don't change often. REST endpoints you own. Any GET with predictable cache semantics.

Gotcha in Next.js 15: fetch() is no longer cached by default. You must explicitly set cache: 'force-cache' or next.revalidate. This broke a lot of apps upgrading from Next.js 14.

2. unstable_cache — Database/Arbitrary Function Cache

import { unstable_cache } from 'next/cache';

const getProducts = unstable_cache(
  async (categoryId: string) => {
    // This runs against your DB — Prisma, Drizzle, raw SQL
    return db.product.findMany({ where: { categoryId } });
  },
  ['products'], // cache key prefix
  {
    revalidate: 300, // 5 minutes
    tags: ['products'], // for on-demand invalidation
  }
);

// Usage in a Server Component
const products = await getProducts('electronics');
Enter fullscreen mode Exit fullscreen mode

What it does: Wraps any async function and caches its return value. This is how you cache database queries, third-party SDK calls, or any computation you don't want to repeat on every request.

When to use: Prisma queries, Drizzle queries, Supabase client calls, anything that doesn't use fetch().

Cache key gotcha: The key includes the arguments you pass. getProducts('electronics') and getProducts('clothing') are stored separately. But the key derivation can be surprising — always test with console.log first.

3. cache() (React cache) — Request-Level Deduplication

import { cache } from 'react';

// This is NOT a persistent cache — it lives for ONE request
const getUser = cache(async (userId: string) => {
  return db.user.findUnique({ where: { id: userId } });
});

// If called 5 times in one request with the same userId,
// the DB query runs ONCE
const user = await getUser('123');
Enter fullscreen mode Exit fullscreen mode

What it does: Deduplicates function calls within a single render pass. If three Server Components on the same page call getUser('123'), the function executes once and the result is shared.

When to use: Utility functions called from multiple components. User session lookups. Anything you want to compute once per request but not persist across requests.

Critical distinction: React.cache does NOT persist between requests. Every new request starts fresh.

The Mental Model

Request lifetime:  React cache()      — lives for 1 request, auto-cleared
Short-term:        unstable_cache()   — minutes/hours, survives requests
Long-term:         fetch() cache      — hours/days, CDN-equivalent
Enter fullscreen mode Exit fullscreen mode

On-Demand Revalidation

import { revalidateTag, revalidatePath } from 'next/cache';

// In your API route or Server Action:
export async function updateProduct(id: string, data: UpdateData) {
  await db.product.update({ where: { id }, data });

  // Invalidate all caches tagged 'products'
  revalidateTag('products');

  // Or invalidate a specific path
  revalidatePath('/products');
}
Enter fullscreen mode Exit fullscreen mode

This works across all three primitives — any cache entry tagged 'products' gets cleared, whether it came from fetch() or unstable_cache().

Common Mistakes

1. Using unstable_cache for user-specific data without proper key scoping:

// WRONG — all users see the same cached result
const getCart = unstable_cache(async () => {
  return db.cart.findMany({ where: { userId: session.userId } });
}, ['cart']);

// RIGHT — scope key to user
const getCart = unstable_cache(
  async (userId: string) => {
    return db.cart.findMany({ where: { userId } });
  },
  ['cart'],
);
const cart = await getCart(session.userId); // userId in key
Enter fullscreen mode Exit fullscreen mode

2. Expecting React.cache to persist across requests (it doesn't)

3. Forgetting Next.js 15 broke default fetch() caching — add cache: 'force-cache' explicitly if you want the old behavior.

The Production Setup I Use

// lib/cache.ts
import { unstable_cache } from 'next/cache';

export const withCache = <T, Args extends unknown[]>(
  fn: (...args: Args) => Promise<T>,
  keyParts: string[],
  options: { revalidate?: number; tags?: string[] } = {}
) => unstable_cache(fn, keyParts, { revalidate: 60, ...options });

// Usage
export const getProducts = withCache(
  (categoryId: string) => db.product.findMany({ where: { categoryId } }),
  ['products'],
  { revalidate: 300, tags: ['products'] }
);
Enter fullscreen mode Exit fullscreen mode

This gives you a consistent interface and makes revalidate TTLs configurable from one place.


Build Faster With Production-Ready Patterns

The AI SaaS Starter Kit includes this caching setup pre-wired with Drizzle ORM, Next.js 15 App Router, and Stripe — skip the configuration archaeology.

$99 one-time → whoffagents.com


What's the most confusing part of Next.js caching you've hit? Drop it below.

Source: dev.to

arrow_back Back to Tutorials