Next.js App Router Cache Strategy Design — How to Combine fetch cache Options, revalidate, and unstable_cache for Your Use Case
One of the most common questions I get after switching to Next.js App Router is "how should I configure caching?" Honestly, I was confused at first too — whether to use force-cache or no-store, where to attach revalidate — and the name unstable_cache scared me off from using it for a while.
Once you dig in, you'll find that each API covers a different area. Running everything with no-store can push TTFB up by 2–3x, while using force-cache carelessly can cause updated content to reach users much later than expected. The core of this article is establishing a decision framework for when to use fetch's cache option, next.revalidate, and unstable_cache — and when to combine them.
This article is aimed at frontend developers who have already used or are currently using Next.js App Router. If you've called fetch from a Server Component before, you'll be able to follow along right away.
Core Concepts
Three Caching Layers, Each with Its Own Role
Caching in Next.js App Router is not a single system. It's easiest to think of it as three distinct layers.
Layer
Applied To
Core Role
fetch + cache option
HTTP fetch calls
Web standard-based, controls the Data Cache (server-side HTTP response cache)
next: { revalidate }
HTTP fetch calls
TTL-based stale-while-revalidate
unstable_cache
DB queries, ORMs, arbitrary functions
Caches async functions other than fetch
The layers are not independent — they share tags, which allows you to connect on-demand invalidation across them. Understanding this connection makes the overall strategy much clearer.
fetch Cache Options — How Long to Reuse an HTTP Response
Next.js extends the Web standard fetch to control the server-side Data Cache. There are three commonly used options.
typescript
// Fixed at build time — permanent cacheconst res = await fetch('https://api.example.com/posts', { cache: 'force-cache',})// Fetch fresh on every request — bypasses cache entirelyconst res = await fetch('https://api.example.com/posts', { cache: 'no-store',})// 60-second TTL + tag-based invalidationconst res = await fetch('https://api.example.com/posts', { next: { revalidate: 60, tags: ['posts'] },})
Next.js 15 Default Change: Up through Next.js 14, force-cache was the default, but starting with 15, no-store is the new default. The reasoning is that explicitly opting in to caching is better than silently relying on it. If you've seen performance regressions after upgrading, this is the first place to check.
revalidate — "Serve Stale Content, But Revalidate in the Background"
next: { revalidate: N } applies ISR (Incremental Static Regeneration) at the fetch level. The cached response is served immediately for N seconds, and after expiry, the first incoming request receives the old data while a fresh fetch begins in the background.
stale-while-revalidate: A pattern where, even after the cache expires, the stale value is returned immediately while a background revalidation starts simultaneously. Users always get a fast response, but they may see the previous data once right after revalidation kicks in.
The first time I used this, I set the TTL to 5 minutes — and ran into a situation where a product price update wasn't reflected to users for 5 minutes after the change. If you're working with data where freshness matters, keep this behavior in mind.
Request Memoization — Deduplicating Requests Within the Same Render Cycle
This is a surprisingly useful concept to know. Within a single server rendering cycle, even if multiple components each independently call fetch to the same URL, only one actual network request goes out. React's internal optimization, Request Memoization, deduplicates them. This operates separately from the server-side Data Cache.
This means that when you're deciding "should I pass this data down as props or fetch it directly in each component?", you can freely choose the latter without worrying about performance.
unstable_cache — Caching Any Async Function That Doesn't Use fetch
When using an ORM like Prisma or Drizzle, or querying a DB directly via a client, fetch is not involved. That's where unstable_cache comes in.
typescript
import { unstable_cache } from 'next/cache'import { db } from './db'export const getCachedPosts = unstable_cache( async () => { return await db.post.findMany({ where: { published: true } }) }, ['posts-list'], // cache key prefix { tags: ['posts'], // tag for on-demand invalidation revalidate: 3600, // 1-hour TTL })
Function arguments are automatically included in the cache key via JSON serialization. When passing objects or arrays as arguments, the serialization result may not match your expectations, so it's safer to use simple values like strings or numbers where possible.
The unstable in the name might make you think it's unsafe for production — but it's actually widely used in production today. It's simply an API in a transitional state, as the official docs recommend moving toward use cache in the long run. The one thing to be careful about is cache key design: a mistake there can cause serious bugs where one user's data leaks to another.
On-demand Invalidation — Instantly Clearing the Cache When an Event Occurs
Time-based revalidation isn't always enough. When a CMS publishes an article or an admin updates product info, you may need the change to appear immediately. That's what revalidateTag and revalidatePath are for.
revalidateTag invalidates all caches — both from fetch's tags option and unstable_cache's tags option — in one call. With well-designed tags, a single Server Action can refresh multiple layers at once.
Experimental API: use cache (Next.js 15+)
The use cache directive is an attempt to unify the approaches above into a single API. To enable it, you must set experimental.dynamicIO: true in next.config.js. Writing 'use cache' in your code without this flag does nothing.
'use cache'import { cacheLife, cacheTag } from 'next/cache'export async function getBlogPosts() { cacheTag('posts') cacheLife('hours') // built-in profile with predefined stale/revalidate/expire values return await db.post.findMany()}
The built-in cacheLife profiles (seconds, minutes, hours, days, weeks, max) each come with preset stale/revalidate/expire values. You can also add custom profiles via the cacheLife key in next.config.js. Since it's still experimental, incomplete integration with on-demand ISR is something to verify before shipping to production.
Practical Application
Example 1: Blog Post List — Build-time Cache
For static content that rarely changes, the simplest approach is to fix it at build time with force-cache. One useful tip: attach tags alongside it so you can connect it to CMS webhooks for on-demand invalidation later.
typescript
// app/blog/page.tsxasync function BlogPage() { const res = await fetch('https://cms.example.com/posts', { cache: 'force-cache', next: { tags: ['posts'] }, }) if (!res.ok) { throw new Error('Failed to load post list') } const posts = await res.json() return <PostList posts={posts} />}
Item
Description
cache: 'force-cache'
Stored in cache on the first request; subsequent requests return cached response
tags: ['posts']
Calling revalidateTag('posts') from a CMS webhook triggers immediate revalidation
Using force-cache alone means content updates after deployment won't appear until the next deploy. Attaching tags and connecting it to a CMS publish event solves this.
But what if the content changes fairly often?
Example 2: Product List — Time-based Revalidation
This is for cases that don't need real-time freshness but do need updates on the order of minutes — think e-commerce product listings or news feeds.
typescript
// app/shop/page.tsxasync function ShopPage() { const res = await fetch('https://api.shop.com/products', { next: { revalidate: 300, tags: ['products'] }, // 5-minute TTL }) if (!res.ok) { throw new Error('Failed to load product list') } const products = await res.json() return <ProductList products={products} />}
Repeat visitors within 5 minutes get the cached response, while the first visitor after 5 minutes receives the previous data as a background refresh begins. Most users effectively get near-current data at fast response times.
On the other hand, for cases where real-time accuracy is essential — like checking stock levels right after placing an order — no-store is the right choice.
typescript
// Real-time stock check — bypasses cache entirelyasync function StockPage() { const res = await fetch('https://api.shop.com/stock/current', { cache: 'no-store', }) if (!res.ok) { throw new Error('Failed to load stock information') } const stock = await res.json() return <StockDisplay stock={stock} />}
Example 3: DB Query Caching — Using unstable_cache
When using an ORM — Prisma or Drizzle accessing a DB directly — fetch is not in the picture, so unstable_cache is needed. The key challenge here is how to include dynamic values in the cache key.
typescript
// lib/data.tsimport { unstable_cache } from 'next/cache'import { db } from './db'// Include userId in the key to isolate the cache per userexport const getUserProfile = unstable_cache( async (userId: string) => { return db.user.findUnique({ where: { id: userId } }) }, ['user-profile'], // cache key prefix { tags: ['user'], revalidate: 600 } // 10-minute TTL)// Call site — with null guardconst profile = await getUserProfile(session.userId)if (!profile) { redirect('/login')}
Item
Description
['user-profile']
Cache key prefix. The actual key is a combination of the prefix + function arguments (JSON-serialized)
tags: ['user']
Calling revalidateTag('user') invalidates all entries under this tag
revalidate: 600
Auto-refreshes after 10 minutes even if on-demand invalidation fails
Example 4: CMS Integration — On-demand Invalidation Webhook
For cases where content published in a CMS needs to be reflected immediately. Set up a Route Handler as a webhook endpoint.
typescript
// app/api/revalidate/route.tsimport { revalidateTag } from 'next/cache'import { NextRequest } from 'next/server'export async function POST(req: NextRequest) { const { tag, secret } = await req.json() if (secret !== process.env.REVALIDATE_SECRET) { return Response.json({ error: 'Unauthorized' }, { status: 401 }) } revalidateTag(tag) return Response.json({ revalidated: true, tag })}
When the CMS sends a POST /api/revalidate request with { tag: 'posts', secret: '...' }, all caches carrying that tag — whether from fetch or unstable_cache — are invalidated at once. Without authentication, anyone could wipe the cache, so don't skip the REVALIDATE_SECRET check.
When using this in a Server Action, it's important to ensure revalidateTag is only called after the DB update succeeds.
typescript
// app/actions.ts'use server'import { revalidateTag } from 'next/cache'export async function publishPost(id: string) { // If the update fails, an exception is thrown and the line below never runs await db.post.update({ where: { id }, data: { published: true } }) revalidateTag('posts')}
Example 5: Component-level Caching — use cache (Next.js 15+)
In environments where use cache is enabled, you can also manage caching at the component level. This is useful when you want each individual item — like a product card — to have its own independent cache lifetime.
Designing cacheTag as product-${productId} lets you selectively invalidate individual products. A single revalidateTag('product-abc123') call refreshes just that product card without touching any others.
The Most Common Mistakes in Practice
Storing user-specific data in a global cache — If you omit userId from the unstable_cache key, user A's profile can end up being shown to user B. Always include an identifier in the key for personalized data.
Relying solely on on-demand invalidation — Webhook failures, network errors, and other issues can cause revalidateTag calls to be missed. It's safer to also set a time-based revalidate as a fallback.
Mixing force-cache and no-store in the same Route Segment — Rather than throwing an error, the entire route silently falls back to dynamic rendering. This can cause unintended performance degradation, so it's best to keep a consistent cache strategy within a single route.
Pros and Cons
Advantages
Item
Details
fetch + cache: 'force-cache'
Web standard API; automatic Request Memoization deduplicates requests to the same URL
revalidate
Applies ISR directly at the fetch level; users always receive fast responses
unstable_cache
Applicable to any async function beyond fetch — ORMs, SDKs, etc.
revalidateTag
Event-driven instant refresh; tag-based bulk invalidation across fetch and unstable_cache
use cache
Unified caching at the function, component, and file level; more type-safe than unstable_cache
Disadvantages and Caveats
Item
Details
Mitigation
force-cache default change
Upgrading from Next.js 14 to 15 can silently disable caching and degrade performance
Explicitly add cache options where needed
stale-while-revalidate behavior
The first request after revalidation returns old data
Use no-store for data that must be real-time
unstable_cache key pollution
Omitting userId from the key can mix data across users
Always include dynamic identifiers in cache keys
Overly broad tags
Blanket invalidation like revalidateTag('all')
Design hierarchical tags like posts, post-{id}
use cache experimental status
Incomplete integration with on-demand ISR; requires the dynamicIO flag
Verify behavior before shipping to production
Closing Thoughts
The essence of a caching strategy is an explicit declaration of "how long this data remains valid." Without that declaration, every request under Next.js 15's default no-store hits the server fresh — and that's exactly where the TTFB difference mentioned at the start comes from. Conversely, by explicitly declaring cache behavior suited to each piece of data, you can reduce server load while delivering predictable, fast responses to users.
Three steps you can take right now:
Find every place in your current project that calls fetch and check whether a cache option is explicitly set. If you've upgraded from Next.js 14 to 15, some routes may have been silently affected by the default change.
If you have functions that call an ORM or DB client directly, try wrapping them with unstable_cache and adding a tags option. Designing tag names hierarchically — like posts and post-{id} — lets you fine-tune the scope of invalidation.
If you have a CMS or admin interface, connecting revalidateTag to a Server Action or Route Handler lets you reflect content changes instantly without manual redeployment.