knext

How your app is built

knext's default build compiles your Next.js app with next build into a standalone server, run as a Bun bytecode executable — the official-adapter-verified target. vinext (a single executable) stays selectable.

When you run knext build, knext turns your Next.js app into a deployable artifact 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 compiled-executable 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 is next build's standalone output (build: 'turbopack'), run by a small supervisor that spawns Next's own server.js and drains it gracefully on shutdown. This is the target the official Next.js compatibility suite verifies knext against. You do not have to configure anything to get it:

knext.config.ts
const config: KnativeNextConfig = {
  name: 'acme',
  registry: 'registry.example.com/acme',
  // build: 'turbopack' is the default — you can state it explicitly, but you don't need to
  // runtime: 'bun' is ALSO the default: the standalone server compiles into a Bun
  // bytecode executable. Set runtime: 'node' for the uncompiled fallback.
};

Pick which runtime executes it with runtime:

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

runtime: 'node' with cache.provider: redis: the Redis-backed ISR/data cache can silently fall back to a per-pod in-memory cache on this runtime — see ISR & the Redis cache for the symptom and workarounds. runtime: 'bun' (the default) is not affected.

The second target — opt-in, Beta in the stability tiers (implemented and tested on both runtimes, not yet behind its own compatibility credential) — compiles your app with vinext, an open-source, Vite-based implementation of the Next.js framework, into a single executable on Bun, or a plain server on Node. Select it explicitly with build: 'vinext':

knext.config.ts
const config: KnativeNextConfig = {
  name: 'acme',
  registry: 'registry.example.com/acme',
  build: 'vinext',
};

On this target a Pages Router app loads _document first, then _app, then your page modules, the same order Next.js evaluates them in, so top-level side effects in a custom document run before the app's and the page's.

experimental.inlineCss in next.config works on this target with both runtimes: an App Router page inlines its stylesheets in <style> tags instead of linking them.

An absolute-URL assetPrefix with a path (for example https://cdn.example.com/assets) works on this target with both runtimes: the app also serves its client bundles at /assets/_next/static/... on its own origin, so a CDN or proxy in front of it can fetch them from there.

next/og's ImageResponse (for example a dynamic opengraph-image route) works on this target with both runtimes, in pages API routes, app route handlers (Edge or Node.js runtime) and middleware:

  • Bun (compiled executable): the renderer's font and WebAssembly files are embedded in the binary rather than left behind on the machine that built it.
  • Node.js: knext build copies the same files into .output/server, which the image ships.

This includes @vercel/og 1.0.3, the version vinext 1.1.0 installs. Its published package is missing the HarfBuzz text-shaping file (hb.wasm), so the build takes that file from the exact satori and harfbuzzjs versions @vercel/og was built against. On Node.js it is staged next to the harfbuzzjs MIT licence (hb.wasm.LICENSE). On the Bun single executable the file is embedded in the binary, so the build writes the licence notice (knext-third-party-notices.txt) beside it and the generated Dockerfiles copy it into the image.

If those versions do not match, for example because an overrides or resolutions entry changed satori or harfbuzzjs, the build prints this warning (the Bun build prefixes it with [knext compile]):

WARNING: next/og will fail at runtime (ImageResponse answers 500): HarfBuzz's hb.wasm could not be bundled — …

The build still succeeds, because an app that never renders next/og is unaffected. An app that does render it removes the override and reinstalls. Set KNEXT_COMPILE_STRICT_REQUIRES=1 to make the build fail instead, on both the Bun and the Node.js runtime. A --self-contained Bun build also fails.

New apps from knext create --builder vinext pin @vercel/og to 0.11.1 through an overrides entry in package.json. That pin is no longer required on either runtime. You can keep it or remove it and reinstall.

The same applies to any package, not only @vercel/og. When a dependency reads a file shipped next to its own code, as in readFileSync(new URL('./data.wasm', import.meta.url)) (directly, or through fileURLToPath), the Bun build embeds that file in the binary. The binary still finds it after it is copied to another machine or into an image. The build log lists each embedded file ([knext compile] embedded sibling asset …). Only file reads are embedded. A URL passed to new Worker(...), fetch(...) or a dynamic import(...) is left as written, because it loads code or makes a request rather than reading bytes. A file larger than 16 MiB, or one that resolves outside the package that reads it, fails the build instead of being embedded.

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:

knext.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',       // the default runtime for this target too — or 'node' for the uncompiled fallback
};

Both the turbopack and webpack targets have a prerequisite in your project, not just in the config: knext 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 knext create already satisfy this (pass --builder vinext to scaffold the vinext shape instead). An app whose build script runs vite build and whose next.config.ts omits output must change both before you switch it to either of these targets, and an app that stays on vinext must set build: 'vinext' explicitly — an absent build means Turbopack.

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), knext 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.

knext 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' (the default) — knext 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).

Self-contained mode (experimental)

selfContained is an opt-in switch for a single executable that carries everything it needs to boot, with nothing beside it on disk. It defaults to false, and knext build --self-contained turns it on for one build, overriding the config key:

knext.config.ts
const config: KnativeNextConfig = {
  name: 'acme',
  registry: 'registry.example.com/acme',
  selfContained: true,   // optional — leave it out and nothing changes
};

On the compiled standalone-on-Bun target — build: 'turbopack' or build: 'webpack', either one, with runtime: 'bun' — turning it on embeds the server side of the build — the route code, Next.js's own server modules and the build manifests — into the executable, so it starts from a directory that holds only the executable, public/ and .next/static/. If any dependency under .next/server ships a native addon (a compiled .node file), the build fails immediately and names the file rather than embedding it: a native addon loads from a real filesystem path, which the executable's embedded filesystem cannot provide, so an embedded copy would only crash the first request that touches it.

On the compiled standalone-on-Bun target this also changes what knext deploy/knext preview actually ship. With selfContained off, the runtime image carries the full .next/standalone tree plus its own node_modules (installed into the image at build time so a small set of supervisor dependencies — logging, metrics, tracing — are available at boot). With it on, the image instead builds from a dedicated recipe that copies in only the compiled executable, public/ and .next/static/ — no node_modules anywhere in the image, and no separate supervisor process. When the image is started with its own default command, the one process that boots is the executable itself. Under knext deploy, the CR records that this build is self-contained, and the operator leaves the container command to the image's own default — the same treatment the vinext target already gets — so only the one process boots there too. A cluster running an operator old enough to predate that field still boots correctly: the image also ships a small server.js compat shim that execs the real executable and relays its signals and exit code, so an old operator's forced bun run server.js still works, just with one extra relay process it no longer needs. Either way the executable is what serves traffic: it drains in-flight requests and runs any pending after() work before exiting on shutdown, and it serves Prometheus text on its usual metrics port — but not the same metrics the regular image serves. This mode is not yet at full parity, and the gap is real, not cosmetic:

  • Served: a knext_up gauge (0 while draining, 1 while serving) and the process's own uptime counter.
  • Not yet included: the golden-signal and cold-start metrics the regular image's supervisor merges in from the app process, Node's own default process metrics (event-loop lag, heap, GC), the DB-connection-pool drain on shutdown, and the image-optimization variant cache sync (so a cold pod re-optimizes every image another pod already produced, rather than reusing it from object storage). Anything scraping or alerting on those signals for a self-contained deployment will not see them.

Closing this gap is tracked work, not an open-ended caveat — check the project's issue tracker for current status before relying on this mode for a deployment that needs those signals.

On the vinext target, the binary embeds the server runtime and your public files, and unpacks its native image libraries into the temp directory the first time an image is optimized — so nothing needs to sit beside the binary, and the temp directory must be writable and executable (the knext operator always mounts a writable emptyDir at /tmp for this build shape — unconditionally, with no extra config needed — which is exec by default; with docker run --read-only, add --tmpfs /tmp:exec — Docker's --tmpfs defaults to noexec, and dlopen-ing the unpacked native library from a noexec mount fails with "failed to map segment from shared object"). See Security → Read-only root filesystem for when knext mounts volumes on other build shapes.

Both targets support it; nothing else does — there, setting it changes nothing about what knext build produces, and the choice is only recorded in the build log. The value must be a boolean — knext validate rejects anything else.

What's measured so far

Two things have been measured on this mode, honestly reported here rather than left as a general "still experimental" caveat.

Compatibility. Run against the official Next.js compatibility suite, served from an empty directory so nothing left beside the executable could paper over a missing file: the compiled-standalone-on-Bun target passed 763 of 792 tests with the turbopack build and 767 of 792 with the webpack build. With self-contained mode off, the same target passes all 792 of 792 — the result knext's compatibility credential is built on. On the vinext target, the self-contained build passed 719 of 778 tests, against 728 of 778 for the regular (non-self-contained) vinext build.

Cold start. Across seven paired scale-to-zero cycles on a real cluster, the self-contained image's median cold start was 2.07 s against 2.93 s for the default image — a difference within the run-to-run spread of these measurements, so this is not (yet) a proven speed win, even though it points in the faster direction. The image itself is smaller either way: 101 MB against 115 MB.

Because of the compatibility gap above, the default build — self-contained mode off — is the build covered by knext's compatibility credential. Self-contained mode stays opt-in and experimental: turn it on only for a deployment you have tested against your own app, not as the default choice for a new one.

Known gaps, and how to tell if your app hits one

  • A package your bundler resolves as an "external" cannot always be embedded (Turbopack builds only). If your app hits this, knext build fails at build time and names the package — the build never produces an image, so you find out before you deploy, not at runtime.
  • Files your app reads from disk at request time, when they are not produced by the build itself, are not included in the executable. Custom fonts loaded for generated images, static files read directly from the filesystem, and per-route data files read outside the framework's own data-fetching functions are the common cases. If your app hits this, the affected route answers with a server error (occasionally the connection drops instead) rather than the page you expect — check the response for that route specifically after you deploy with this mode on.
  • A dependency with a native addon (a compiled .node file) other than the image-optimization library cannot be embedded, for the same reason described above for standalone builds: knext build fails immediately and names the dependency.
  • Some ISR and Partial Prerendering resume paths behave differently. A page can serve a stale or empty shell, or report a render as freshly built when it should report it as already revalidated. There is no build-time signal for this one — it only shows up by exercising the affected route after you deploy.
  • Some app configurations fail the startup check before the executable serves traffic. Apps that combine middleware with a generated sitemap, or that use React's taint APIs, may not become ready — check the startup logs after you deploy with this mode on. The default build is unaffected.

If your app depends on any of the above, keep self-contained mode off for now. knext build surfaces the first two at build time either way; self-contained mode turns them from "runs, with a gap" into "the build refuses," so you find out before you deploy rather than after.

vinext on Node

The vinext target also runs on Node. Set build: 'vinext' explicitly and runtime: 'node':

knext.config.ts
const config: KnativeNextConfig = {
  name: 'acme',
  registry: 'registry.example.com/acme',
  build: 'vinext',
  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 knext.config.ts, so knext build, knext 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 knext.config.ts, default /api/health) before shipping the compiled code as a layer. knext deploy and 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. knext 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 knext build, which never overwrites an existing one.
  • Bun is not needed to build or run it, at all. If your app depends on sharp (every app knext create --builder vinext scaffolds does, for image optimization), knext build stages sharp's native addon for the image's target platform from your lockfile — either bun.lock or npm's package-lock.json. A plain npm install project (the default knext create tells you to run) needs nothing extra: the lockfile it already has is enough.
  • knext 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. knext 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 './knext.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 vinext pipeline

On the vinext target, knext 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 knext build bakes them into the binary. This covers every file of the server bundle, including the chunks nitro splits off next to the entry. If one of them cannot be resolved at build time, the build still succeeds but prints a warning naming the package and the file that loads it:

[knext compile] WARNING: the server output runtime-requires package(s) that do not resolve and
cannot be bundled: <package> (chunks/<file>.mjs) — 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>'.

The build also warns, naming the file, in two cases where it cannot know what to bundle:

  • a dependency calls require() with a package name computed at runtime (for example require(name)), so the package is only known when that code runs;
  • the bundle creates its require in a form the build does not recognise.

In both cases the binary throws Cannot find module if that code loads a package that was not bundled and is not deployed next to the binary.

To make these warnings a build failure (for example in CI), set KNEXT_COMPILE_STRICT_REQUIRES=1 when you run knext build. Two limits apply:

  • Minified server bundles. When the server bundle is minified, other code can reuse the short name of the bundle's require, so the build cannot always tell a real require from an unrelated call. It then reports the finding as WARNING: possibly a runtime require of …. These warnings do not fail a strict build, because they can be false alarms. The server bundle is not minified by default.
  • Optional dependencies. Some packages require() another package only if it is installed, for example ws (bufferutil, utf-8-validate) or pg (pg-native). If your app does not install those, the build correctly warns that they cannot be bundled, and a strict build fails on that warning. Leave strict mode off for such apps, or install the optional packages.

The same applies to packages you list in serverExternalPackages and to the packages Next.js keeps external by default (for example typescript, sqlite3, prisma). The binary carries a bundled copy of each one. When .output/server/node_modules is deployed next to the executable, the binary loads the CommonJS ones from there instead, just as next start would, and a runtime require.resolve() for a package finds it there too. That matters for packages that need their own files at runtime: a package that looks up files next to itself (as typescript does for its lib.*.d.ts files), or one with a native addon. The build names these packages:

[knext compile] 1 server external(s) load from …/.output/server/node_modules when it is beside
the binary, else from the bundle: typescript
[knext compile] 1 ES-module server external(s) stay bundled (their own imports cannot reach the
sidecar): twoslash

The executable loads these packages only from the .output/server/node_modules directory next to it.

A package with a native addon (a compiled .node file, such as sqlite3) can only load from that directory, and the build warns about it:

[knext compile] WARNING: sqlite3 ship(s) a native addon, which cannot load from inside the binary.
The binary loads it from .output/server/node_modules, so that directory must be deployed next to
the binary (built for the target platform).

The generated Dockerfile does not copy .output/server/node_modules yet. If your app depends on a native addon, copy that directory into the image next to the binary (as /app/.output/server/node_modules), and build it on the platform the image runs on, because a native addon only loads on the operating system and CPU it was compiled for.

Bundled vinext fixes

knext ships some vinext fixes before vinext itself releases them, so you do not wait on an upstream release. They are applied to the vinext installed for your app by knext vinext-patches, which a vinext app created by knext create runs from its postinstall script; knext build applies them again before building, so an app created before the script existed is covered too. The fixes target the exact vinext version knext create pins. With a different vinext version installed they are all skipped, and the build prints a warning that says how many fixes it skipped, what the build is missing and how to fix it (install the pinned vinext version). Set KNEXT_VINEXT_PATCHES=strict to fail the build on that mismatch instead, and see knext doctor for the matching check. Set KNEXT_VINEXT_PATCHES=0 to turn the fixes off, for example if you patch vinext yourself (0, false, off and no all turn them off). A misspelled value, such as stric, is treated as unset and a vinext build prints a warning that names it. Each fix is removed from knext once a vinext release includes it.

The fixes bundled with vinext 1.1.0:

  • require() of CommonJS dependencies in server code resolves the package's require export condition, even for a package Vite would otherwise leave external.
  • turbopack.resolveExtensions without .mjs no longer breaks the build by hiding the server preset's own entry file.
  • Packages with a react-server export condition get that condition in Server Components, because their dependencies are now bundled into the server build.
  • outputFileTracingIncludes / outputFileTracingExcludes in next.config are applied to the dependency files copied next to the server build.
  • A file that starts with a #! shebang line and also uses module.exports/require() (for example, a CLI-style script imported by your app) no longer fails to build with an "Invalid Character" parse error.
  • A server action's redirect() called from client-invoked (fetch) code now answers 200, matching current Next.js, instead of sometimes falling back to 303. The redirect target still reaches the browser the same way (an x-action-redirect header, no Location header); only the response's own status code changes, so this only matters if your own code inspects response.status on a server action call. A no-JS <form> submission's redirect is unaffected and still answers 303.
  • /_next/image in a Nitro build is served by the app itself with the Next.js image headers (including x-nextjs-cache: MISS) instead of a redirect to the source file. This covers Pages Router apps too, where the endpoint used to answer 404.
  • images.loaderFile in next.config is honoured: next/image renders its <img> through your custom loader instead of silently using the built-in /_next/image endpoint. A missing file, or a loader other than default/custom set next to it, fails the build as in Next.js.
  • A page with <style jsx> in a Nitro build renders instead of failing with an "Invalid hook call" error. The build now always traces react and react-dom, so styled-jsx shares the server's single React copy, including after you move the .output directory. If your app was scaffolded before this fix and you do not use the bundled fixes, add traceDeps: ['react', 'react-dom'] to the nitro({ ... }) options in vite.config.ts.
  • A rewrite to a file in public/ (a next.config rewrite, or NextResponse.rewrite() in middleware) serves that file instead of the 404 page. Pages Router apps get this for beforeFiles, afterFiles and fallback rewrites, App Router apps for beforeFiles and middleware rewrites.
  • Middleware runs before a file in public/ that its matcher covers, as in Next.js. A request for such a file reaches your middleware first, and the file is served only if the middleware lets the request continue. Middleware without a matcher runs for every file in public/. Files the matcher does not cover, and the build's own /_next/static/ assets, are still served directly without running middleware.
  • A tsconfig paths entry with several targets ("@lib/*": ["lib/a/*", "lib/b/*"]) falls back to the later targets when the first one does not resolve, and a .d.ts target is never used as a module, as in TypeScript and Next.js. Before, only the first target was used.
  • import data from "./data" with { type: "json" } loads the file as JSON even when it has no .json extension, instead of failing the build with a parse error.
  • A require() in a branch that process.env.NEXT_RUNTIME rules out (for example, an instrumentation file that requires a module only when the runtime is neither nodejs nor edge) is no longer resolved, so the build no longer fails on a module that branch could never load.
  • In an app with both app/ and pages/, a Pages route that returns notFound (from getStaticProps or getServerSideProps, or a path a fallback: false page does not list) renders app/not-found, as in Next.js, instead of pages/404. The 404 response keeps the Pages route's Cache-Control. An app with no app/not-found of its own gets the built-in not-found page, not pages/404, as in Next.js. This applies to production builds; the development server still shows pages/404.
  • The client build manifest (/_next/static/<buildId>/_buildManifest.js) lists the app's Pages Router routes in sortedPages, in the order Next.js uses, instead of an empty list. API routes, /_app and /_error are included, as in a Next.js build.

Build the image on your own builder (Cloud Build / CI)

knext build leaves a ready docker build context in your project, so you can build the image on a remote builder instead of local docker. For the standalone targets (Node and Bun, including the compiled single executable) it writes Dockerfile.standalone, its .dockerignore sidecar, and the entry shims, and prints the context path and a docker buildx build command template. They are the same files knext deploy stages before its own image build.

The staged recipe is named Dockerfile.standalone, not Dockerfile, so gcloud builds submit --tag (which looks for a file named Dockerfile) does not work. Pass the file and target explicitly. The image must be linux/amd64, the platform the cluster runs. The --target is standalone-bun, standalone-bun-self-contained or standalone-node, matching your runtime and selfContained settings (the printed command already has the right one).

Cloud Build: a minimal cloudbuild.yaml at the project root:

steps:
  - name: gcr.io/cloud-builders/docker
    args:
      - build
      - --platform=linux/amd64
      - -f
      - Dockerfile.standalone
      - --target
      - standalone-bun
      - -t
      - $_IMAGE
      - .
images: [$_IMAGE]
knext build
gcloud builds submit --config cloudbuild.yaml \
  --substitutions _IMAGE="$REGISTRY/my-app:v1"
knext deploy --image "$REGISTRY/my-app@sha256:..."

Any other CI: run the printed command (docker buildx build --platform linux/amd64 --target <target> -f Dockerfile.standalone -t <image-ref> --push .) from the project root after knext build.

Ignore files. gcloud builds submit uses a .gcloudignore, falls back to your .gitignore when there is none, and does not read the .dockerignore sidecar. A scaffolded .gitignore hides the compiled knext-exec* binaries, so without a .gcloudignore the build fails with a missing COPY source. Do not copy the sidecar into .gcloudignore: it is written for docker's root-anchored matching and also excludes Dockerfile*, dist and build, which under gitignore matching would drop the recipe and files inside the standalone tree. Use this instead, which sends everything the recipe copies (the recipe, the three entry shims, the compiled executable, .next/standalone, .next/static, public and node_modules/@getknext/core) and trims the rest of node_modules:

.gcloudignore
.git
.next/cache
node_modules/*
!node_modules/@getknext

Plain docker and buildx read the .dockerignore sidecar instead.

Context size. The uploaded context holds the compiled executable and the standalone tree, often well over 100 MB. Trim other unneeded directories the same way, in the ignore file.

If the project has no lockfile, knext build skips staging (and says so), since the build context cannot be anchored; add one, as knext deploy also requires. If staging itself fails, knext build fails with the reason. vinext apps use their own scaffolded Dockerfile and are not staged this way.

Requirements

For vinext: 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"
}

knext 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 on the Bun runtime or vinext 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 knext 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.

Including extra files in the compiled executable (experimental)

The compiled vinext executable carries the modules your server code imports statically. A module your code loads by a path the bundler cannot follow — a plugin whose file name is chosen at runtime, say — is not in it, and on a cluster there is nothing beside the binary to load it from. List such modules in compile.include:

knext.config.ts
const config: KnativeNextConfig = {
  name: 'acme',
  registry: 'registry.example.com/acme',
  build: 'vinext',
  compile: {
    include: ['plugins/*.js'],   // globs, relative to the app root
  },
};

Each matching module is embedded in the executable at /$bunfs/root/<its path relative to the app root> — plugins/greet.js becomes /$bunfs/root/plugins/greet.js (a .ts, .mjs or .cjs source is embedded with a .js extension). It is not run at startup; it loads from inside the executable the first time your code imports it, and nothing needs to sit beside the binary. It is compiled to bytecode like the rest of the server, and knext build checks that it was. This works with the stock Bun on your PATH; no special toolchain is involved.

Import an included module by its embedded path, from code the bundler does not rewrite. Vite turns an import() whose specifier it cannot analyse into a stub that fails at runtime, so build the call at runtime instead:

const load = new Function('specifier', 'return import(specifier)') as (s: string) => Promise<any>;
const plugin = await load(`/$bunfs/root/plugins/${name}.js`);

Limits. The build fails, naming the offending pattern or file, rather than embedding less than you asked for:

  • only JavaScript and TypeScript modules (.js .mjs .cjs .ts .mts .cts .jsx .tsx) can be included — a glob that matches a JSON file, an image or any other file fails the build;
  • a native addon (.node, .so, .dylib, .dll) is refused, whether a glob matches it or you name it exactly: an addon inside the executable cannot be loaded. Load it with a plain static require("./native/addon.node") instead, which Bun embeds and loads itself;
  • a pattern that matches nothing fails the build;
  • patterns are relative to the app root: an absolute pattern, or one containing .., fails the build;
  • every match must really be inside the app root — a file or directory that is a symlink to somewhere outside it fails the build (a glob does not follow a symlinked file at all), and node_modules is never searched — a directory pattern such as plugins embeds every module below it except anything under a node_modules directory;
  • files that look like secrets (.env*, *.pem, *.key, id_*) are never picked up by a glob — the build fails and names them; list one by its exact path if it really belongs in the executable;
  • a file whose name contains a backslash cannot be included — the build fails and names it;
  • compile.include applies only to the compiled vinext executable (build: 'vinext' on the Bun runtime); the config check rejects it for every other target.

It is experimental: the option may change in a minor release.

Optional patched Bun toolchain (experimental)

By default the compile step runs the Bun on your PATH, and compile.include works as described above. As an opt-in, knext can run that one step with a knext-published build of Bun 1.4.4 plus --compile --include, an option that is not in a Bun release yet:

knext.config.ts
compile: {
  bun: 'knext-patched',        // default: 'stock' (the Bun on PATH)
  include: ['plugins/*.js'],   // optional; exactly the same option as above
},

Same option, same checks, same paths. include is expanded and checked by knext exactly as on stock Bun — every limit in the list above applies unchanged — and the resulting list of files (never your raw globs) is handed to the patched Bun's --include, with any glob characters in a file name ([ ] { } * ?, as in [id].js) escaped so that exactly that file is embedded. Each module lands at the same /$bunfs/root/<its path relative to the app root> and is imported the same way. If the compiler does not actually embed a listed module, the build fails rather than shipping a binary without it.

How it is verified. The first knext build on a machine downloads the toolchain from the knext GitHub release into a cache directory ($KNEXT_CACHE_DIR, else $XDG_CACHE_HOME/knext, else ~/.cache/knext) and checks its sha256 against a value pinned inside the knext version you installed. A mismatch, an HTTP error or a network error fails the build — it never falls back to stock Bun, and nothing unverified is cached. A cached copy is re-hashed on every build, so a modified cache file is discarded and downloaded again.

The release is published only after its own gates pass: the binaries are checked against the pinned checksums, smoke-tested on x64 and on a real arm64 machine, and used for an end-to-end knext build that boots the image and loads an included module. Its checksum file is signed (cosign, keyless, by the knext release workflow) and each binary carries a GitHub build-provenance attestation. knext build does not check either — the pinned sha256 is the binding check — but you can, with the release tag the knext version you installed pins:

TAG=bun-patched-1.4.4-knext.3
gh release download "$TAG" -R getknext-dev/knext
cosign verify-blob --bundle SHA256SUMS.sigstore.json \
  --certificate-identity "https://github.com/getknext-dev/knext/.github/workflows/bun-patched-release.yml@refs/tags/bun-patched-1.4.4-knext.3" \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com SHA256SUMS
sha256sum -c SHA256SUMS --ignore-missing
gh attestation verify bun-linux-x64 -R getknext-dev/knext \
  --cert-identity "https://github.com/getknext-dev/knext/.github/workflows/bun-patched-release.yml@refs/tags/bun-patched-1.4.4-knext.3" \
  --source-ref "refs/tags/$TAG"

The signature and the attestation show that the knext release workflow, at that tag, checked and published those exact bytes. They do not attest the machine that compiled Bun itself: that build runs outside GitHub, from the recipe in the knext repository.

What it changes, and what it does not. The patched Bun runs the compile step only. The shipped executable targets linux-musl, which Bun builds on its own stock Bun base for that target, so the binary you deploy still runs on stock Bun. The toolchain is published for Linux build machines with glibc, x64 and arm64 (such as GitHub-hosted runners); on a musl-based (Alpine) machine, macOS or Windows the build stops with an error that says so. Remove bun, or set bun: 'stock', to go back to the Bun on your PATH.

Known limits. Read these before relying on it:

  • No cluster-deploy proof yet. It is tested by building the real container image with it, booting that image and loading an included module, on x64 and arm64 build machines. A full knext deploy to a Kubernetes cluster with this toolchain has not been run.
  • Thin SBOM. Each binary's SBOM records only the file and its digest, with no component list. The separate source SBOM lists the Bun source tree's components but does not record the added patch.
  • Build identity. The binaries are compiled on Google Cloud Build under the project's default build service account, not a dedicated least-privilege one. The signature and attestation cover the release workflow's checks and publication, not that build machine.
  • A slower compiler. It is not built with link-time optimisation, so the compile step runs about 4–5% slower than stock Bun. The executable you ship is unaffected.

Not covered by the compatibility credential. The verified results on this site were earned with stock Bun. Treat this toolchain as experimental: its shape may change in a minor release, and it will be removed once a stock Bun release ships --compile --include.

Optional vinext build

build: 'vinext' is an opt-in build target, enabled by setting that one field in knext.config.ts (or scaffolding a new app with knext create --builder vinext). It runs on both runtimes:

  • runtime: 'bun' (the default once you opt in) — the compiled Bun single executable described above, with the whole server precompiled to bytecode.
  • runtime: 'node' — vinext on Node above: a plain Node server with no compile step, instead getting its bytecode caching from a V8 compile cache baked into the image at docker build time (Dockerfile.vinext-node).

Tier: Beta (stability tiers) on both runtimes — implemented, built and booted end to end against the published package, but not covered by the v1.0 compatibility credential (the 14-consecutive-run gate the four default-target cells carry — see the compatibility credential). vinext has its own, separate, not-yet-complete credentialing track.

Fixed from this release on: the build's own boot check used to fail on glibc Linux build machines for apps that depend on sharp. After compiling, knext build on this target boots the executable itself to confirm it serves and drains correctly before finishing. On a glibc-based Linux machine — most Linux CI runners (including GitHub-hosted ones) and most Linux desktops; macOS was unaffected — that check used to fail with an "unknown build arch" error for any app that depends on sharp (every app knext create --builder vinext scaffolds does, for image optimization). The boot check now stages a real sharp native addon for that check's own binary too, the same way the binary you ship always has, so the check boots cleanly. The compiled binary you ship was never affected; only this build-time check was.

Compatibility, stated honestly

knext's official-suite results — for example 1101 of 1101 at Next.js v16.3.8, in dispatched runs on each of the four default cells — were earned on the standalone target (the build: 'turbopack' and build: 'webpack' shapes above), which is a different artifact from the compiled vinext executable. None of those cells is credentialed yet; see the compatibility credential. The suite runs against the standalone server directly, so the result covers that output rather than the supervisor knext boots it with. The vinext build (Bun runtime) is measured, not credentialed: its latest run passed 1016 of 1099 files. Of the rest, 41 are disclosed known-gap files (vinext does not implement the Next.js 16.3 client-router architecture), 6 are individually quarantined and 36 fail. On the official suite minus those 41 known-gap files, 1016 of 1058 pass; vinext remains Beta. The Node runtime for the vinext target has not been run through this 778-test suite at all — it is proven to build, boot and serve a live request against the published package, but has no official-suite number yet. Read the per-feature status on the compatibility matrix rather than assuming the standalone number, or the Bun vinext number, carries over to Node.

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 vinext number is published as it is measured, even when it is low. Track current known gaps on the project's public issue tracker.

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 the compatibility credential and the compatibility matrix.

On this page