cacheHandlers
Back 'use cache' with your own storage so entries survive restarts and are shared across instances.
cacheHandlers?: { default?: string; remote?: string; [name: string]: string | undefined }- Added in Next.js v16.0.0
Parameters
| Parameter | Type | Description |
|---|---|---|
$defaultoptional | string= in-memory LRU | Path to the module implementing the handler used by the 'use cache' directive. Falls back to an in-memory LRU cache. |
$remoteoptional | string= in-memory LRU | Path to the module implementing the handler used by the 'use cache: remote' directive. Falls back to an in-memory LRU cache. |
$[name]optional | string= undefined | Any additional named handler, referenced from code as 'use cache: <name>'. 'use cache: private' is not configurable. |
Errors
Next.js does not wrap get() in a try/catch, so an unhandled exception from get() propagates as a render error — catch internally and return undefined to signal a miss.
Changelog
- PHP v16.0.0cacheHandlers introduced.
By default 'use cache' writes to an in-memory LRU that lives inside one Node process. Two containers means two caches, and a restart or redeploy empties them. cacheHandlers points the directives at a module you write, so entries can live in Redis, on disk, or anywhere else.
When to reach for it
Most apps never need one. Reach for it when several instances must share cached output, when entries should survive a restart, or when the memory a large in-memory cache holds is a cost you would rather move elsewhere. A common shape is to leave default in memory for hot values and give remote a shared store, then choose per call site with 'use cache' or 'use cache: remote'.
What trips people up
- A handler implements five methods:
get,set,refreshTags,getExpirationandupdateTags. Tag invalidation across instances only works ifupdateTagswrites to shared storage andrefreshTagsreads it back. CacheEntry.valueis aReadableStream. Use.tee()if you must both store and return it, and decide what to do with a stream that errors halfway.set()runs after the response is already streaming, so a failure loses the entry silently instead of breaking the request.'use cache: private'never uses a handler. Static export is not supported.
Example
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
cacheHandlers: {
default: require.resolve('./cache-handlers/default-handler.js'),
remote: require.resolve('./cache-handlers/remote-handler.js'),
},
}
export default nextConfigWhen to use it
- Share cached output across several containers or serverless instances instead of one cache per process.
- Keep cached pages warm across restarts and deploys by storing them in Redis or on disk.
- Make revalidateTag on one instance invalidate entries held by every other instance.
Frequently asked questions
How do I use Redis as the cache for 'use cache' in Next.js?
Write a module implementing get, set, refreshTags, getExpiration and updateTags, then point cacheHandlers.remote (or .default) at it with require.resolve. Serialize CacheEntry.value, which is a ReadableStream, on the way in and reconstruct it on the way out.
Why does revalidateTag not invalidate the cache on my other instances?
Because the default in-memory handler tracks tags locally only. Coordinating them requires your handler to write invalidation timestamps to shared storage in updateTags, read them back in refreshTags, and return the newest one from getExpiration.
Do I need a custom cache handler?
Most applications do not — the default in-memory LRU is fine for a single instance. Reach for one when several instances must share the cache, when entries need to survive restarts, or when you want the cache out of process memory.
More examples
A Redis-backed remote handler
set() must await pendingEntry, because the entry can still be rendering when the handler is called. The value is a stream, so it is drained into a buffer before serialization — for very large pages, stream straight to storage instead of buffering. This handler does not coordinate tags: getExpiration returning 0 says no tag was ever revalidated, so revalidateTag on one instance will not reach the others.
const { createClient } = require('redis')
const client = createClient({ url: process.env.REDIS_URL })
client.connect()
module.exports = {
async get(cacheKey, softTags) {
const stored = await client.get(cacheKey)
if (!stored) return undefined
const data = JSON.parse(stored)
return {
value: new ReadableStream({
start(controller) {
controller.enqueue(Buffer.from(data.value, 'base64'))
controller.close()
},
}),
tags: data.tags,
stale: data.stale,
timestamp: data.timestamp,
expire: data.expire,
revalidate: data.revalidate,
}
},
async set(cacheKey, pendingEntry) {
const entry = await pendingEntry
const reader = entry.value.getReader()
const chunks = []
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value)
}
} finally {
reader.releaseLock()
}
const data = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)))
await client.set(
cacheKey,
JSON.stringify({
value: data.toString('base64'),
tags: entry.tags,
stale: entry.stale,
timestamp: entry.timestamp,
expire: entry.expire,
revalidate: entry.revalidate,
}),
{ EX: entry.expire }
)
},
async refreshTags() {},
async getExpiration(tags) {
return 0
},
async updateTags(tags, durations) {},
}Coordinating tags across instances
This is the part a naive Redis handler misses. updateTags records the invalidation in shared storage when revalidateTag runs, refreshTags pulls those timestamps in before each request, and getExpiration reports the newest one so entries created earlier are treated as stale. A dedicated set of tag keys avoids scanning the Redis keyspace.
const { createClient } = require('redis')
const client = createClient({ url: process.env.REDIS_URL })
client.connect()
const localTagTimestamps = new Map()
module.exports = {
// ... get() and set() as above ...
async refreshTags() {
const tagKeys = await client.sMembers('revalidated-tags')
if (tagKeys.length > 0) {
const values = await client.mGet(tagKeys.map((k) => `tag:${k}`))
for (let i = 0; i < tagKeys.length; i++) {
localTagTimestamps.set(tagKeys[i], Number(values[i]))
}
}
},
async getExpiration(tags) {
const timestamps = tags.map((tag) => localTagTimestamps.get(tag) || 0)
return Math.max(...timestamps, 0)
},
async updateTags(tags, durations) {
const now = Date.now()
const pipeline = client.multi()
for (const tag of tags) {
pipeline.set(`tag:${tag}`, String(now))
pipeline.sAdd('revalidated-tags', tag)
localTagTimestamps.set(tag, now)
}
await pipeline.exec()
},
}Choosing the handler at the call site
Both scopes are cached, but they land in different handlers. Expensive output that every instance would otherwise recompute goes to remote; small values that are cheap to rebuild stay in memory, where a lookup costs nothing over the network.
import { cacheLife } from 'next/cache'
// In-memory: cheap, per-instance, gone on restart
export async function getNavigation() {
'use cache'
cacheLife('hours')
return db.navigation.findMany()
}
// Shared store: survives restarts, visible to every instance
export async function getProductCatalog() {
'use cache: remote'
cacheLife('hours')
return db.products.findMany()
}
Discussion