
Self-hosted Next.js 16 keeps use cache in memory, so every container has its own copy and revalidateTag only clears one of them. Wire up a shared Redis handler that fixes both.
In Next.js Zero Downtime Deployment with Docker Rollout we got deploys down to zero downtime by running two containers side by side and only killing the old one once the new one is healthy. That post does exactly what it says.
It also quietly creates a new problem, and it took me a while to connect the two.
For a few seconds during every rollout — and permanently, if you ever scale to two replicas — you have two Next.js processes each holding their own private cache. Hit refresh twice and you can get two different pages. Then I called revalidateTag('posts') after publishing an article, saw the new content, and got the old one back on the next reload. Nothing was broken. The request had simply landed on the other container, which had never heard about that revalidation.
This article fixes that with Redis. But before the code, there is one piece of configuration that almost everyone gets wrong, including me for an entire evening.
Next.js 16's Cache Components flipped the default. Nothing is cached unless you say so with use cache, which is a much better model. What the announcement posts skip over is where that cache lives when you self-host.
It lives in memory, in that one Node process. Which gives you three separate problems:
app:3000 across every running replica. Each one renders and caches independently, so the same URL can return different HTML depending on which container answered.revalidateTag() only reaches one process. The container that handled your webhook or Server Action clears its own cache. The other one keeps happily serving stale content until its own cacheLife runs out. This is the one that made me think I had a caching bug in my CMS.Redis fixes all three. Let's set it up.
Next.js 16 has two cache handler config keys, they are spelled almost identically, and they do completely different jobs:
| Config key | Used by | What it covers |
|---|---|---|
cacheHandler (singular) |
The Next.js server cache | ISR pages, route handler responses, optimized images |
cacheHandlers (plural) |
The use cache directives |
'use cache' and 'use cache: remote' |
If you search for "Next.js Redis cache handler" you will mostly find cacheHandler — the singular one. That's the ISR-era API, it still exists, and it still works for what it covers. But it is not used by use cache at all. Point it at Redis, restart, watch your use cache functions keep caching in memory, and spend the evening wondering why nothing changed. Ask me how I know.
For Cache Components you want cacheHandlers, plural. It was introduced in 16.0 and it takes an object:
default — backs the plain 'use cache' directiveremote — backs 'use cache: remote''use cache: <name>'
'use cache: private'is per-browser and deliberately not configurable. No handler you write will ever see it, which is exactly what you want for user-specific data.
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
output: 'standalone',
cacheComponents: true,
}
export default nextConfigWithout cacheComponents: true, 'use cache: remote' is not available and your handlers are ignored.
Redis stays on the internal Docker network. It gets no host port — nothing outside the compose project has any business talking to it.
services:
app:
image: next-app:latest
restart: unless-stopped
env_file: .env
environment:
REDIS_URL: redis://cache:6379
expose:
- "3000"
depends_on:
cache:
condition: service_healthy
cache:
image: redis:7-alpine
restart: unless-stopped
# No `ports:` — Redis has no password here, so it must stay off the host.
# 256mb with LRU eviction: when it fills, Redis drops the coldest entries
# instead of returning errors on write.
command: >
redis-server
--maxmemory 256mb
--maxmemory-policy allkeys-lru
--save ""
--appendonly no
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
proxy:
image: nginx:alpine
restart: unless-stopped
ports:
- "3005:80"
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- appTwo deliberate choices there. --save "" --appendonly no turns off persistence — this is a cache, not a database, and disk snapshots of throwaway data are just IO you're paying for. And allkeys-lru matters more than it looks: the default policy is noeviction, which starts rejecting writes once Redis is full. Your handler would then throw on every set forever.
Install the client:
bun add redisThis is the whole thing. Create cache-handlers/redis-handler.js in your project root.
The interface has five methods, and the two you'll be tempted to skip — refreshTags and getExpiration — are the ones that make revalidateTag() work across containers.
const { createClient } = require('redis')
const PREFIX = 'nxc:' // cache entries
const TAG_PREFIX = 'nxt:' // tag -> last revalidation timestamp
const TAG_SET = 'nxt:all' // set of every tag we've ever revalidated
// Tag timestamps are read on every request, so we keep a local copy and
// refresh it in refreshTags() instead of hitting Redis per lookup.
const localTags = new Map()
let client
let connecting
// Lazy connect. Doing this at module load means a Redis blip during boot
// takes the whole app down with it.
async function getClient() {
if (client?.isReady) return client
if (!connecting) {
client = createClient({
url: process.env.REDIS_URL,
socket: { reconnectStrategy: (n) => Math.min(n * 100, 3000) },
})
// Without an error listener, node-redis throws on the process and kills it.
client.on('error', (err) => console.error('[cache] redis:', err.message))
connecting = client.connect().finally(() => { connecting = null })
}
await connecting
return client
}
module.exports = {
async get(cacheKey, softTags) {
try {
const redis = await getClient()
const stored = await redis.get(PREFIX + cacheKey)
if (!stored) return undefined
const entry = JSON.parse(stored)
// Past its revalidate window — let Next re-render it.
if (Date.now() > entry.timestamp + entry.revalidate * 1000) {
return undefined
}
// Was any of this entry's tags invalidated after it was written?
// softTags are the implicit route tags that make revalidatePath work.
const allTags = [...entry.tags, ...softTags]
for (const tag of allTags) {
if ((localTags.get(tag) || 0) > entry.timestamp) return undefined
}
return {
// Next expects a stream back, not a buffer.
value: new ReadableStream({
start(controller) {
controller.enqueue(Buffer.from(entry.value, 'base64'))
controller.close()
},
}),
tags: entry.tags,
stale: entry.stale,
timestamp: entry.timestamp,
expire: entry.expire,
revalidate: entry.revalidate,
}
} catch (err) {
// Next does NOT wrap get() in a try/catch. Throwing here becomes a
// render error and your page 500s because Redis hiccuped. Never throw.
console.error('[cache] get failed:', err.message)
return undefined
}
},
async set(cacheKey, pendingEntry) {
try {
// The entry may still be rendering when set() is called.
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(Buffer.from(value))
}
} finally {
reader.releaseLock()
}
const redis = await getClient()
await redis.set(
PREFIX + cacheKey,
JSON.stringify({
value: Buffer.concat(chunks).toString('base64'),
tags: entry.tags,
stale: entry.stale,
timestamp: entry.timestamp,
expire: entry.expire,
revalidate: entry.revalidate,
}),
// Let Redis expire the key itself so dead entries don't accumulate.
{ EX: Math.max(1, Math.ceil(entry.expire)) }
)
} catch (err) {
// A failed set is survivable — the response is already streaming to the
// user. We just lose the cache entry and re-render next time.
console.error('[cache] set failed:', err.message)
}
},
// Called before each request. This is how a container finds out that some
// *other* container ran revalidateTag().
async refreshTags() {
try {
const redis = await getClient()
const tags = await redis.sMembers(TAG_SET)
if (!tags.length) return
const values = await redis.mGet(tags.map((t) => TAG_PREFIX + t))
tags.forEach((tag, i) => localTags.set(tag, Number(values[i]) || 0))
} catch (err) {
// Stale tag state is better than a failed request.
console.error('[cache] refreshTags failed:', err.message)
}
},
async getExpiration(tags) {
return Math.max(0, ...tags.map((tag) => localTags.get(tag) || 0))
},
// Called when revalidateTag() / revalidatePath() runs on this instance.
async updateTags(tags) {
const now = Date.now()
try {
const redis = await getClient()
const tx = redis.multi()
for (const tag of tags) {
tx.set(TAG_PREFIX + tag, String(now))
tx.sAdd(TAG_SET, tag)
localTags.set(tag, now) // apply locally right away
}
await tx.exec()
} catch (err) {
console.error('[cache] updateTags failed:', err.message)
}
},
}The part worth reading twice is refreshTags + updateTags. updateTags writes "tag posts was invalidated at time T" into Redis. refreshTags runs before each request on every container and pulls those timestamps down. Then get compares them against the entry's own timestamp and throws out anything older. That three-step handshake is the entire fix for the stale-content-on-refresh problem.
Storing entries as base64 JSON is fine for normal pages. If you cache genuinely large payloads, that's memory in Redis and CPU on both ends — stream to something like S3 instead and keep only a pointer in Redis.
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
output: 'standalone',
cacheComponents: true,
cacheHandlers: {
// 'use cache' -> Redis
default: require.resolve('./cache-handlers/redis-handler.js'),
// 'use cache: remote' -> same Redis
remote: require.resolve('./cache-handlers/redis-handler.js'),
},
}
export default nextConfigPointing both at Redis is the simplest setup and what I'd start with. If you'd rather keep hot, cheap data in process memory and only push expensive things over the network, leave default off entirely — it falls back to the built-in in-memory LRU — and set only remote. Then you choose per function:
import { cacheLife, cacheTag } from 'next/cache'
// Cheap and hot — in-memory is fine, no network hop.
export async function getNavigation() {
'use cache'
cacheLife({ expire: 3600 })
return db.query.navigation.findMany()
}
// Expensive, shared across every visitor, must be consistent
// across containers — this one belongs in Redis.
export async function getPublishedPosts() {
'use cache: remote'
cacheTag('posts')
cacheLife({ expire: 300 })
return db.query.posts.findMany({ where: eq(posts.published, true) })
}That cacheTag('posts') is what makes revalidateTag('posts') work from a Server Action or webhook.
Skip this and you get bugs that look nothing like caching bugs.
During a rollout, two containers built from different images serve traffic at once. If each one generated its own build ID and its own Server Action encryption key, they disagree about cache keys and about how to decrypt Server Action payloads. The symptom is intermittent Failed to find Server Action errors that vanish once the rollout finishes — which makes them very easy to dismiss as a fluke.
const nextConfig: NextConfig = {
// ...
generateBuildId: async () => process.env.GIT_HASH ?? 'dev',
deploymentId: process.env.GIT_HASH,
}Generate the encryption key once and keep it in .env forever:
openssl rand -base64 32NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=your-generated-keyThen pass the commit hash in at build time in deploy.sh:
export GIT_HASH=$(git rev-parse --short HEAD)
DOCKER_BUILDKIT=1 docker build \
--secret id=env,src=.env \
--build-arg GIT_HASH="$GIT_HASH" \
-t "$IMAGE" .And accept it in the Dockerfile so it's present during next build:
ARG GIT_HASH
ENV GIT_HASH=$GIT_HASHDon't trust this until you've watched it. Scale to two containers on purpose:
docker compose up -d --scale app=2Watch keys appear as you browse:
docker compose exec cache redis-cli --scan --pattern 'nxc:*' | head
docker compose exec cache redis-cli dbsizeThe real test is cross-container invalidation. Hit a cached page a few times so both containers have served it, trigger your revalidateTag('posts'), then confirm the tag landed in Redis:
docker compose exec cache redis-cli smembers nxt:all
docker compose exec cache redis-cli get nxt:postsNow reload the page ten times. Before this setup, roughly half those reloads would show stale content. Now every one of them should be fresh, because both containers picked up that timestamp in refreshTags().
Finally, prove the cache survives a restart:
docker compose restart app
docker compose exec cache redis-cli dbsize # should be unchangedThis one surprised me, and it is worth understanding before you file a bug.
Cache entries do not persist across deploys, and that's deliberate. The cache key includes your deploymentId (or the build ID), so a new build produces entirely new keys and the previous build's entries become unreachable. They sit in Redis until their TTL expires.
It sounds wasteful until you think about what the alternative would be. Between two builds you might have upgraded a dependency, refactored a cached function, or changed the shape of what it returns. Reusing the old entry would mean feeding last week's data structure to this week's component. A cold cache is a much better outcome than a subtly malformed page.
So the honest scorecard for this setup:
| Scenario | Shared cache? |
|---|---|
| Two containers during a rollout | Yes |
| Scaling to multiple replicas | Yes |
revalidateTag() across containers |
Yes |
| Container restart / crash | Yes |
| A new deploy | No — by design |
If you specifically need something to outlive deploys, that's what unstable_cache or the plain fetch cache are still for.
cacheHandler is not cacheHandlers. The singular key handles ISR, route handlers and images. It does nothing for use cache. Nearly every Redis-with-Next.js guide online predates Cache Components and shows you the singular one.get() throw. Next doesn't wrap it in a try/catch, so an unhandled Redis error becomes a render error and your page 500s. Catch everything and return undefined — a cache miss is always recoverable.entry.value is a ReadableStream, and it's single-use. Read it fully in set() before storing. If you need to both store and return it, .tee() it.noeviction. Once it's full it rejects writes instead of dropping cold keys, and every set starts failing silently. Set allkeys-lru explicitly.error on the process, and an unhandled one takes the container down. That's a health check failure and a rolled-back deploy over a two-second network blip.bun server.js starts throwing Cannot find module 'redis', file tracing missed it. Confirm with ls .next/standalone/cache-handlers/, and if the dependency is absent add outputFileTracingIncludes for it.refreshTags looks like it works. Caching runs fine, keys show up in Redis, everything seems healthy — until a cross-container revalidateTag() silently does nothing. Implement it from the start.use cache is a genuinely better model than the implicit caching it replaced, but on a self-hosted box the default in-memory store quietly assumes you only ever run one process. The moment you add zero-downtime deploys — which means running two containers on purpose — that assumption stops holding.
Three things fix it: the plural cacheHandlers key so you're configuring the handler use cache actually reads, a Redis handler that never throws out of get(), and refreshTags plus updateTags so an invalidation on one container is visible to all of them.
Get those in place and revalidateTag() starts meaning what you always assumed it meant. Just don't expect the cache to survive a deploy — it isn't supposed to, and the first cold render after ./deploy.sh is the cheapest bug you'll never have to debug.