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:

  1. Define your cache handler in a separate file, see examples for implementation details.
  2. Reference the file path in your Next config file
import type { NextConfig } from 'next'
 
const nextConfig: NextConfig = {
  cacheHandlers: {
    default: require.resolve('./cache-handlers/default-handler.js'),
    remote: require.resolve('./cache-handlers/remote-handler.js'),
  },
}
 
export default nextConfig

Handler types

  • default: Used by the 'use cache' directive
  • remote: 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:

import type { CacheHandler, CacheEntry } from 'next/cache'

An entry has the following structure:

interface CacheEntry {
  value: ReadableStream<Uint8Array>
  tags: string[]
  stale: number
  timestamp: number
  expire: number
  revalidate: number
}
PropertyTypeDescription
valueReadableStream<Uint8Array>The cached data as a single-use stream.
tagsstring[]Cache tags (excluding soft tags).
stalenumberDuration in seconds for client-side staleness.
timestampnumberWhen the entry was created (timestamp in milliseconds).
expirenumberHow long the entry is allowed to be used (in seconds).
revalidatenumberHow 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 every get(). 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 the pendingEntry promise. 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 before set() 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.

set(cacheKey: string, pendingEntry: Promise<CacheEntry>): Promise<void>
ParameterTypeDescription
cacheKeystringThe unique key to store the entry under.
pendingEntryPromise<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.

const cacheHandler = {
  async set(cacheKey, pendingEntry) {
    // Wait for the entry to be ready
    const entry = await pendingEntry
 
    // Consume the stream, then keep the data rather than the stream
    const value = new Uint8Array(await new Response(entry.value).arrayBuffer())
 
    // Store in your cache system
    cache.set(cacheKey, { ...entry, value })
  },
}

get()

Retrieve a cache entry for the given cache key.

get(cacheKey: string, softTags: string[]): Promise<CacheEntry | undefined>
ParameterTypeDescription
cacheKeystringThe unique key for the cache entry.
softTagsstring[]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 revalidate but within expire, 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 negative revalidate always 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().

const cacheHandler = {
  async get(cacheKey, softTags) {
    const entry = cache.get(cacheKey)
    if (!entry) return undefined
 
    // No age check: Next.js compares `timestamp` against `expire` itself.
    // `entry.value` holds the stored bytes, so wrap them in a fresh stream
    return {
      ...entry,
      value: new ReadableStream({
        start(controller) {
          controller.enqueue(entry.value)
          controller.close()
        },
      }),
    }
  },
}

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.

refreshTags(): Promise<void>

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.

const cacheHandler = {
  async refreshTags() {
    // For in-memory cache, no action needed
    // For distributed cache, sync tag state from external service
  },
}

getExpiration()

Get the maximum revalidation timestamp for a set of tags.

getExpiration(tags: string[]): Promise<number>
ParameterTypeDescription
tagsstring[]Array of tags to check expiration for.

Returns:

  • 0 if none of the tags were ever revalidated
  • A timestamp (in milliseconds) representing the most recent revalidation
  • Infinity to indicate soft tags should be checked in the get method 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.

const cacheHandler = {
  async getExpiration(tags) {
    // Return 0 if not tracking tag revalidation
    return 0
 
    // Or return the most recent revalidation timestamp
    // return Math.max(...tags.map(tag => tagTimestamps.get(tag) || 0));
  },
}

updateTags()

Called when tags are revalidated or expired.

updateTags(tags: string[], durations?: { expire?: number }): Promise<void>
ParameterTypeDescription
tagsstring[]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.

const cacheHandler = {
  async updateTags(tags, durations) {
    // Invalidate all cache entries with matching tags
    for (const [key, entry] of cache.entries()) {
      if (entry.tags.some((tag) => tags.includes(tag))) {
        cache.delete(key)
      }
    }
  },
}

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:

  1. updateTags() is called when revalidateTag() is invoked. Your handler should write the invalidation timestamp to shared storage.
  2. 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.
  3. getExpiration() returns the most recent revalidation timestamp across all provided tags. The default implementation returns Math.max(...timestamps, 0).

Here's an example using Redis for distributed tag coordination:

cache-handlers/distributed-tags.js
const { createClient } = require('redis')
 
const client = createClient({ url: process.env.REDIS_URL })
client.connect()
 
// Local cache of tag timestamps, synced via refreshTags
const localTagTimestamps = new Map()
 
module.exports = {
  // ... get() and set() methods ...
 
  async refreshTags() {
    // Sync tag invalidation timestamps from Redis
    // Using a dedicated set to track tag keys avoids scanning the keyspace
    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()
  },
}

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 because set() 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 rejected set() 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 return undefined (the "cache miss" signal). The framework does not wrap get() in a try/catch, so an unhandled exception from get() 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.

cache-handlers/memory-handler.js
// Entries hold the serialized bytes, never the stream they arrived on
const cache = new Map()
const pendingSets = new Map()
 
function streamFromBytes(bytes) {
  return new ReadableStream({
    start(controller) {
      controller.enqueue(bytes)
      controller.close()
    },
  })
}
 
module.exports = {
  async get(cacheKey, softTags) {
    // Wait for any pending set operation to complete
    const pendingPromise = pendingSets.get(cacheKey)
    if (pendingPromise) {
      await pendingPromise
    }
 
    const entry = cache.get(cacheKey)
    if (!entry) {
      return undefined
    }
 
    // No age check: Next.js compares `timestamp` against `expire` itself.
    // This `Map` also has no eviction, which a real handler needs.
 
    // Every hit gets its own stream over the stored bytes
    return { ...entry, value: streamFromBytes(entry.value) }
  },
 
  async set(cacheKey, pendingEntry) {
    // Create a promise to track this set operation
    let resolvePending
    const pendingPromise = new Promise((resolve) => {
      resolvePending = resolve
    })
    pendingSets.set(cacheKey, pendingPromise)
 
    try {
      // Wait for the entry to be ready
      const entry = await pendingEntry
 
      // An in-memory store buffers the stream. Storing the stream itself, or
      // a branch of it, keeps the request that produced the entry alive.
      const value = new Uint8Array(
        await new Response(entry.value).arrayBuffer()
      )
 
      cache.set(cacheKey, { ...entry, value })
    } finally {
      resolvePending()
      pendingSets.delete(cacheKey)
    }
  },
 
  async refreshTags() {
    // No-op for in-memory cache
  },
 
  async getExpiration(tags) {
    // Return 0 to indicate no tags have been revalidated
    return 0
  },
 
  async updateTags(tags, durations) {
    // Implement tag-based invalidation
    for (const [key, entry] of cache.entries()) {
      if (entry.tags.some((tag) => tags.includes(tag))) {
        cache.delete(key)
      }
    }
  },
}

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:

cache-handlers/redis-handler.js
const { createClient } = require('redis')
 
const client = createClient({ url: process.env.REDIS_URL })
client.connect()
 
module.exports = {
  async get(cacheKey, softTags) {
    // Retrieve from Redis
    const stored = await client.get(cacheKey)
    if (!stored) return undefined
 
    // Deserialize the entry
    const data = JSON.parse(stored)
 
    // Reconstruct the ReadableStream from stored data
    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
 
    // Read the stream to get the data
    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()
    }
 
    // Combine chunks and serialize for Redis storage
    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 } // Use Redis TTL for automatic expiration
    )
  },
 
  async refreshTags() {
    // No-op for basic Redis implementation
    // Could sync with external tag service if needed
  },
 
  async getExpiration(tags) {
    // Return 0 to indicate no tags have been revalidated
    // Could query Redis for tag expiration timestamps if tracking them
    return 0
  },
 
  async updateTags(tags, durations) {
    // Implement tag-based invalidation if needed
    // Could iterate over keys with matching tags and delete them
  },
}

Platform Support

Deployment OptionSupported
Node.js serverYes
Docker containerYes
Static exportNo
AdaptersPlatform-specific

Version History

VersionChanges
v16.0.0cacheHandlers introduced.