knext

Skew protection

Old clients keep fetching their own build's chunks across rollouts and rollbacks.

Version skew happens when more than one revision serves at once — during a rollout, or when a rollback canary splits traffic. A browser that loaded build A keeps requesting _next/static/A/... chunks even after the server rolls forward to build B. If A's assets are gone, that client breaks. With object storage configured, knext keeps old clients working by serving every build's assets from the durable object store and only reaping them under a build-id-aware retention rule. Without a storage block the deploy-pinned build id below still applies, but the asset-retention half does not — see Without object storage.

Stable, deploy-pinned BUILD_ID

knext ties Next.js's BUILD_ID to the deploy tag rather than a random id. At build time the CLI sets KNEXT_BUILD_ID to the deploy tag, and next.config reads it back as the build id:

next.config.ts
generateBuildId: () => process.env.KNEXT_BUILD_ID || process.env.NEXT_DEPLOYMENT_ID || null,

Scaffolded apps ship that line already. Returning null keeps a plain local build on its own generated id, so building outside knext deploy is unchanged. Use || rather than ?? — with ??, an environment that exports the variable as an empty string produces an empty build id rather than falling back.

Because the build id is the deploy tag, the uploaded asset prefix (_next/static/<id>/), the image tag, and the prefix the GC prunes by all share one value. A client on an old build notices the new build id on its next navigation and reloads onto the new build, and the deploy carries the same id into the running pod's environment.

Do not set deploymentId in next.config for a standalone (turbopack or webpack) build. From Next.js 16.2.11, any deployment id — including one Next picks up from a NEXT_DEPLOYMENT_ID environment variable — makes next build ignore generateBuildId and write a fixed build id that is the same for every deploy. knext deploy therefore leaves NEXT_DEPLOYMENT_ID unset for standalone builds, even when your shell or CI exports it, and says so in its output. The vinext target is not affected: it still receives NEXT_DEPLOYMENT_ID and uses it to append ?dpl=<id> to asset requests.

If the deploy tag's asset prefix is not there after the build, knext deploy aborts before anything is uploaded or applied. It never warns and continues. The two ways to see this are a next.config.ts with no generateBuildId, and --skip-build run against a build directory left over from an earlier tag — in that second case the assets would otherwise be uploaded under the old build's prefix, which the collector may then reap while the new revision is serving. The check runs only for deploys that upload assets: with no storage block, or with --skip-upload, there are no remote prefixes to keep in step and nothing to check.

Adding this to an app you already have

An app created before this existed either has no generateBuildId or reads only NEXT_DEPLOYMENT_ID in it. On a standalone build its next deploy stops with:

Update generateBuildId in next.config to read KNEXT_BUILD_ID

Change the one line (and remove any deploymentId line on a standalone build), and nothing else changes:

next.config.ts
const nextConfig: NextConfig = {
  generateBuildId: () => process.env.KNEXT_BUILD_ID || process.env.NEXT_DEPLOYMENT_ID || null,
  // ...the rest of your config
};

Local next dev and next build are unaffected — outside knext deploy the variable is unset and the fallback keeps Next's own id.

Uploads are additive

A deploy uploads the new build's assets but never prunes on upload — old builds' static prefixes persist. Pruning is a separate, bounded step, so a fresh deploy can never strand a client that is mid-session on the previous build.

Build-id-aware GC

After upload, knext reaps old static prefixes under two keep rules, OR'd together:

  1. Retain window — keep the newest N build-ids. N is storage.assetRetention (default 3).
  2. Live set — keep any build-id currently serving traffic, even if it is older than the window.

The live set is read read-only from NextApp.status.currentTraffic: knext parses each live revisionName, then resolves it to a build-id via a label the operator stamps onto the revision. So a pinned, canaried, or rolled-back build is never reaped while it serves — even if it has aged out of the retain window.

Deletes are scoped strictly to <app>/_next/static/<buildId>/; the bare <app>/ prefix is teardown-only and is never a prune target. Shared, content-hashed directories (chunks/, css/, media/) are never treated as build-id prefixes, so assets referenced across builds are never candidates for pruning.

The GC runs automatically after every deploy (best-effort — a GC failure never fails a deploy that has already shipped) and is also available as a standalone command, knext gc, for scheduled or manual runs — see the CLI reference.

The GC is fail-safe: if any live revision cannot be resolved to a build-id (label missing or the read failed), the whole GC is skipped for that deploy. The only failure mode is over-keeping assets — never over-deleting a build a client still needs.

Each upload also writes a small marker object into its own _next/static/<id>/ prefix, and the collector only ever considers prefixes that carry one. Anything else in that space — an upload from an older knext, a directory a future Next release introduces, a file someone put there by hand — is kept. That is the same direction as the rule above: a prefix whose origin cannot be established is kept forever rather than guessed at.

Tuning retention

knext.config.ts
storage: {
  provider: 'gcs',
  bucket: 'acme-assets',
  assetRetention: 5,   // keep the newest 5 build-ids (default 3)
},

The live set is added to whatever the window keeps, so raising assetRetention widens the skew window for normal rollouts; live revisions are protected regardless.

The browser-level proof — a real client on a canaried old build fetching its own chunks — runs as a nightly end-to-end test, not on every PR.

Without object storage

storage is optional. Without it, each revision serves its own build's assets from its container image, and this page's guarantees split in two:

  • Navigations stay protected. The deploy-pinned BUILD_ID (and, on vinext, ?dpl= client pinning) is independent of storage, so a client on an old build still detects the skew and reloads onto the new one.
  • In-flight fetches lose their safety net. Once an old revision is fully retired and scaled away, a chunk request that was already in flight from a client still on that build has no object store to fall back to and can fail. The durable store is exactly what covers that window, and it is the main reason to add a storage block as your traffic grows.
  • Rollback gets safer. A pinned revision's assets live in its own image, so no retention rule can ever reap them.

Every storage-less deploy announces this mode explicitly, and knext gc reports there is nothing to reap. See Starting without object storage for the full trade-off and the growth path.

On this page