cacheHandlers

Back 'use cache' with your own storage so entries survive restarts and are shared across instances.

Signature
cacheHandlers?: { default?: string; remote?: string; [name: string]: string | undefined }
  • Added in Next.js v16.0.0

Parameters

ParameterTypeDescription
$defaultoptionalstring= in-memory LRUPath to the module implementing the handler used by the 'use cache' directive. Falls back to an in-memory LRU cache.
$remoteoptionalstring= in-memory LRUPath to the module implementing the handler used by the 'use cache: remote' directive. Falls back to an in-memory LRU cache.
$[name]optionalstring= undefinedAny 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, getExpiration and updateTags. Tag invalidation across instances only works if updateTags writes to shared storage and refreshTags reads it back.
  • CacheEntry.value is a ReadableStream. 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

Example · typescript
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 nextConfig

When 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.

Example · javascript
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.

Example · javascript
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.

Example · typescript
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

  • Be the first to comment on this lesson.