Skip to the solution
HardFrontend12 min read

How do you self-host Next.js across multiple instances (cache handlers, ISR, build IDs)?

66 views

The problem

Question presented to candidate: "We run Next.js on Kubernetes with four replicas behind a load balancer. Editors say a published article sometimes shows the old version, depending on the refresh, and after deploys some users get 'Failed to find Server Action'. What is going on, and how should multi-instance self-hosting be set up?"

What a strong answer should cover:

  • By default the ISR and data cache lives on each instance's disk and memory, so revalidatePath or revalidateTag on one replica leaves the others stale.
  • A custom cacheHandler (and cacheHandlers for 'use cache') backed by a shared store, with cacheMaxMemorySize: 0, plus tag invalidation shared across instances.
  • Build once and run the same artifact everywhere: separate builds get different build IDs, Server Action IDs and encryption keys.
  • deploymentId for version skew during rolling deploys (asset ?dpl=, deployment headers, hard reload on mismatch); generateBuildId when you must rebuild per environment; NEXT_SERVER_ACTIONS_ENCRYPTION_KEY pinned.
  • Operational pieces: a reverse proxy with buffering disabled for streaming, graceful shutdown, and a CDN that respects Cache-Control.

Clarifying questions expected:

  • "Does each pod run next build, or is one image deployed?" β€” it decides whether IDs and keys can match.
  • "Is there a shared store available, like Redis?" β€” it is the basis for a shared cache.

Code / implementation expected: Yes β€” a next.config with cacheHandler and deploymentId, and a sketch of the shared handler.

  • nextjs
  • self-hosting
  • caching
  • deployment

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

12 min read Β· 8 sections

Read the code

next.config.ts for a multi-instance deployment
// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "standalone", // self-contained server for the container image

  // ISR pages, Route Handler responses and fetch cache entries go to a shared store,
  // not to each instance's disk and memory.
  cacheHandler: require.resolve("./cache-handler.mjs"),
  cacheMaxMemorySize: 0, // no per-instance in-memory copy that could go stale

  // Version skew protection: ?dpl= on assets, x-deployment-id on navigations,
  // hard reload when a client meets a different deployment.
  deploymentId: process.env.DEPLOYMENT_VERSION,
};

export default nextConfig;
cache-handler.mjs: a shared-store cache handler (lab stand-in for Redis)
// cache-handler.mjs (referenced from next.config.ts)
// Lab stand-in for Redis: every instance reads and writes the same directory.
import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
import { createHash } from "node:crypto";
import { join } from "node:path";
import { serialize, deserialize } from "node:v8"; // keeps Buffer and Map values intact

const DIR = process.env.SHARED_CACHE_DIR ?? "/tmp/next-shared-cache";
mkdirSync(DIR, { recursive: true });
const file = (key) => join(DIR, createHash("sha256").update(key).digest("hex"));
const TAGS = join(DIR, "tags.json");
const readTags = () => (existsSync(TAGS) ? JSON.parse(readFileSync(TAGS, "utf8")) : {});

export default class SharedCacheHandler {
  async get(key) {
    const f = file(key);
    if (!existsSync(f)) return null;
    const entry = deserialize(readFileSync(f));
    const tags = [...(entry.tags ?? []), ...String(entry.value?.headers?.["x-next-cache-tags"] ?? "").split(",").filter(Boolean)];
    const revalidated = readTags();
    if (tags.some((t) => (revalidated[t] ?? 0) > entry.lastModified)) return null; // invalidated elsewhere
    return entry;
  }
  async set(key, data, ctx) {
    console.log(`[cache ${process.env.INSTANCE}] set ${key}`);
    writeFileSync(file(key), serialize({ value: data, lastModified: Date.now(), tags: ctx?.tags ?? [] }));
  }
  async revalidateTag(tags) {
    const all = readTags();
    for (const t of [tags].flat()) all[t] = Date.now();
    writeFileSync(TAGS, JSON.stringify(all));
    console.log(`[cache ${process.env.INSTANCE}] revalidateTag ${JSON.stringify(tags)}`);
  }
  resetRequestCache() {}
}
Build once, run many: the deployment pipeline
# 1. Build ONCE in CI. Pin the Server Functions key so a rebuild of the same
#    release keeps decrypting closures created by the previous one.
export NEXT_SERVER_ACTIONS_ENCRYPTION_KEY="$(vault read -field=key secret/next-actions)"  # openssl rand -base64 32, stored once
export DEPLOYMENT_VERSION="$(git rev-parse --short HEAD)"
next build
docker build -t registry.example.com/shop:"$DEPLOYMENT_VERSION" .

# 2. Run the SAME image on every instance (never `next build` per container).
#    All instances share the cache store and the deployment id.
docker run -e INSTANCE=a -e REDIS_URL=redis://cache:6379 -p 3000:3000 registry.example.com/shop:"$DEPLOYMENT_VERSION"
docker run -e INSTANCE=b -e REDIS_URL=redis://cache:6379 -p 3001:3000 registry.example.com/shop:"$DEPLOYMENT_VERSION"

# 3. Behind nginx: disable buffering for streaming, and drain on shutdown
#    (send SIGTERM and wait 10-30 s so in-flight requests and after() callbacks finish).

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 128 of 129 in Next.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All Next.js questions
Up nextHard

What does `use cache` unlock in Next.js 15?

Continue