knext

How your app is built

knext's default build compiles your Next.js app into a single self-contained executable through vinext — vite build, a nitro Bun bundle, then a bytecode-precompiled Bun binary.

When you run kn-next build, knext turns your Next.js app into one self-contained executable rather than a folder of JavaScript to be interpreted at boot. That binary — the server, every route it reaches, and the Bun runtime, compiled together — is what your image runs directly. This page explains the build target, the pipeline that produces it, and what your app has to satisfy to build on it.

For the performance numbers and a plain description of the artifact, see the compiled executable. For how this relates to the official Next.js adapter, see the adapter and the compiled build.

The build target

knext offers three build targets, and you choose between them with the build field.

The default target compiles your app with vinext, an open-source, Vite-based implementation of the Next.js framework, into a single executable. You do not have to configure anything to get it:

kn-next.config.ts
const config: KnativeNextConfig = {
  name: 'acme',
  registry: 'registry.example.com/acme',
  // build: 'vinext' is the default — you can state it explicitly, but you don't need to
};

The second target is next build's standalone output, run by a small supervisor that spawns Next's own server.js and drains it gracefully on shutdown. The official Next.js compatibility suite number was earned on this target's standalone output; the supervisor knext wraps it in has not itself been run through the suite. Select it with build: 'turbopack', and pick which runtime executes it with runtime:

kn-next.config.ts
const config: KnativeNextConfig = {
  name: 'acme',
  registry: 'registry.example.com/acme',
  build: 'turbopack',   // next build -> .next/standalone, run by a supervisor
  runtime: 'bun',       // or 'node' (the default runtime for this target)
};

The third target is the same standalone output, produced with webpack instead of Turbopack as the bundler. Select it with build: 'webpack'; everything else about it — the entry, the supervisor, the runtime choice, the bytecode caching — is identical to the turbopack target, because both are next build with a different bundler flag, and the standalone output they produce is the same shape:

kn-next.config.ts
const config: KnativeNextConfig = {
  name: 'acme',
  registry: 'registry.example.com/acme',
  build: 'webpack',     // next build --webpack -> .next/standalone, run by a supervisor
  runtime: 'bun',       // or 'node' (the default runtime for this target)
};

Both the turbopack and webpack targets have a prerequisite in your project, not just in the config: kn-next build runs your app's own build script, so that script must run next build (with --webpack for the webpack target) and output: 'standalone' set in next.config.ts — otherwise there is no standalone server for the image to copy. Apps created by kn-next create are vinext apps whose build script runs vite build and whose next.config.ts deliberately omits output, so switching such an app to either of these targets means changing both first.

If you select build: 'turbopack' or build: 'webpack' and your build script still doesn't produce .next/standalone (for example, the build script wasn't updated), kn-next build fails immediately with an actionable error telling you to set output: 'standalone' in next.config.ts and make sure your build script runs next build. This is a hard failure on purpose: without it, the first sign of the missing artifact used to be an opaque error deep inside the Docker image build at deploy time.

kn-next stages and builds the matching standalone runtime image for you. On the vinext target, runtime: 'node' switches from the Bun executable to a Node server — see vinext on Node below. On the standalone targets (turbopack and webpack, identically) it decides how the server is packaged:

  • runtime: 'bun'kn-next build compiles the standalone server.js into a Bun single executable with bytecode, verifies the bytecode is actually there (the build fails otherwise), and the image runs that executable inside the standalone folder. Needs Bun 1.4 or newer where you build. Details on the Bun runtime page.
  • runtime: 'node' — the supervisor spawns server.js under Node, and bytecode caching comes from the V8 compile cache in the image (see bytecode caching).

vinext on Node

The vinext target also runs on Node. Set runtime: 'node' and keep build at its default:

kn-next.config.ts
const config: KnativeNextConfig = {
  name: 'acme',
  registry: 'registry.example.com/acme',
  build: 'vinext',      // the default
  runtime: 'node',      // a Node server instead of the Bun executable
};

Nothing is compiled into an executable on this target. Instead:

  • The build emits a Node server. vite build produces nitro's Node preset rather than the Bun one, with knext-node-entry.mjs as the server entry. That entry provides what the Bun entry does: the health route, the :9464 metrics endpoint, and draining in-flight requests on SIGTERM. vite.config.ts picks the preset from the runtime in kn-next.config.ts, so kn-next build, kn-next deploy and a plain npm run build all agree.
  • Bytecode caching is the V8 compile cache, baked into the image. The image (Dockerfile.vinext-node, next to your Dockerfile) boots the server once during docker build and warms your configured health check path (healthCheckPath in kn-next.config.ts, default /api/health) before shipping the compiled code as a layer. kn-next deploy and kn-next preview — the two commands that actually run docker build — thread a custom healthCheckPath through automatically as a --build-arg, so a custom health route works with nothing extra to configure through either of them. kn-next build does not: it only stages Dockerfile.vinext-node into your app (and never runs docker itself), so if you build the staged Dockerfile by hand, pass --build-arg KNEXT_HEALTH_CHECK_PATH=<your path> yourself. Every pod starts with the cache, including the first one after scaling to zero. The image build fails if the warm-up request does not succeed or the cache comes out too small, rather than shipping an empty cache — see diagnosing a failed bake. To see the cache being used, start a container with NODE_DEBUG_NATIVE=COMPILE_CACHE: the log says the cache for /app/.output/server/index.mjs was accepted.

    A Dockerfile.vinext-node staged before this fix (no ARG KNEXT_HEALTH_CHECK_PATH) needs re-staging if your app uses a custom healthCheckPath: delete the file and re-run kn-next build, which never overwrites an existing one.
  • Bun is not needed to build it. Only the Bun executable target requires Bun 1.4 on the build machine.
  • kn-next build checks the preset. If the build produced the Bun preset (see below), it stops before uploading anything and tells you what to change. Running a Bun-preset server under Node would crash on startup.

Apps created before this target existed need two changes. kn-next build adds Dockerfile.vinext-node and its ignore file to your app if they are missing, and it never overwrites your own copies. It does not edit your vite.config.ts, which may still hardcode preset: 'bun'. Make the nitro plugin follow the runtime:

vite.config.ts
import knext from './kn-next.config';

const onNode = knext.runtime === 'node';

// inside plugins: [...]
nitro({
  preset: onNode ? 'node' : 'bun',
  entry: onNode ? './knext-node-entry.mjs' : './knext-bun-entry.mjs',
  rollupConfig: { output: { inlineDynamicImports: true } },
}),

Then copy knext-node-entry.mjs from a freshly created app into yours, and declare the server package it imports in your package.json: "srvx": "0.11.22". The entry drains in-flight requests and waits for after() work before exiting on SIGTERM.

Two differences from the Bun executable, stated plainly:

  • Image optimization serves originals unless sharp can load. The Node image does not ship sharp's native addon. /_next/image then serves the source image unoptimized and logs one warning. It never fails a request.
  • The request body cap is enforced as the body is read, not before your handler runs. The limit is the same KNEXT_MAX_REQUEST_BYTES.

The pipeline

kn-next build produces the executable in two stages:

  1. vite build — vinext compiles your app and emits a nitro server bundle targeting the Bun preset (a .output directory whose entry is an ES module).
  2. bun build --compile --minify --bytecode — that bundle is minified, precompiled to bytecode ahead of time, and baked together with the Bun runtime into one binary, cross-built for the Linux target your image runs (musl by default, matching the shipped base image).

Compiling the whole bundle at once is what lets the executable win both cold start and throughput at the same time: minification and bundling remove the per-module boundary cost that made earlier file-by-file bytecode approaches trade one for the other. The engine never parses your server source at boot — that work already happened at build time.

The native image-optimization codec cannot live inside the binary, so knext ships it beside the executable in the image and loads it by path; /_next/image serves optimized AVIF/WebP exactly as it does on a folder deployment. See image optimization.

Some server dependencies stay outside the nitro bundle and are loaded when the server runs. For example, vinext keeps every @opentelemetry/* package your app depends on external. On a folder deployment those load from .output/server/node_modules, which does not exist next to the executable, so kn-next build bakes them into the binary. If one of them cannot be resolved at build time, the build still succeeds but prints a warning:

[knext compile] WARNING: the entry runtime-requires package(s) that do not resolve from … and
cannot be bundled: <package> — the binary throws if that code path runs

Install the named package as a dependency of your app and rebuild. Otherwise, any request that reaches code using that package fails with Cannot find module '<package>'.

Requirements

Your app must be an ES module

vinext builds through Vite, whose server/client (RSC ↔ SSR) module graph requires your app to be an ES module. Set it in package.json:

package.json
{
  "type": "module"
}

kn-next create sets this for you, so a freshly scaffolded app already satisfies it. It matters only when you point the compiled build at an existing app that was written as CommonJS. In that case:

  • add "type": "module" to package.json;
  • name your Next.js config next.config.mjs or next.config.cjs (a bare next.config.js is interpreted as ESM once "type": "module" is set);
  • if the project still has CommonJS files that rely on require() or on a .js extension meaning CommonJS, rename them to .cjs (or convert them to import/export).

Without "type": "module", the build fails while wiring up React Server Components — the server/client module graph cannot be resolved — rather than producing a broken binary. A CommonJS app is not supported on this target.

Building requires Bun 1.4 or newer

The compile step shells out to bun build --compile, so the machine doing the build needs Bun 1.4.0 or newer on its PATH. (The kn-next CLI itself runs under plain Node; only this one step needs Bun.) The floor is enforced, not advisory: an older Bun cannot serve the compiled tree correctly, so the build refuses to run under it rather than quietly producing a binary that boots roughly twice as slowly. Upgrade with bun upgrade. If Bun is missing entirely the build stops and tells you how to install it.

CI and container builds get Bun the same way; the requirement is only on whoever runs the build, not on anyone deploying an already-built image.

Compatibility, stated honestly

knext's official-suite credential — 778/778, re-verified nightly — was earned on the standalone target (the build: 'turbopack' shape above), which is a different artifact from the compiled vinext executable. If suite-verified compatibility is what you need, that target is selectable today — with one caveat stated plainly: the suite runs against the standalone server directly, so the credential covers that output rather than the supervisor knext boots it with. The suite lane for the compiled vinext executable is being stood up; until it publishes a green run, treat the compiled build's compatibility as measured per feature rather than suite-verified, and read the per-feature status on the compatibility matrix rather than assuming the standalone number carries over.

The remaining gaps are largely a function of how far the underlying vinext framework has matured on areas such as streaming/Suspense flushing, Partial Prerendering, Cache Components ('use cache'), edge middleware, and internationalized routing. The matrix marks each honestly — ✅ only where a red-on-fail check backs it, ⚠️ where something is implemented but unguarded, and ❌/⛔ where it is a known or upstream-gated gap. Nothing there is papered over, and the first published suite number for the compiled build may be low — it gets published anyway.

Which page answers what. This page covers how the build works and what it needs. For the measured cold-start and throughput numbers see the compiled executable; for what is and isn't verified see verified compatibility and the compatibility matrix.

On this page