Run a TypeScript file directly — no tsconfig, no build step, no ts-node:

nub index.ts
nub server.tsx
nub script.js

On top of stock Node, nub <file> adds a modern runtime:

  • 🦆 Full TypeScript — non-erasable syntax (enum, namespace, parameter properties), emitDecoratorMetadata decorators
  • ⚛️ JSX / TSX — the automatic runtime by default
  • 🧭 Editor-style resolution — extensionless imports, .js → .ts rewriting, tsconfig.json#paths
  • 🆕 Modern syntax like using — transpiler-downleveled on older Node
  • 🔐 Automatic .env* loading — Next.js / Vite parity, with ${VAR} expansion
  • 🗂️ Data-file imports — .yaml, .toml, .jsonc, .json5, .txt as parsed default exports
  • 🌐 Modern globals backfilled per Node version — Temporal, URLPattern, WebSocket, EventSource, node:sqlite, and more
  • 🧵 Source maps in stack traces, automatically
  • ⚡ ~2.9× faster startup than tsx

Nub is a drop-in for node: same argv shape, same flag set, and every flag reaches Node verbatim. Pass --node (or set NODE_COMPAT=1) to turn every augmentation off and run on plain Node.

Supported Node versions

Nub runs your code on stock Node — it resolves which Node your project pins and fetches one if it's missing, but never ships a runtime of its own.

Supported versions and tiers

The hard floor is Node 18.19 — below it no loader-hook API can carry Nub's augmentations, so Nub refuses to run. Above the floor, augmentation is delivered through one of two tiers:

  • Fast tier — sync module.registerHooks(), run in-thread via a --require CJS preload. No loader-worker, lower startup overhead.
  • Compatibility tier — async module.register(), run in a loader-worker thread via an --import ESM preload. Same augmentations, a little more startup cost.

Nub selects the tier from your Node version. The fast tier needs sync module.registerHooks(), which Node added in v23.5.0 and backported to v22.15.0 — and never backported to the 20.x line. So the tier boundary lands differently per Node major:

Node lineMinimum Nub supportsFast-tier floorTierRecommended
18.x18.19Compatibility (no registerHooks)
20.xall 20.xCompatibility (registerHooks never backported to 20)
22.xall 22.x22.15Fast at 22.15+, compatibility on 22.0–22.14✓ at 22.15+
23.xall 23.x23.5Fast at 23.5+, compatibility on 23.0–23.4✓ at 23.5+
24.xall 24.x24.0Fast (whole line)
25.xall 25.x25.0Fast (whole line)
26.xall 26.x26.0Fast (whole line)default

Why a fast-tier Node is recommended

The compatibility tier routes module loading through an async module.register() loader-worker, which costs about 1.4× slower cold start than the fast tier — a fixed ~80 ms startup overhead plus ~90 µs per module. There's zero runtime cost: execution speed is identical, and the penalty is module loading only. The fast tier (22.15+, sync module.registerHooks()) has none of it.

Augmentations

  • TypeScript — every TypeScript feature runs directly, including non-erasable syntax (enum, namespace, parameter properties). Types are stripped, not checked.
  • JSX.jsx and .tsx files run with the modern automatic runtime by default, configured through tsconfig.json or a per-file @jsxImportSource pragma.
  • Decorators — legacy decorators run with no build step, including emitted design-type metadata for the DI / ORM ecosystem.
  • Resolution — extensionless imports, .js → .ts rewriting, and tsconfig.json path aliases resolve at runtime, the way tsc and your editor resolve them.
  • Environment variables.env* files load into the environment before Node starts, with ${VAR} expansion. No dotenv, no --env-file.
  • Varlock — a project with an @env-spec schema hands its environment to Varlock instead, with no wrapper command and no import in your source.
  • Loaders — import .yaml, .toml, .jsonc, .json5, and .txt files directly as parsed values, with no npm parser.
  • Modern APIs — modern globals and built-ins (Temporal, URLPattern, WebSocket, EventSource, node:sqlite, and more) work out of the box, polyfilled or unflagged per Node-version band.
  • Workers — the browser-shape Worker global runs your .ts entry points, wrapping node:worker_threads.
  • Web StoragelocalStorage / sessionStorage behavior and the opt-in that backs persistent storage.

Modern APIs

Modern globals — TC39, web-platform, and newer Node built-ins — work out of the box under Nub. Native implementations win; Nub fills a missing API or enables a version's experimental Node feature only where needed.

const now = Temporal.Now.plainDateTimeISO();
const route = new URLPattern({ pathname: "/u/:id" });
const { me, feed } = await Promise.allKeyed({ me: getMe(), feed: getFeed() });

See Modern APIs for the complete version-aware reference and examples.

Node version resolution

Like Corepack's shims, nub itself auto-installs Node versions as needed. When you run a file with nub, it infers the version of Node your project expects and provisions it — downloading and caching the matching stock build if it isn't already on your machine. The inference walks up from the working directory to the nearest pin, taking the first source that yields a version. Highest precedence first:

  • NODE_EXECUTABLE — an explicit path to a Node binary (a hard override)
  • package.json#/devEngines/runtime
  • .node-version
  • .nvmrc
  • .tool-versions — the asdf/mise file; its nodejs or node line
  • package.json#/engines/node (a range)
  • the node on your PATH, when nothing is pinned

This resolved Node version is auto-installed and cached for future runs.

$ echo 26 > .node-version
$ nub hello.ts
Using Node.js 26.7.0 (resolved from .node-version)
Installed in 9.8s
Hello world!

A few details of the walk:

  • Discovery walks up the directory tree to the nearest pin, and skips pin files that live inside an installed dependency (under node_modules) — a dependency's own CI pin never drives your project.
  • The package.json fields (devEngines.runtime, engines.node) are read from the workspace root manifest when one exists above you — a monorepo pins its Node once at the root.
  • Once a version is pinned, Nub finds a binary for it in order: the node on PATH (so fnm / Volta / mise auto-switching just works) → Nub's own download store (~/.cache/nub/node/<version>/) → an nvm-installed version → download the matching stock build from nodejs.org, SHA-256 verified and cached.

For pinning, pre-installing, and the nub node subcommands, see Managing Node versions.

A drop-in for node

Anything node <args> accepts, nub <args> accepts too — every flag reaches Node verbatim:

# diagnostics
--prof  --cpu-prof  --report-*
# module resolution
--conditions  --preserve-symlinks
# inspector
--inspect  --inspect-brk
# warnings
--no-warnings  --trace-deprecation

Reading a program from stdin works the same way node - does.

nub --max-old-space-size=4096 build.ts
nub --import ./instrument.js server.ts
# everything after the file is your script's argv
nub script.ts --port 3000 --verbose
echo 'console.log(1 + 1)' | nub -

Debugging in VS Code

Point VS Code's debugger at the nub binary to run a program through Nub with breakpoints and stepping. See Debugging.

Project configuration

Settings you want on every run go in a nub.jsonc at the project root rather than on the command line.

nub.jsonc
{
  "preload": ["./instrumentation.ts", "tsx/esm"], // loaded before the entry file
  "nodeOptions": ["--enable-source-maps"],        // Node options for every run
  "v8Flags": ["--expose-gc"]                      // V8 flags, on Node's argv
}

A preload entry is either a path, resolved from the directory holding the file that supplied it, or a bare package specifier resolved the way an import in your project would be. V8 flags reach Node on its command line rather than through NODE_OPTIONS, so the full V8 set works — including the flags NODE_OPTIONS rejects, such as --stack-size.

A command-line flag always wins over the file. See nub.jsonc for the full precedence order.

Compatibility mode

Nub augments by default. Compatibility mode turns every augmentation off — no load hook, no preload, no unflagging, no .env loading — and runs your code on plain Node, exactly as node <file> would. It still runs the project's pinned Node; version provisioning stays on. (This is distinct from the compatibility tier — the startup path for the Node lines without sync registerHooks, in the table above — which is about how augmentation is delivered, not whether it runs.)

There are three spellings: a per-invocation flag, a tree-wide env var, and a project field. They compose — any one of them forces compatibility mode.

--node

Pass --node to run a single invocation with zero augmentation. Your code runs on the project's pinned Node (from .node-version / .nvmrc, fetched if missing), vanilla — which makes it the tool for differential debugging: does this reproduce on plain Node, on the exact version this project pins? A bare node script.js can't answer that, because it runs your shell's Node, not the project's.

nub --node script.js     # the project's pinned Node, vanilla
node script.js           # your shell's Node, unaugmented, unprovisioned

NODE_COMPAT

Setting a truthy NODE_COMPAT (1, true, or yes, case-insensitive) is the project/tree-wide form of --node. It has the identical effect — zero augmentation, Node-version provisioning still on — but it's persistent and inherited by every descendant node / nub in the tree, so you don't repeat --node per invocation:

export NODE_COMPAT=1     # every nub/node in this shell now runs vanilla
nub script.ts            # plain Node, still on the project's pinned version

nodeCompat

Setting nodeCompat in a nub.jsonc is the project-wide form: everyone working in the repository gets plain Node without setting anything in their shell.

nub.jsonc
{
  // ...
  "nodeCompat": true  // plain Node, version pinning kept
}

None of the other nub.jsonc runtime fields apply once it is set.

How it works

Every augmentation rides on Node's own extension points — Nub patches nothing and ships no runtime of its own. It registers a module.registerHooks() load hook (an async module.register() loader-worker on the compatibility tier) for transpilation and resolution, and injects a small --import preload for the globals and .env loading. On the fast tier, transpilation runs through an embedded oxc N-API addon.

Read the full docs for module customization hooks on nodejs.org.