This guide walks through moving a project from the Bun runtime to stock Node driven by Nub. The shape of the move is: Nub runs your TypeScript directly on stock Node — the version your project pins, provisioned on demand — your package manager and lockfile stay where they are, and the Bun.* APIs your code calls get swapped for their Node or npm-ecosystem equivalents. Most of the work is the last part — Bun's batteries-included global APIs are the surface with no exact Node counterpart, so each one needs a deliberate replacement.

Everything below is grounded in how Nub actually behaves today. Where Nub does not yet do something, this guide says so plainly rather than papering over it.

Runtime and TypeScript execution

Bun runs .ts files directly. So does Nub — point it at an entry file and it transpiles TypeScript (and JSX, enums, namespaces, parameter properties, emitDecoratorMetadata) on stock Node, with no tsconfig build step and no separate loader to register.

bun run src/index.ts          # before
nub src/index.ts              # after

There is no nub run <file> form for a bare file — nub <file> is the file runner, and nub run <script> is the package.json script runner. Keep the two straight: nub run dev runs the "dev" script, nub src/server.ts runs the file.

If you ever need to compare against unaugmented Node — to rule out a transpile or runtime-augmentation difference — nub --node <file> runs the file on plain Node with Nub's augmentation switched off, while still using the Node version your project pins. A truthy NODE_COMPAT environment variable does the same thing tree-wide and persistently.

Choosing a Node version

Bun bundles its own runtime, so a Bun project never picks a Node version. On Nub you do, because Nub runs your Node. Pin it the way the rest of the Node ecosystem does — an .nvmrc, a packageManager/devEngines entry, or nub node pin — and Nub provisions and uses that version. Pick a version your dependencies actually support; some packages reach for globals (Float16Array, web crypto surfaces, structuredClone) that only exist from a given Node major onward, and a too-old floor will fail at runtime rather than at install. When in doubt, pin a current LTS or the latest stable and set a realistic engines.node floor.

Nub's runtime augmentation is tier-banded by Node version: the fast tier (Node 22.15+) uses synchronous module hooks, and the compat tier (18.19–22.14) uses the async loader-worker path. Both transpile TypeScript; the mechanism differs. Newer Node is the smoother path.

Package manager and lockfile

Nub's package-manager surface mirrors pnpm's command grammar:

bun install                   # → nub install   (alias: nub i)
bun add <pkg>                 # → nub add <pkg>
bun remove <pkg>              # → nub remove <pkg>
bunx <pkg>                    # → nubx <pkg>     (alias: nub dlx <pkg>)

Nub is lockfile-compatible with what your project already uses — it round-trips npm, pnpm, and bun lockfiles and reads yarn's — so you do not have to convert your lockfile to adopt Nub. The CLI grammar is pnpm's, but the lockfile axis is multi-PM. If you want Nub to own resolution as a Nub-identity project (consuming only neutral, cross-tool config), drop the bun-branded bunfig.toml and let Nub write a neutral .npmrc; if you keep an incumbent PM, Nub mirrors that PM faithfully.

One real difference from Bun: Nub follows pnpm's dependency model, which does not auto-install peer dependencies. Packages that worked under Bun because Bun silently installed their peers will surface as missing — add the peers explicitly to your package.json. This is the single most common post-migration install surprise.

Scripts: parallel and sequential

Bun exposes bun run --parallel and bun run --sequential for fanning out multiple scripts. Nub's nub run mirrors pnpm's script grammar, which is not the same shape:

  • A single script name runs that script: nub run build.
  • A /regex/ literal runs every script whose name matches, in package.json order: nub run "/^build:/". By default the matched set runs concurrently (--sequential serializes it). This is pnpm's regex script selector, and it is the native multi-script form Nub supports today.
  • Space-separated nub run a b is not a multi-script form — exactly as in pnpm, that runs script a with b as an argument. Do not expect nub run lint test to run two scripts.

There is no native run-p / run-s with an explicit list of named scripts (nub run --parallel lint test typecheck). If your Bun setup leaned on --parallel <a> <b> <c>, the portable replacement is npm-run-all2, which provides run-p and run-s and runs cleanly under Nub:

// package.json
{
  "scripts": {
    "check": "run-p lint typecheck test",   // parallel
    "release": "run-s build publish"         // sequential
  }
}
nub run check

If your script names share a prefix, the regex selector is the lighter option and needs no dependency: name them check:lint, check:typecheck, check:test and run nub run "/^check:/".

Environment files

Bun auto-loads .env, .env.local, .env.<mode> and accepts an explicit --env-file. Nub auto-discovers and loads your .env* files for the file runner, nub run, and nub watch, and accepts an explicit --env-file:

bun --env-file=.env.production src/index.ts
nub --env-file=.env.production src/index.ts

Behavior to know:

  • Passing any --env-file flag suppresses Nub's automatic .env* discovery — only the file(s) you name load, matching the "explicit means explicit" contract. This mirrors how an explicit file opts out of the auto cascade.
  • Nub expands ${VAR} cross-references in env-file values (the auto-load path and the explicit-flag path both expand). Plain Node's --env-file parser does not expand; Nub closes that gap.
  • The shell environment still wins over file values (a var already set in the environment is not overwritten).

Two gaps to plan around:

  • Nub does not currently implement --env-file-if-exists as its own flag. If you pass it, Nub does not treat it as an env-file directive — it forwards it to Node, where Node 20.12+/22+ honors it for the file it runs, but Nub's own .env-merge layer will not pick those variables up the way it does for --env-file. Prefer --env-file with files you know exist, or rely on auto-discovery (which already skips missing files).
  • Nub's --env-file applies to the run it is on; it is not a general nub exec-style env injector for arbitrary child processes. For loading an env file into a tool that is not the Nub-run entry (a Prisma CLI invocation, say), dotenv-cli remains the portable answer.

A note on Bun's --env-file "cascade"

It is sometimes assumed that Bun's --env-file=.env expands into a cascade — also loading .env.local, .env.development, and so on. It does not. In Bun, the explicit-files path and the default cascade are mutually exclusive: when one or more --env-file arguments are present, Bun loads only those named files (splitting on commas, last-wins) and skips the default .env/.env.local/.env.<mode> discovery entirely. The cascade is the default behavior you get when you pass no --env-file at all. This matches Node's single-file --env-file semantics and matches Nub's behavior, so there is nothing special to reproduce here — --env-file means "load exactly these files" in all three runtimes.

Shell commands: Bun.$

Bun's $ template tag runs shell commands with a chainable result API. There is no Node built-in for it. The common replacements:

// before (Bun)
import { $ } from "bun";
const out = await $`ls -la ${dir}`.text();
// after (Node) — tinyexec
import { x } from "tinyexec";
const { stdout } = await x("ls", ["-la", dir]);

tinyexec is a small, dependency-light process runner and the lightest drop-in for most $ call sites. If your code relies heavily on $'s chain methods (.text(), .quiet(), .nothrow()), a thin wrapper of ~100–150 lines over tinyexec (or execa, which is heavier but feature-complete) preserves the call shape so you do not have to rewrite every site.

Two things Bun.$ does that a plain process runner does not: it parses and runs an embedded mini-shell (pipes, redirects, globs in the template string), and it auto-escapes interpolated values. tinyexec and execa run a single program with an argument array — they do not interpret a pipeline. For piped commands, restructure them into explicit steps in JavaScript (e.g. read a directory with node:fs instead of ls | grep), which is also safer than handing user-controlled strings to a shell.

Database access: Bun.sql

Bun ships Bun.sql, a Postgres client modeled on postgres.js. It has no Node equivalent. Two clean replacements, depending on what you already have:

  • postgres (postgres.js) — a near-zero-friction swap, because Bun.sql is modeled on it. The tagged-template API is largely the same; the main change is constructing the client yourself and calling sql.end() at process shutdown.

    import postgres from "postgres";
    const sql = postgres(process.env.DATABASE_URL!);
    const rows = await sql`select * from users where id = ${id}`;
    await sql.end();
  • pg — if you already use Prisma (whose driver adapters pull in pg), reusing pg for raw queries avoids adding a second Postgres client:

    import { Pool } from "pg";
    const pool = new Pool({ connectionString: process.env.DATABASE_URL });
    const { rows } = await pool.query("select * from users where id = $1", [id]);

If you use Prisma, the migration is mostly about raw queries — your Prisma models are unaffected by the runtime swap. Pick one raw-query client and use it consistently.

The full Bun.* and bun:* API map

This is the comprehensive reference: every Bun-specific API, grouped by category, with the recommended Node or npm-ecosystem replacement. Three things to read off it. First, a large share of Bun's "APIs" are standard Web APIs (fetch, WebSocket, Request/Response, Blob, URL, crypto.subtle, structuredClone, streams) — those are present on Node and on Nub's runtime, so there is nothing to migrate. Second, anything that exists only to make TypeScript/JSX run (Bun.Transpiler) is unnecessary under Nub, which runs TypeScript directly. Third, the genuinely Bun-only batteries — the Bun.* server, database, FS, hashing, and shell helpers — each map to a maintained Node primitive or a well-established npm package, listed below. Pick one replacement per call site and keep it consistent.

Where a single package is named, it is a current, maintained option as of this writing; where the standard library covers it, the node:* module is the first choice.

Process, shell, and system

Bun APIRecommended replacementNotes
Bun.$ (shell)tinyexec or execaSee the shell section above. tinyexec is the lighter drop-in; execa is feature-complete. Neither interprets a pipeline — restructure pipes into explicit steps.
Bun.spawn, Bun.spawnSyncnode:child_process (spawn, spawnSync)Wrap to reproduce the web-stream result shape if your code reads .stdout as a ReadableStream.
Bun.which(cmd)whichOr resolve manually against process.env.PATH with node:fs.
Bun.openInEditor()open or launch-editorOpens a file in the user's editor; no Node built-in.
Bun.envprocess.envDirect rename.
Bun.argvprocess.argvDirect rename.
Bun.mainprocess.argv[1] (resolved) or import.meta.main (Node 24+)The entry module path.
Bun.version, Bun.revisionprocess.versions, process.versionNub runs Node, so report the Node version.

File system and globbing

Bun APIRecommended replacementNotes
Bun.file(path)node:fs / node:fs/promises (readFile, createReadStream)Bun.file returns a lazy Blob-like handle; the closest Node shape is fs.openAsBlob(path) (Node 20+) for a real Blob, else readFile.
Bun.write(path, data)node:fs/promises writeFileWrap if you depend on Bun.write's auto-mkdir of parent directories (mkdir(dirname, { recursive: true }) first).
Bun.stdin, Bun.stdout, Bun.stderrprocess.stdin / process.stdout / process.stderrNode's streams; wrap if you read them as web streams.
Bun.Globtinyglobby or fast-globnode:fs also has a built-in glob/globSync (Node 22+) for simpler patterns. tinyglobby is the lightweight current choice.
Bun.FileSystemRouterfind-my-way or a framework's routerFile-system-based route resolution; most frameworks (Next, Nitro, Hono with a file-router plugin) provide their own.
Bun.mmapmmap-ioMemory-mapped files; niche, native addon. Prefer streaming reads unless you specifically need mmap.

HTTP, networking, and real-time

Bun APIRecommended replacementNotes
Bun.serveHono (@hono/node-server), Fastify, or Expressnode:http is the raw primitive; a framework is the practical replacement. Hono is the closest in spirit (Web-standard Request/Response) and runs unchanged on Node.
Bun.serve WebSocket handlerwsThe standard Node WebSocket server. Pair with your HTTP framework's upgrade handling.
new WebSocket() (client)Built-in on the runtimeThe WebSocket client global is present on modern Node and on Nub's runtime — no dependency.
Bun.listen, Bun.connect (TCP)node:net (createServer, connect)Direct standard-library mapping.
Bun.udpSocketnode:dgramDirect standard-library mapping.
Bun.dns.lookup, Bun.dns.prefetchnode:dns / node:dns/promisesNo prefetch primitive in Node; the lookups map directly.
Bun.Cookie, Bun.CookieMapcookie (parse/serialize)Or your framework's cookie helpers.
Bun.CSRF.generate, Bun.CSRF.verifycsrf-csrf or csrfFramework-level CSRF middleware where available.
fetch, Request, Response, Headers, FormDataBuilt-in on the runtimeStandard Web APIs — present on Node and Nub; nothing to migrate.

Databases and storage

Bun APIRecommended replacementNotes
Bun.sql, Bun.SQL (Postgres)postgres (postgres.js) or pgSee the database section above. Bun.sql is modeled on postgres.js, so it is the near-drop-in; pg if you already pull it in via Prisma.
bun:sqlitenode:sqlite (Node 22.5+) or better-sqlite3node:sqlite is the standard-library synchronous SQLite (experimental but functional); better-sqlite3 is the mature, battle-tested choice with the closest API.
Bun.redis, Bun.RedisClientioredis or redisBoth are maintained, full-featured Redis/Valkey clients.
Bun.s3, Bun.S3Client@aws-sdk/client-s3The official AWS SDK; works with any S3-compatible store.
Bun.secretskeytar or the platform keychain via a small native addonOS credential-store access; keytar is the established cross-platform option.

Hashing, crypto, and compression

Bun APIRecommended replacementNotes
Bun.password.hash, Bun.password.verifynode:crypto scrypt or @node-rs/argon2Bun.password defaults to argon2id; @node-rs/argon2 matches that algorithm. node:crypto's scrypt is the zero-dependency standard-library path. bcrypt if you need bcrypt specifically.
Bun.hash, Bun.hash.* (non-crypto)xxhash-wasm or node:cryptoBun.hash is a fast non-cryptographic hash (wyhash family); xxhash-wasm is a close, fast equivalent. For a stable cryptographic digest use node:crypto.
Bun.CryptoHashernode:crypto createHash / createHmacDirect mapping for SHA/MD5/HMAC; streaming update/digest shape is the same.
Bun.shanode:crypto createHash('sha256')Convenience one-shot SHA.
Bun.gzipSync, Bun.gunzipSyncnode:zlib (gzipSync, gunzipSync)Direct standard-library mapping.
Bun.deflateSync, Bun.inflateSyncnode:zlib (deflateSync, inflateSync)Direct standard-library mapping.
Bun.zstdCompressSync, Bun.zstdDecompressSync (+ async)node:zlib zstd (Node 22.15+ / 23.8+)Node added zstd support recently; on older floors use @mongodb-js/zstd or fzstd. Check your pinned Node version.
crypto.subtle, crypto.randomUUID, crypto.getRandomValuesBuilt-in on the runtimeStandard Web Crypto — present on Node and Nub.

Build, transpile, and modules

Bun APIRecommended replacementNotes
Bun.TranspilerNot needed under NubNub runs TypeScript and JSX directly, so an in-process transpiler API is unnecessary. If you genuinely need to transpile a string at runtime, use oxc-transform or esbuild's transform.
Bun.buildesbuild, tsup, or rolldownFor application/library bundling. tsup wraps esbuild for the common library case; rolldown is the Rust-based Rollup-compatible bundler.
Bun.plugin (loader plugins)The bundler's plugin API (esbuild/rollup/rolldown plugins)Bun's runtime-loader plugin model has no Node equivalent; move custom loaders into your bundler or a module.register() hook.
Bun.resolveSyncimport.meta.resolve() or node:module createRequire(...).resolveimport.meta.resolve is the standard ESM resolver; createRequire().resolve for CJS-style resolution.
import.meta.dir, import.meta.fileimport.meta.dirname, import.meta.filenameNode ESM equivalents (Node 20.11+/21.2+). See the import.meta section above.

Testing

Bun APIRecommended replacementNotes
bun:testvitest or node:testVitest is the closest in ergonomics (describe/it/expect, watch mode, mocking) and runs under Nub. node:test + node:assert is the zero-dependency standard-library runner.
bun:test mocking (mock, spyOn)Vitest's vi.fn/vi.spyOn, or node:test's mockVitest's mocking is the nearest match to Bun's.
bun:test snapshotsVitest snapshotsBuilt into Vitest; node:test has no native snapshot support.

FFI and native

Bun APIRecommended replacementNotes
bun:ffi (dlopen, CString, …)koffiThe maintained, actively developed FFI library for Node (successor to the unmaintained ffi-napi).
Node-API (N-API addons)Node-APIWorks identically — N-API addons are a Node standard. Build with node-gyp or napi-rs for Rust.
bun:jsc (engine internals)No equivalentJavaScriptCore-specific; no V8/Node counterpart. Remove or replace with node:v8 / node:vm where a feature overlaps (heap snapshots, serialization).

HTML, text, and parsing

Bun APIRecommended replacementNotes
HTMLRewriternode-html-parser or cheerioFor streaming transformation specifically, html-rewriter-wasm ports Cloudflare's rewriter. Nub does not provide a built-in rewriter.
Bun.escapeHTMLescape-htmlTiny, single-purpose.
Bun.stringWidthstring-widthTerminal display width (handles wide chars, ANSI).
Bun.stripANSIstrip-ansiRemoves ANSI escape codes.
Bun.wrapAnsiwrap-ansiANSI-aware word wrap.
Bun.markdownmarked or markdown-itMarkdown-to-HTML.
Bun.colorculori or colorColor parsing/conversion across formats.
Bun.semversemverThe canonical semver library; Bun's API is a subset.
Bun.TOML.parsesmol-toml or @iarna/tomlTOML parsing.
Bun.YAML.parseyamlYAML parsing/stringifying.
Bun.ImagesharpImage decode/encode/transform.

Utilities, timing, and inspection

Bun APIRecommended replacementNotes
Bun.inspectnode:util inspectDirect mapping; same depth/colors options.
Bun.deepEquals, Bun.deepMatchnode:util isDeepStrictEqual, or dequalisDeepStrictEqual for strict equality; dequal for a fast loose-equality check.
Bun.sleep, Bun.sleepSyncnode:timers/promises setTimeout(ms)await setTimeout(ms) for async; for a true sync sleep use Atomics.wait on a SharedArrayBuffer (rarely needed).
Bun.nanosecondsprocess.hrtime.bigint()High-resolution monotonic clock.
Bun.randomUUIDv7uuid (v7)crypto.randomUUID() is built-in but emits v4; uuid's v7 for time-ordered IDs.
Bun.peekNo equivalentSynchronously reads a settled promise's value; restructure to await or track resolution yourself.
Bun.fileURLToPath, Bun.pathToFileURLnode:url (fileURLToPath, pathToFileURL)Direct standard-library mapping.
Bun.readableStreamTo* (Text, JSON, Blob, ArrayBuffer, Bytes, Array, FormData)Consume the web stream directlynew Response(stream).text()/.json()/.blob()/.arrayBuffer()/.formData() covers most; node:stream Readable.fromWeb to bridge to Node streams.
Bun.deflate/buffer sinks (Bun.ArrayBufferSink, Bun.allocUnsafe, Bun.concatArrayBuffers)Buffer (Buffer.allocUnsafe, Buffer.concat) and node:streamNode's Buffer covers the allocation/concatenation helpers; use a PassThrough stream for sink-style accumulation.
Bun.gc, Bun.generateHeapSnapshotnode:v8 (writeHeapSnapshot, --expose-gc for global.gc)Heap snapshot and manual GC; GC requires the --expose-gc flag.
Bun.stdin/stdout byte sinks, Bun.indexOfLinenode:buffer / node:readlineLine scanning via node:readline over the stream.
Bun.cron (scheduling)croner or node-cronIn-process cron scheduling. croner is the actively maintained, dependency-free choice.

A practical migration tactic for the high-frequency APIs (Bun.file, Bun.write, Bun.spawn, Bun.$): write one small shim module that re-exports Node primitives under the call shapes your code already uses, import it everywhere those APIs were used, and the diff stays localized to that one file instead of spreading across every call site.

import.meta and CJS globals

Bun and Node have converged here, so these are mechanical renames in ESM:

BunNode (ESM)
import.meta.dirimport.meta.dirname
import.meta.fileimport.meta.filename
import.meta.mainimport.meta.main (Node 24+) — otherwise compare process.argv[1] to the resolved module path.
__dirname (in ESM)import.meta.dirname
__filename (in ESM)import.meta.filename

Production server and Docker

If your server runtime targets Bun (for example a framework preset like Nitro's bun preset), switch it to its Node target (node-server / node) and run the built entry with node. The Docker base image changes from a Bun image to a Node image plus a global Nub install:

FROM node:26-slim
RUN npm install -g @nubjs/nub
# ... copy app, nub install, build ...
CMD ["node", ".output/server/index.mjs"]

Use node directly to run a built, already-transpiled server bundle; reach for Nub in the build stage (install, run scripts, run TypeScript entrypoints) where its augmentation earns its keep.

CI and Dependabot

  • CI setup: replace a Bun setup action with Nub's. See the setup-nub page for the GitHub Action.
  • Dependabot / Renovate: these bots do not have native Nub support. Use package-ecosystem: "npm" so the bot understands your manifest, and add a workflow step that runs nub install after the bot's update to regenerate the lockfile in Nub's format. A bot that bumps package.json but cannot regenerate the lockfile will otherwise leave the two out of sync.

Supply-chain cooldown (minimumReleaseAge)

Bun's bunfig.toml supports [install].minimumReleaseAge — a supply-chain hardening gate that refuses to install package versions newer than a threshold. Nub honors the same gate. As a Nub-identity project it lives in .npmrc as minimum-release-age, in minutes:

# .npmrc
minimum-release-age=1440   # one day

If you are migrating a bunfig.toml, note the unit change: bunfig expresses minimumReleaseAge in seconds, and nub install converts it to the engine's minutes (rounding up so a small non-zero gate never collapses to zero). Bun's minimumReleaseAgeExcludes maps to Nub's minimumReleaseAgeExclude.

Gotchas worth pre-empting

  • Peer dependencies are not auto-installed. The biggest behavioral difference from Bun. Add missing peers explicitly; expect more than one to surface.
  • nub run a b is not multi-script. It runs a with b as an argument (same as pnpm and Bun's plain run). Use a /regex/ selector or npm-run-all2 for real fan-out.
  • --env-file suppresses auto-discovery. Once you pass an explicit file, the automatic .env* cascade is off for that run — pass every file you need, or rely on auto-discovery instead.
  • --env-file-if-exists is not a first-class Nub flag yet. Use --env-file with files you know exist, or auto-discovery (which already tolerates missing files).
  • Pin a realistic Node version. Nub runs the Node you pin; a too-old floor fails at runtime on missing globals, not at install. Pin a current LTS or stable and set engines.node.
  • Memory profile differs from Bun. Node generally uses more memory than Bun for the same parallel fan-out. If you parallelized many heavy tasks (type-check, lint, test) under Bun and hit OOM kills after migrating, cap concurrency (for example, run at most two heavy tasks at once, and order the most memory-hungry last).
  • A benign module.register() deprecation warning may appear on newer Node from loader hooks; it is harmless and resolves as tooling adopts module.registerHooks(). Silence it with --no-warnings or a specific --disable-warning if it is noisy.