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:
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:
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':
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 buildcopies 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:
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 buildcompiles the standaloneserver.jsinto 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 spawnsserver.jsunder 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:
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_upgauge (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 buildfails 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
.nodefile) other than the image-optimization library cannot be embedded, for the same reason described above for standalone builds:knext buildfails 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':
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 buildproduces nitro's Node preset rather than the Bun one, withknext-node-entry.mjsas the server entry. That entry provides what the Bun entry does: the health route, the:9464metrics endpoint, and draining in-flight requests onSIGTERM.vite.config.tspicks the preset from theruntimeinknext.config.ts, soknext build,knext deployand a plainnpm run buildall agree. - Bytecode caching is the V8 compile cache, baked into the image. The image
(
Dockerfile.vinext-node, next to yourDockerfile) boots the server once duringdocker buildand warms your configured health check path (healthCheckPathinknext.config.ts, default/api/health) before shipping the compiled code as a layer.knext deployandpreview— the two commands that actually rundocker build— thread a customhealthCheckPaththrough automatically as a--build-arg, so a custom health route works with nothing extra to configure through either of them.knext builddoes not: it only stagesDockerfile.vinext-nodeinto 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 withNODE_DEBUG_NATIVE=COMPILE_CACHE: the log says the cache for/app/.output/server/index.mjswas accepted.
ADockerfile.vinext-nodestaged before this fix (noARG KNEXT_HEALTH_CHECK_PATH) needs re-staging if your app uses a customhealthCheckPath: delete the file and re-runknext 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 appknext create --builder vinextscaffolds does, for image optimization),knext buildstages sharp's native addon for the image's target platform from your lockfile — eitherbun.lockor npm'spackage-lock.json. A plainnpm installproject (the defaultknext createtells you to run) needs nothing extra: the lockfile it already has is enough. knext buildchecks 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:
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/imagethen 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:
vite build— vinext compiles your app and emits a nitro server bundle targeting the Bun preset (a.outputdirectory whose entry is an ES module).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 runsInstall 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 examplerequire(name)), so the package is only known when that code runs; - the bundle creates its
requirein 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 realrequirefrom an unrelated call. It then reports the finding asWARNING: 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 examplews(bufferutil,utf-8-validate) orpg(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): twoslashThe 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'srequireexport condition, even for a package Vite would otherwise leave external.turbopack.resolveExtensionswithout.mjsno longer breaks the build by hiding the server preset's own entry file.- Packages with a
react-serverexport condition get that condition in Server Components, because their dependencies are now bundled into the server build. outputFileTracingIncludes/outputFileTracingExcludesinnext.configare applied to the dependency files copied next to the server build.- A file that starts with a
#!shebang line and also usesmodule.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 answers200, matching current Next.js, instead of sometimes falling back to303. The redirect target still reaches the browser the same way (anx-action-redirectheader, noLocationheader); only the response's own status code changes, so this only matters if your own code inspectsresponse.statuson a server action call. A no-JS<form>submission's redirect is unaffected and still answers303. /_next/imagein a Nitro build is served by the app itself with the Next.js image headers (includingx-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.loaderFileinnext.configis honoured:next/imagerenders its<img>through your custom loader instead of silently using the built-in/_next/imageendpoint. A missing file, or aloaderother thandefault/customset 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 tracesreactandreact-dom, so styled-jsx shares the server's single React copy, including after you move the.outputdirectory. If your app was scaffolded before this fix and you do not use the bundled fixes, addtraceDeps: ['react', 'react-dom']to thenitro({ ... })options invite.config.ts. - A rewrite to a file in
public/(anext.configrewrite, orNextResponse.rewrite()in middleware) serves that file instead of the 404 page. Pages Router apps get this forbeforeFiles,afterFilesandfallbackrewrites, App Router apps forbeforeFilesand middleware rewrites. - Middleware runs before a file in
public/that itsmatchercovers, 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 amatcherruns for every file inpublic/. Files the matcher does not cover, and the build's own/_next/static/assets, are still served directly without running middleware. - A tsconfig
pathsentry with several targets ("@lib/*": ["lib/a/*", "lib/b/*"]) falls back to the later targets when the first one does not resolve, and a.d.tstarget 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.jsonextension, instead of failing the build with a parse error.- A
require()in a branch thatprocess.env.NEXT_RUNTIMErules out (for example, an instrumentation file that requires a module only when the runtime is neithernodejsnoredge) is no longer resolved, so the build no longer fails on a module that branch could never load. - In an app with both
app/andpages/, a Pages route that returnsnotFound(fromgetStaticPropsorgetServerSideProps, or a path afallback: falsepage does not list) rendersapp/not-found, as in Next.js, instead ofpages/404. The 404 response keeps the Pages route'sCache-Control. An app with noapp/not-foundof its own gets the built-in not-found page, notpages/404, as in Next.js. This applies to production builds; the development server still showspages/404. - The client build manifest (
/_next/static/<buildId>/_buildManifest.js) lists the app's Pages Router routes insortedPages, in the order Next.js uses, instead of an empty list. API routes,/_appand/_errorare 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:
.git
.next/cache
node_modules/*
!node_modules/@getknextPlain 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:
{
"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"topackage.json; - name your Next.js config
next.config.mjsornext.config.cjs(a barenext.config.jsis interpreted as ESM once"type": "module"is set); - if the project still has CommonJS files that rely on
require()or on a.jsextension meaning CommonJS, rename them to.cjs(or convert them toimport/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:
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 staticrequire("./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_modulesis never searched — a directory pattern such aspluginsembeds every module below it except anything under anode_modulesdirectory; - 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.includeapplies 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:
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 deployto 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 atdocker buildtime (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.
The adapter and the compiled build
How knext's default (standalone) build uses the official Next.js adapter, and how the selectable vinext build relates to it — and which claims each carries.
Deploy from a workspace monorepo
Set the tracing root to your workspace root so knext packages an app that shares code with packages above it.