Skip to content

How It Works

The architecture behind the binary — what the build adapter does at build time, what the runtime does at boot and per request.

Updated View as Markdown

Pipeline

  1. next build runs your normal Next.js build. Because adapterPath points at next-bun-compile, Next invokes the adapter’s onBuildComplete with its typed build outputs: every route’s traced assets, prerender metadata (revalidate times, PPR state, headers), middleware matchers, and routing rules.
  2. The adapter assembles a standalone-equivalent tree from those traced outputs — per-route assets, the server runtime’s NFT trace, prerender seed files. output: "standalone" is never used.
  3. Code generation scans the assembled tree, discovers externalized packages and turbopack-mangled aliases, computes which pages can be served frozen from memory, and generates server-entry.js + assets.generated.js.
  4. Bun compiles the entry, the serve runtime, and every embedded asset file into one minified binary. Next.js itself is not bundled — it loads at runtime from the extracted tree, so nothing is carried twice.

The three-tier runtime

A single Bun.serve owns the port. Requests route through the fastest tier that can answer them correctly:

  • Tier 1 — static assets. /_next/static/* and public/* served from memory with precompressed gzip, ETags, and immutable cache headers.
  • Tier 2 — frozen prerendered pages. Pages with revalidate: false, no PPR postponed state, and no covering middleware or routing rule are served from memory with full RSC content negotiation. Non-GET methods (server actions!) always fall through to Next.
  • Tier 3 — everything else. PPR resume, ISR, API routes, server actions, and dynamic rendering go to Next’s own router-server request handler in-process, through a fetch→node bridge — the exact stack next start runs, minus its HTTP listener. ISR and cache-component responses additionally get an in-memory L1 cache.

Revalidation stays Next’s job. The runtime patches Next’s incremental-cache wrapper — the layer that delegates to whichever cache handler is configured — so every revalidateTag/revalidatePath and background regeneration drops the affected page from the memory tiers. Semantics are identical to next start, just faster between changes. With a custom cacheHandler configured, the page tiers turn themselves off entirely.

Next boots lazily on the first dynamic request; the static tiers answer from the first millisecond the port is open.

What gets embedded

  • All static files in .next/static/ (skipped if assetPrefix set)
  • All public files
  • Prerendered pages: HTML, RSC payloads, and their metadata
  • The .next/server/ tree (chunks, manifests, build ID)
  • Every externalized server package — traced from your node_modules/ into the binary as file assets
  • The server orchestration graph (router-server chain), traced with the same pruning output: "standalone" applied

Runtime: asset extraction

On first run, the binary writes its embedded assets to disk relative to process.execPath — or to NBC_RUNTIME_DIR if set (point it at tmpfs for RAM-backed runtime files and read-only root filesystems). It then stamps .next/.nbc-extracted with a content hash of the build plus the directory it extracted into. Later boots compare that stamp: a match skips extraction entirely (one file read), while a mismatch — new binary, moved directory, or a half-finished previous extraction — re-extracts everything with overwrite so nothing stale can shadow the embedded assets. Extraction is bounded to 64 concurrent writes so it can’t exhaust file-descriptor limits.

Resolver hook for externalized packages

Next.js with Turbopack externalizes some packages (sharp, bcrypt, anything in serverExternalPackages, anything with native bindings) — they’re loaded at runtime instead of bundled. Two things make that challenging in a compiled binary:

  1. Mangled names. Turbopack rewrites require("sharp") to require("sharp-457ea9eae1af1a9c") in chunks, with a build-time symlink at .next/node_modules/sharp-457... → sharp. The compiled binary has no node_modules tree pre-baked.
  2. Bun’s compiled-binary resolver. Since bun 1.3.4 a compiled executable does not consult node_modules on disk unless built with --compile-autoload-package-json, which next-bun-compile passes. With it, an extracted package (e.g. sharp/lib/sharp.js) resolves its own dependencies from the extracted tree, ESM imports included. The CJS hook below still covers the cases bun’s resolver rejects.

Two mechanisms

1. Build-time chunk rewrite

next-bun-compile scans server chunks for "<name>-<16 hex>" string literals (the mangled-alias pattern) and replaces each with the absolute file path of the canonical target:

- require("sharp-457ea9eae1af1a9c")
+ require("__NBC_BASE__/.next/node_modules/sharp/lib/index.js")

__NBC_BASE__ is a placeholder substituted with the real baseDir when extractAssets writes each chunk to disk. Resolution becomes a direct file load — no walks, no exports-map ambiguity, no resolver involvement. Works for both CJS require() and ESM import().

For subpath imports (import("prettier-.../plugins/html")), next-bun-compile reads each canonical’s package.json + exports map to pick the right .mjs/.js/.cjs variant, then rewrites to that absolute path.

2. Runtime Module._resolveFilename hook

Once execution is inside an extracted package (sharp’s own require('detect-libc'), dynamic interpolated requires like require('@img/sharp-PLATFORM/sharp.node')), the chunk rewrite no longer applies. A Module._resolveFilename hook catches CJS resolves that bun’s compiled-binary resolver fails on and reimplements Node-compatible resolution from scratch:

  • Walk node_modules from the calling file
  • Read package.json + main for top-level
  • Honor exports maps (require/node/default conditions)
  • Handle directory subpaths with their own package.json + main (e.g. next/dist/compiled/source-map)

The hook also consults an alias map (sharp-457... → sharp) and redirects mangled names that slip past the build-time rewrite.

Module stubs (dev-only code paths)

Some modules can’t be resolved at compile time but are never reached in production — Next’s dev server, the dev bundler, optional deps loaded in try/catch. next-bun-compile creates no-op stubs for these only if the real module isn’t installed. If you do install @opentelemetry/api or critters, the real package gets bundled instead.

Pre-flight validator (build time)

For every alias + subpath the chunks reference, next-bun-compile checks whether resolution found a file. If anything didn’t resolve, it warns at build time:

next-bun-compile: ⚠ 1 of 4 turbopack alias reference(s) won't resolve at runtime:
  ✗ some-pkg-deadbeef/foo → some-pkg/foo

Set NEXT_BUN_COMPILE_VERBOSE=1 during build to see every resolution + the file it picked.

Debug mode (runtime)

Set NEXT_BUN_COMPILE_DEBUG=1 when running the binary to log every resolver-hook decision: alias redirects, fallback walks, fallback failures, with parent-file context. See Debug mode.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close