Next.js unstable_cache is the most powerful caching primitive nobody explains well
I kept reaching for the Next.js fetch cache and hitting its limits — it only works with HTTP requests, not database queries. Then I found unstable_cache, which works with any async function. Despite its intimidating name, it has been stable in production for me for over a year. Here is everything you need to know to use it effectively.
What unstable_cache does
unstable_cache wraps any async function and caches its result. The cache key is derived from the function arguments. You can tag cache entries for granular invalidation with revalidateTag.
Basic usage
import { unstable_cache } from 'next/cache';
import { db } from '@/lib/db';
// Without caching — runs on every request
async function getUserById(id: string) {
return db.query.users.findFirst({ where: { id } });
}
// With caching — result cached, invalidatable by tag
const getCachedUser = unstable_cache(
async (id: string) => {
return db.query.users.findFirst({ where: { id } });
},
['user'], // Cache key prefix — must be unique per cached function
{
tags: ['users'], // Tags for invalidation
revalidate: 3600, // Revalidate every hour (optional)
}
);
// In a Server Component
export async function UserProfile({ userId }: { userId: string }) {
const user = await getCachedUser(userId); // Cache hit on repeat calls
return {user?.name};
}
Granular tag-based invalidation
Tags are the most powerful feature. You can invalidate exactly what changed, nothing more:
// Define cached functions with specific tags
const getCachedUser = unstable_cache(
async (id: string) => db.query.users.findFirst({ where: { id } }),
['user-by-id'],
{ tags: ['users', (id) => `user-${id}`] } // Dynamic per-item tags
);
const getCachedUserPosts = unstable_cache(
async (userId: string) => db.query.posts.findMany({ where: { userId } }),
['user-posts'],
{ tags: ['posts', (userId) => `user-${userId}-posts`] }
);
// In a Server Action — invalidate just what changed
'use server';
import { revalidateTag } from 'next/cache';
export async function updateUser(userId: string, data: Partial) {
await db.update(users).set(data).where(eq(users.id, userId));
// Only invalidates this specific user's cache entries
revalidateTag(`user-${userId}`);
}
export async function updateUserPost(userId: string, postId: string) {
// ... update post
revalidateTag(`user-${userId}-posts`); // Invalidates only this user's posts
// Does NOT invalidate the user profile cache
}
Wrapping your entire data access layer
The pattern I use: all database queries go through cached wrappers in a queries/ directory.
// src/queries/products.ts
import { unstable_cache } from 'next/cache';
import { db } from '@/lib/db';
export const getProduct = unstable_cache(
async (id: string) => {
return db.query.products.findFirst({
where: { id },
with: { images: true, variants: true },
});
},
['product'],
{ tags: ['products', (id) => `product-${id}`] }
);
export const getProductsByCategory = unstable_cache(
async (categoryId: string, page: number = 1) => {
const limit = 20;
const offset = (page - 1) * limit;
return db.query.products.findMany({
where: { categoryId },
limit,
offset,
orderBy: { createdAt: 'desc' },
});
},
['products-by-category'],
{
tags: ['products', (categoryId) => `category-${categoryId}-products`],
revalidate: 300, // Revalidate every 5 minutes
}
);
The "why unstable_cache over fetch?" answer
Next.js fetch caching automatically caches fetch() calls. It's great for external API calls. But it only works with HTTP. For:
- Direct database queries (Drizzle, Prisma, raw SQL)
- File system reads
- Complex computations
- Any async function that isn't a fetch call
You need unstable_cache.
The "why unstable in the name?" answer
The API has been stable in behavior since Next.js 14 but the name was kept as "unstable" to signal that the caching semantics may change in future major versions. Next.js 15 introduced cacheLife and cacheTag as replacements, but unstable_cache continues to work. I use it in production and have not had issues — just be aware that Next.js 15 has a newer equivalent if you are starting fresh.