knext

ISR & the Redis cache

How a knext app's ISR/data cache reaches Redis — where the wiring lives, what the runtime guarantees, and how to upgrade an app scaffolded before the cache wiring landed.

knext's ISR / data cache is Redis. Every pod shares one keyspace, so a page rendered (or a tag invalidated) by one pod is seen by all of them — which is what makes ISR coherent under scale-to-zero, where pods appear and disappear constantly.

Where the wiring lives

On the default build (build: 'turbopack' or 'webpack'), Next reads the cacheHandler option in next.config.ts. knext create scaffolds it, pointing at a cache-handler.js file in your project, together with cacheMaxMemorySize: 0 so the per-pod memory cache never diverges from Redis:

next.config.ts
const nextConfig: NextConfig = {
  cacheHandler: new URL('./cache-handler.js', import.meta.url).pathname,
  cacheMaxMemorySize: 0,
};

Keep the handler file inside your project so next build copies it into the standalone output.

On the vinext build (build: 'vinext'), the app builds with vinext (Vite/rolldown). vinext does not read the cacheHandler option in next.config.ts — that option belongs to Next's webpack/turbopack pipeline, which this build never runs. The line is kept in scaffolded apps for Next-aware tooling, but it is inert at runtime.

The live registration on vinext is in vite.config.ts, on the vinext() plugin itself:

vite.config.ts
import vinext from 'vinext';

export default defineConfig({
  plugins: [
    vinext({
      cache: {
        data: {
          // `-node` on `runtime: 'node'`, `-bun` on the default Bun runtime.
          adapter: '@getknext/core/internal/vinext-cache-adapter-bun',
        },
      },
    }),
    // …nitro(…)
  ],
});

The adapter is picked per runtime so the Redis client is resolved at build time: the -node adapter imports ioredis directly, so the build ships it in the image, and the -bun adapter uses Bun's built-in Redis client. If REDIS_URL is set but the client cannot be loaded, the app logs one Redis client unavailable error at startup and serves from memory — ISR is then not shared between pods and is lost on scale-to-zero. All three subpaths work with vinext 1.0.1 and 1.1.0. The generic …/vinext-cache-adapter subpath still works for apps scaffolded earlier, but it cannot be traced by the build, so switch it to the one for your runtime.

At build time, vinext generates a registration module from this option and runs it in every server entry, so the Redis-backed handler is registered before any route executes. Page-level ISR (HIT / STALE serving and in-process background regeneration) then flows through that handler.

Deleting this block does not fail the build. The app still serves 200s — with a per-pod in-memory cache instead: every pod renders independently, nothing survives scale-to-zero, and the provisioned Redis stays empty. If you see x-nextjs-cache: MISS on every request and an empty Redis, this block is the first thing to check.

What the runtime guarantees

  • First request after a cold cache is a MISS and writes the rendered page to Redis.
  • Within the revalidate window requests are served HIT from Redis — including the first request after the app scales up from zero, for pages generated at request time as well as prerendered ones. Each entry carries its own revalidate window, so a freshly started pod does not need to have rendered the page to know it is still fresh. That applies only to entries written by the same build; after a redeploy, entries from the previous build are revalidated as usual.
  • Past the window requests are served STALE from Redis while a background render replaces the entry — stale-while-revalidate, not a blocking re-render.
  • The Redis entry's TTL is deliberately longer than the revalidate window (it is the route's expire time when one is declared, and a floor of one hour otherwise). If the entry expired exactly at the revalidate window there would be nothing left to serve stale.
  • When REDIS_URL is unset (local dev), the handler falls back to in-memory caching, so vinext dev and local builds work without a Redis.
  • The Redis client depends on the runtime in your deploy config. On node the handler uses ioredis, which the build copies into the image. On bun it uses Bun's built-in Redis client. knext build, knext deploy and knext preview pick the right one for you.
  • When REDIS_URL is set but the Redis client cannot be loaded in the pod, the handler still serves from memory, but it logs one error at startup that starts with [CacheHandler] Redis client unavailable. If you see it, cache entries are not shared between pods and do not survive scale-to-zero. Check that you built the image with knext deploy or knext build for the same runtime you deploy.

On the standalone build's Node runtime, the Redis-backed handler can silently fall back to a per-pod in-memory cache. This affects runtime: 'node' only — the Bun runtime (the default) and the vinext compiled executable are not affected. The app still serves 200s, but pages render independently per pod, nothing survives scale-to-zero, and the provisioned Redis stays empty — the same symptom as deleting the cacheHandler block above. If you need a shared cache today, use the Bun runtime. This is fixed in the next patch release; check the release notes for your version.

The connection details come from the environment — REDIS_URL and REDIS_KEY_PREFIX are injected into your pods from the cache block of your deploy config (see the operator reference); nothing about Redis is hardcoded in your app.

'use cache' is per-process today

The Redis cache above covers ISR and the response cache. It does not back Next's 'use cache' directive (Cache Components). Today, on knext:

  • A server-side 'use cache' entry is per-process: each pod keeps its own copy in memory. It is not shared across pods and is lost on scale-to-zero, so a fresh pod recomputes it.
  • The scaffolded next.config.ts sets cacheMaxMemorySize: 0 and registers no cacheHandlers. With that, 'use cache' keeps nothing at all, so the directive is effectively a no-op. Your code still runs and returns correct results; it just is not cached. Keep that setting: raising it lets each pod's in-memory cache drift from the shared Redis cache.
  • 'use cache: remote' has no shared store behind it either, so it behaves the same way.
  • Tag invalidation with revalidateTag still reaches ISR pages, but it cannot invalidate a 'use cache' entry held by another pod.

Plan around that: treat 'use cache' as a per-pod speed-up, not as a shared cache, and keep anything that must be shared (or must survive scale-to-zero) in ISR or your own data store.

knext doctor warns when your source uses 'use cache'. It names the first file it finds and tells you which case applies: a no-op when your next.config sets cacheMaxMemorySize: 0, or per-process otherwise. Run it from your app directory, the one with next.config. It skips dependencies, build output, the public folder and symlinks. In a directory with a very large number of files the scan stops early and says so instead of guessing. The warning never fails the check.

A 'use cache' store shared across pods is planned for a future major version. Until it ships, the behaviour above is what you get.

The Cache-Control header clients see

Next.js marks cacheable pages with directives meant for a shared cache in front of the server, such as s-maxage=2, stale-while-revalidate=31535998 for an ISR page or s-maxage=31536000 for a static one. knext serves your app directly, with no shared cache in between, so every build rewrites those responses to public, max-age=0, must-revalidate: the standalone server (build: 'turbopack' or 'webpack', on Node or Bun — the default), the vinext compiled executable, and vinext on Node (build: 'vinext' with runtime: 'node'). That's the header a deployed Next.js app returns to browsers. The first request for a fallback: true page gets the same header rather than private, no-store. Hashed static assets (immutable) and non-GET/HEAD responses are never changed.

With the vinext build (the compiled executable, or vinext on Node), two layers produce this header:

  • vinext's own deploy mode covers the responses vinext builds itself: ISR and static pages, /_next/data requests, and the first request for a fallback: true page. The server turns it on by setting VINEXT_NEXT_DEPLOY_CACHE_CONTROL=1, unless you've set that variable yourself.
  • knext's rewrite covers s-maxage headers your own code sets, for example in a route handler or on a redirect.

If you put your own CDN or shared cache in front of knext and want it to see the s-maxage values, set KNEXT_CACHE_CONTROL_NORMALIZE=0 in the app's environment. That one switch turns both layers off, and the server sends Next.js's original headers. To turn off only vinext's layer, set VINEXT_NEXT_DEPLOY_CACHE_CONTROL=0.

On vinext on Node, both layers live in the app's knext-node-entry.mjs. An app created before this behaviour shipped has an older copy of that file and still serves Next.js's original headers; replace it with the one from a freshly created app (knext create).

Upgrading a vinext app scaffolded before the cache wiring

Vinext apps created by earlier versions of knext create wired the cache handler only through next.config.ts's cacheHandler — which the vinext build ignores, so their ISR never reached Redis. To fix an existing app, add the cache block to the vinext() call in your vite.config.ts exactly as shown above, and make sure @getknext/core is in your devDependencies. No other change is needed: the handler, the environment contract, and the operator side were already in place.

To verify after deploying: request an ISR route twice — the second response should carry x-nextjs-cache: HIT — and redis-cli KEYS '<your-prefix>:cache:*' should show the page's entries, with TTL greater than the route's revalidate window.

On this page