cacheHandlers
Configure custom cache handlers for use cache directives in Next.js.
The cacheHandlers configuration allows you to define custom cache storage implementations for 'use cache' and 'use cache: remote'. This enables you to store cached components and functions in external services or customize the caching behavior. 'use cache: private' is not configurable.
When to use custom cache handlers
Most applications don't need custom cache handlers. The default in-memory cache works well in the typical use case.
Custom cache handlers are for advanced scenarios where you need to either share cache across multiple instances or change where the cache is stored. For example, you can configure a custom remote handler for external storage (like a key-value store), then use 'use cache' in your code for in-memory caching and 'use cache: remote' for the external storage, allowing different caching strategies within the same application.
Sharing cache across instances
The default in-memory cache is isolated to each Next.js process. If you're running multiple servers or containers, each instance will have its own cache that isn't shared with others and is lost on restart.
Custom handlers let you integrate with shared storage systems (like Redis, Memcached, or DynamoDB) that all your Next.js instances can access.
Changing storage type
You might want to store cache differently than the default in-memory approach. You can implement a custom handler to store cache on disk, in a database, or in an external caching service. Reasons include: persistence across restarts, reducing memory usage, or integrating with existing infrastructure.
Usage
To configure custom cache handlers:
- Define your cache handler in a separate file, see examples for implementation details.
- Reference the file path in your Next config file
Handler types
default: Used by the'use cache'directiveremote: Used by the'use cache: remote'directive
If you don't configure cacheHandlers, Next.js uses an in-memory LRU (Least Recently Used) cache for both default and remote. You can view the default implementation as a reference.
You can also define additional named handlers (e.g., sessions, analytics) and reference them with 'use cache: <name>'.
Note that 'use cache: private' does not use cache handlers and cannot be customized.
CacheEntry Type
A cache handler stores and returns CacheEntry objects. Both this type and CacheHandler are exported from next/cache:
An entry has the following structure:
| Property | Type | Description |
|---|---|---|
value | ReadableStream<Uint8Array> | The cached data as a single-use stream. |
tags | string[] | Cache tags (excluding soft tags). |
stale | number | Duration in seconds for client-side staleness. |
timestamp | number | When the entry was created (timestamp in milliseconds). |
expire | number | How long the entry is allowed to be used (in seconds). |
revalidate | number | How long until the entry should be revalidated (in seconds). |
The entry value stream
Every handler writes entries through set() and serves them through get(). Both methods work with the entry's serialized value, which arrives as a ReadableStream<Uint8Array>. Two properties of that stream constrain every implementation:
- It can be read only once. Consume it in
set(), and build a new stream over your stored data in everyget(). A stream that was already read cannot serve a second reader: it delivers no data, or throws if the first reader still holds its lock. - It belongs to the request that produced the entry. Do not keep it once
set()resolves. The stream holds a reference to that request, so keeping it holds the request's memory until your entry is evicted. The same applies to thependingEntrypromise. Next.js keeps its own copy of the value for the render, so your handler is free to consume the stream it receives.
How you persist the data is up to you. Buffering the stream and piping it to your store as chunks arrive are both fine, as long as you consume it exactly once. Large entries are worth piping: a page can serialize to a lot of bytes, and an S3-like backend can accept them without your handler holding a second copy.
Two more cases to plan for:
- Fan-out. If a single entry has to reach two destinations, such as a local store and a remote one, use
.tee()and consume both branches beforeset()resolves. Teeing is not a way to keep the value around. A stored branch does serve the next read, but it holds the source stream, its buffered chunks, and the request that produced them, and none of that appears in the size your store accounts for. - Interrupted values. The stream can error partway through rendering, which leaves you holding an incomplete value. Decide whether to keep it or discard it. Discarding is safer, because an incomplete value produces an incomplete page. This is separate from a partially written storage entry, which Error Handling covers.
API Reference
A cache handler must implement the CacheHandler interface with the following five methods.
set()
Store a cache entry for the given cache key.
| Parameter | Type | Description |
|---|---|---|
cacheKey | string | The unique key to store the entry under. |
pendingEntry | Promise<CacheEntry> | A promise that resolves to the cache entry. |
Returns Promise<void>.
The entry may still be generating when set() is called, so await pendingEntry first. Then consume the value stream and persist what it delivers, as described in The entry value stream.
The example below buffers, which is the simpler option and the right one for an in-memory store. A remote handler can pipe entry.value into an upload instead.
get()
Retrieve a cache entry for the given cache key.
| Parameter | Type | Description |
|---|---|---|
cacheKey | string | The unique key for the cache entry. |
softTags | string[] | Implicit tags derived from the route path. See Soft Tags for how to use them. |
Returns a CacheEntry object if one is stored, or undefined if not.
Your get method should retrieve the cache entry from storage and return undefined when there is nothing stored.
You do not need to check how old the entry is. Next.js compares timestamp against the entry's own expire on every read and treats a too-old entry as a miss. It applies the same check to revalidate when the result is about to be written into another server cache, and it keeps entries a little longer on the dev server so reloads stay fast. Your own age check only duplicates the first of those.
What the two timings mean for you is what Next.js does with the entry you return:
- Past
expire, the entry is discarded and the value is regenerated. - Past
revalidatebut withinexpire, the entry is served and a fresh one is generated in the background.
Dropping entries earlier is a policy choice rather than a requirement. The built-in handler drops at revalidate because warming a replacement is wasted work when an in-memory entry is likely to be evicted before anything reads it. A shared store has no such problem and can serve until expire.
Eviction is yours, though. Next.js never deletes from your store, so use a mechanism that reclaims entries nobody reads again: a TTL derived from expire, or a size-bounded LRU like the one in the built-in handler. Checking the age of an entry as you serve it is not eviction, because it only ever reaches the keys that are still being read.
Invalidation is separate from both timings. When a tag tells you an entry is out of date, you have two options:
- Return
undefined. The entry counts as missing, and Next.js regenerates it now. - Return the entry with
revalidate: -1. Next.js serves it and regenerates in the background, because a negativerevalidatealways lies in the past.
See Soft Tags and Distributed Tag Coordination for where those invalidations come from.
Wrap your stored data in a new stream on every hit, never the stream you received in set().
refreshTags()
Called once per request, before the first cache read for this handler's kind, so the handler can sync tag state from an external service.
Returns Promise<void>.
For in-memory caches, this can be a no-op. For distributed caches, use it to read tag state from an external service or database, so this instance learns about invalidations made by the others before it serves anything from its own store. A request that reads nothing from this handler never calls it.
getExpiration()
Get the maximum revalidation timestamp for a set of tags.
| Parameter | Type | Description |
|---|---|---|
tags | string[] | Array of tags to check expiration for. |
Returns:
0if none of the tags were ever revalidated- A timestamp (in milliseconds) representing the most recent revalidation
Infinityto indicate soft tags should be checked in thegetmethod instead
If you're not tracking tag revalidation timestamps, return 0. Otherwise, find the most recent revalidation timestamp across all the provided tags. Return Infinity if you prefer to handle soft tag checking in the get method.
updateTags()
Called when tags are revalidated or expired.
| Parameter | Type | Description |
|---|---|---|
tags | string[] | Array of tags to update. |
durations | { expire?: number } | Optional expiration duration in seconds. |
Your handler should update its internal state to mark these tags as invalidated.
Returns Promise<void>.
When tags are revalidated, your handler should invalidate all cache entries that have any of those tags. Iterate through your cache and remove entries whose tags match the provided list.
Soft Tags
Soft tags are implicit tags that Next.js automatically generates based on the route path. Every segment in the path gets a layout tag, plus the leaf route itself. For example, the route /blog/hello generates soft tags for /layout, /blog/layout, /blog/hello/layout, and /blog/hello. These tags are prefixed internally with _N_T_.
Soft tags enable revalidatePath() to work through the same tag-based cache system. When revalidatePath('/blog/hello') is called, it invalidates all cache entries associated with that path's soft tags.
In the cache handler API, soft tags are passed to the get() method as the softTags parameter. Your handler should check whether any soft tag has been invalidated (via getExpiration() or direct timestamp comparison) after the cache entry's timestamp.
If an invalidation is more recent than the entry, report the entry as out of date using either option described in get(). The built-in handler uses both: it returns undefined for an expired tag, and revalidate: -1 for a stale one.
Distributed Tag Coordination
When running multiple Next.js instances, tag invalidation must be coordinated across instances. The default in-memory handler only tracks tags locally, so calling revalidateTag() on one instance does not affect others.
To coordinate tags across instances:
updateTags()is called whenrevalidateTag()is invoked. Your handler should write the invalidation timestamp to shared storage.refreshTags()runs once per request, before the first read from this handler. Your handler should read recent invalidation events from shared storage and update its local tag state.getExpiration()returns the most recent revalidation timestamp across all provided tags. The default implementation returnsMath.max(...timestamps, 0).
Here's an example using Redis for distributed tag coordination:
For a full explanation of the tag architecture (including soft tags and multi-instance considerations), see How Revalidation Works.
Error Handling
Cache operations should be implemented defensively:
set()failure: the response is still served to the user becauseset()is called asynchronously after the response stream is already flowing. The cache entry is lost, and the next request triggers a fresh render. Catch your own errors even so: a rejectedset()joins the request's pending revalidation work, where it surfaces as a failed background task instead of a render error.get()failure: your handler should catch internal errors and returnundefined(the "cache miss" signal). The framework does not wrapget()in a try/catch, so an unhandled exception fromget()will propagate as a render error.- Partial writes: if a cache entry is partially written and then read, the behavior is undefined. Use atomic writes or a write-then-rename pattern to avoid serving partial entries.
Examples
Basic in-memory cache handler
Here's a minimal implementation using a Map for storage. This example demonstrates the core concepts, but for a production-ready implementation with LRU eviction, error handling, and tag management, see the default cache handler.
External storage pattern
For durable storage like Redis or a database, you'll need to serialize the cache entries. Here's a simple Redis example:
Platform Support
| Deployment Option | Supported |
|---|---|
| Node.js server | Yes |
| Docker container | Yes |
| Static export | No |
| Adapters | Platform-specific |
Version History
| Version | Changes |
|---|---|
v16.0.0 | cacheHandlers introduced. |