COLUMN
30/09/2026
Next.js ISR Without Vercel: How On-Demand Revalidation Actually Works When You Self-Host

On Vercel, Incremental Static Regeneration is something you barely have to think about: a background revalidation runs, the cache updates, and every request sees the fresh page moments later. Move the same app to your own servers, and the parts that were automatic turn into infrastructure decisions you now have to make yourself.
The Cache Behind ISR Is Just a Cache
Next.js documentation is direct about this: caching and revalidating pages with ISR uses "the same Next.js server cache," and by default that cache lives on the local filesystem of each Next.js server instance. On a single next start process with a persistent disk, this works with zero configuration — which is exactly the environment most tutorials (and Vercel) assume.
The moment you run more than one instance, that assumption breaks. The Next.js self-hosting guide calls out the specific case worth reading twice if you're planning a Kubernetes deployment: "If you are hosting Next.js using a container orchestration platform like Kubernetes, each pod will have a copy of the cache." Two pods, two independent caches, no coordination between them by default.

Pointing the Cache Somewhere Shared
The fix Next.js ships is a cacheHandler you configure in next.config.js, paired with turning off the in-memory layer:
module.exports = {
cacheHandler: require.resolve('./cache-handler.js'),
cacheMaxMemorySize: 0, // disable default in-memory caching
}
cache-handler.js is a plain class your app instantiates, implementing get, set, and revalidateTag:
const cache = new Map()
module.exports = class CacheHandler {
constructor(options) {
this.options = options
}
async get(key) {
return cache.get(key)
}
async set(key, data, ctx) {
cache.set(key, {
value: data,
lastModified: Date.now(),
tags: ctx.tags,
})
}
async revalidateTag(tags) {
tags = [tags].flat()
for (let [key, value] of cache) {
if (value.tags.some((tag) => tags.includes(tag))) {
cache.delete(key)
}
}
}
resetRequestCache() {}
}
The example above stores everything in a Map, which is really just documentation for the shape of the interface — production use means swapping that Map for Redis, S3, or whatever durable store your team already runs, plus the eviction policy and error handling the docs explicitly say the example doesn't cover.
Why "On-Demand" Doesn't Automatically Mean "Everywhere"
This is the part that surprises teams moving off Vercel. revalidatePath() and revalidateTag() are the two functions you call to invalidate cached content on demand — and per the docs, revalidatePath is just "a convenience layer on top of cache tags," calling revalidateTag internally with a default tag for the page. So the real primitive is tags.
Here's the catch: by default, calling revalidateTag() on one instance only invalidates the cache on that instance. Every other pod keeps serving the stale version until it independently discovers the invalidation on its own. A webhook hits one pod behind your load balancer, that pod purges its tag, and the other four keep answering with old content — with no error, and nothing in your logs to say so.

Coordinating that across instances needs one more piece: a refreshTags() method on your custom cache handler, called before each request, which syncs tag invalidation state from shared storage so every pod learns about a purge promptly instead of waiting to rediscover it on its own. This is the mechanism that makes "on-demand revalidation" actually mean on demand, everywhere, rather than on demand, eventually, on whichever pod happens to get hit next.
A Newer Option, Same Underlying Problem
The custom cacheHandler above is the general-purpose fix, but it's not the only lever. Next.js's multi-server guidance also points to 'use cache: remote' as the directive to reach for when the default in-memory cache — which, again, is not shared across instances — isn't good enough for a given piece of data. It still needs a custom cache handler underneath to actually talk to external storage; the directive changes how a function or component opts in, not where the data ends up living.
Either path — a global cacheHandler or 'use cache: remote' on specific boundaries — gets you to the same place: cached data that lives outside any single pod's memory or disk, which is the actual prerequisite for correct behavior once you're past one replica.
Where This Actually Shows Up
None of this is visible in local development, because local development is a single process — one cache, one instance, no coordination problem to have. It shows up the first time you scale past one replica in staging, and it tends to show up as a support ticket ("the page says it's updated but I still see the old price") rather than as a stack trace.
If your team's current setup is a single Docker Compose file running one container, migrating from Docker Compose to Kubernetes is usually the point where this stops being theoretical — the day you go from one replica to three is the day tag coordination becomes something you have to design for, not something you can defer.
This is where Kubo fits into the picture: it's standard Kubernetes with the networking and storage plumbing already wired, so pointing a cacheHandler at a shared Redis instance behind your pods is a configuration choice you make once, rather than an infrastructure project you scope from scratch.
If You're Choosing What to Run This On
Cache coordination is one of several things that change once you're managing your own pods instead of relying on a single managed runtime. If you haven't settled on a distribution yet, choosing between K3s and a full Kubernetes distribution is worth reading before you commit — the caching setup above works the same either way, but the operational overhead around it doesn't.
It's also worth deciding early how many environments actually need this shared-cache setup. A single staging environment that mirrors production's replica count is usually enough to catch tag-coordination bugs before they reach users; running one per developer, on the other hand, multiplies the same Redis-and-cache-handler wiring for no real benefit.
A CDN in Front Changes the Picture Again
If you put a CDN or reverse proxy in front of your self-hosted Next.js server, there's a second layer of caching decisions on top of everything above. Next.js sends Cache-Control: public on fully static pages so a CDN can cache them, and Cache-Control: private the moment a dynamic API is used on that route, so the CDN knows not to. ISR pages carry an s-maxage value taken directly from the revalidation window you set in the route — but only if the CDN or reverse proxy in question actually respects that header and varies its cache key correctly. Skip that check and you end up debugging two independent caching systems instead of one, each with a plausible but wrong story for why a page looks stale.
The Short Version
Next.js's ISR cache is "just a cache" — which is good news, because it means the fix is ordinary infrastructure work, not a Next.js-specific mystery. Self-hosting it correctly means: a shared cacheHandler instead of the per-instance filesystem default, cacheMaxMemorySize: 0 so nothing silently falls back to memory, and a refreshTags() implementation so revalidateTag() reaches every pod, not just the one that received the webhook.
If you're still scoping what self-hosted Next.js costs beyond a single container — extra environments, a shared cache backend, the coordination logic on top — how many environments you actually need is a good next read for the environment side of that question, and it's the kind of cost that a managed Kubernetes setup with the shared-cache plumbing already in place tends to make disappear.