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'] },
});
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');
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');
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
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');
}
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
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'] }
);
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.