# nbb: ClojureScript scripting on Node.js without a build step

> nbb is a Node.js runtime for ad hoc ClojureScript that evaluates code with SCI, the same interpreter that powers babashka, so a script runs straight from a file with no compile step and no custom Node build. The price is an interpreter, a subset of ClojureScript, and slower execution than compiled output.

**babashka/nbb** — Scripting in Clojure on Node.js using SCI

- Repository: https://github.com/babashka/nbb
- Stars: 970 · Forks: 56
- Language: Clojure
- License: EPL-1.0
- Published: 2026-10-20 · Updated: 2026-10-20 · Language: en
- Canonical page: https://hysenlabs.com/projects/babashka-nbb

## A script runner for people who already know Clojure

nbb fills a narrow gap: running a ClojureScript expression on a normal Node.js installation without configuring shadow-cljs, without a bundler, and without the custom Node build that some other Clojure-on-Node approaches need. The README summarises the goal as making it easy to get started with ad hoc CLJS scripting on Node.js, and the secondary goals are fast startup without a custom Node version, a small artifact of around 1.2MB, first class macros, support for building small TUI apps with Reagent, and complementing babashka with libraries from the Node ecosystem.

The name is the pitch. The README opens with 'Not babashka. Node.js babashka!', and that comparison is the quickest way to place the tool. babashka is a Clojure interpreter with its own set of built-in libraries; nbb is the same idea expressed on top of Node, where the library you reach for is an npm package rather than a JVM jar. The package metadata describes it as a babashka-like tool for Node.js, and the repository layout shows how it is assembled: index.mjs as the entry point, cli.js as the nbb executable, nbbun.js as a second binary, a src directory, a build directory, shadow-cljs.edn for the build configuration, a bb.edn and deps.edn for the Clojure side of the build, plus ext, externs, interop.cljs, script and test directories.

The audience is a developer with a JavaScript-shaped problem and Clojure-shaped instincts, or a Clojure developer who needs to touch a Node service. The two examples directories of the repository, one holding sample scripts and one holding the example folders the README links to, span aws, axios, chalk, cheerio, express, fastify, glob, got, ink, jimp, better-sqlite3 and a puppeteer example, which is a good indication of the intended surface area.

The runtime requirement is modest: Node.js v14 or newer.

## Why SCI changes what your script can be

The mechanism is stated plainly in the README: CLJS code is evaluated through SCI, the same interpreter that powers babashka. Nothing is compiled ahead of time, so a file on disk is a program you can run immediately.

The interesting part is why an interpreter is the right trade here. Because SCI works with advanced compilation, the resulting bundle is smaller than what self-hosted CLJS produces, especially once other dependencies are added, and a smaller bundle means a faster start. The README gives a baseline of about 170ms for a script on the author's laptop, measured with the time command on the simplest possible expression, and notes that invoking it through npx adds another 300ms or so.

The costs are equally explicit. Execution is less performant than compiled ClojureScript, and only a subset of CLJS is available. The example given is deftype, which does not exist. That is not a small gap: no deftype means no records, no protocols built on it, no reify, and therefore no cljs.spec-style tooling that depends on those constructs. The subset boundary is the defining constraint of nbb, and any code ported from a real ClojureScript project has to be checked against it.

The startup numbers also point at a practical detail. The README suggests using a globally installed nbb, or invoking the binary directly as $(npm bin)/nbb script.cljs, to bypass the npx layer when startup time matters.

## Installing nbb and running a first expression

The install is a global npm package:

```bash
npm install nbb -g
```

Omit the -g flag for a local install. There is also a zero-install path, since the README suggests running npx nbb to run nbb on your own machine, and a browser route through a Replit project for trying the tool without any local setup at all.

The first thing to run is an expression, which is what the -e flag is for:

```bash
nbb -e '(+ 1 2 3)'
```

That prints 6 and exits. A script file works the same way:

```bash
nbb script.cljs
```

The package version in the repository's package.json is 1.6.214, and the package publishes as an ES module with type set to module. It ships two binaries, nbb pointing at cli.js and nbbun pointing at nbbun.js, and there is a deno.json in the repository root, so the same tooling is also arranged for a Deno context.

One requirement is easy to miss. The README states that nbb needs babashka installed, in the form of the bb executable on the system PATH, in the case of downloading Clojure dependencies. So a plain script with only npm libraries needs nothing but Node, but the moment you want to pull in a Clojure library from Maven, babashka becomes a prerequisite on the same machine.

## npm interop, dynamic import and the $default convention

Npm libraries are the point of the tool, and the interop rules are worth understanding because they differ from what a ClojureScript compiler would do.

Nbb implements :require via dynamic import, the import() call in JavaScript. That has two visible consequences. The first is that npm packages are resolved relative to the script that requires them, not relative to some global location, and React in a Reagent project is resolved the same way as any other npm library. The second is the $default suffix.

The README explains it directly: the :default foo syntax is shadow-cljs only, and nbb does not support it either, while the newer $default syntax works in vanilla CLJS and in shadow-cljs, which is why nbb supports it. Because the interop goes through dynamic import, you have to write $default on the import when you want the default export of a module. A namespace that has no default export is imported as a plain namespace.

A complete script therefore looks like this, with the require form split from the body:

```clojure
(ns example
  (:require ["csv-parse/sync" :as csv]
            ["fs" :as fs]
            ["path" :as path]
            ["shelljs$default" :as sh]
            ["term-size$default" :as term-size]
            ["zx" :refer [$]]
            ["zx$fs" :as zxfs]
            [nbb.core :refer [*file*]]))
```

```clojure
(prn (path/resolve "."))

(prn (term-size))

(println (count (str (fs/readFileSync *file*))))

(prn (sh/ls "."))

(prn (csv/parse "foo,bar"))

(prn (zxfs/existsSync *file*))

($ #js ["ls"])
```

The nbb.core namespace is worth noticing: *file* is the path of the running script, which is how the example can read its own file without hardcoding a path. The library set behind this example comes from a package.json that pulls in csv-parse, shelljs, term-size and zx, which is the standard shape of an nbb script: npm dependencies in package.json, Clojure code in a .cljs file.

## Macros, plet and writing async code without callback soup

nbb supports macros as a first class feature, and you define them inside the .cljs file the way you would on the JVM. That claim is easy to dismiss until you remember what a ClojureScript compiler would otherwise require, a separate macro compilation step, which an ad hoc script does not have.

The README's worked example is a macro named plet, defined in the script itself:

```clojure
(defmacro plet
  [bindings & body]
  (let [binding-pairs (reverse (partition 2 bindings))
        body (cons 'do body)]
    (reduce (fn [body [sym expr]]
              (let [expr (list '.resolve 'js/Promise expr)]
                (list '.then expr (list 'clojure.core/fn (vector sym)
                                        body))))
            body
            binding-pairs)))
```

The macro rewrites a binding list into a chain of .then calls on a promise, so the code that follows reads synchronously:

```clojure
(plet [browser (.launch puppeteer)
       page (.newPage browser)
       _ (.goto page "https://clojure.org")
       _ (-> (.screenshot page #js{:path "screenshot.png"})
             (.catch #(js/console.log %)))]
      (.close browser))
```

The point of the transformation is that the same script written with .then and .catch chains nests deeper and further right with every step, which is exactly the shape of code that becomes hard to edit in a browser console or a small automation script.

Since version v0.0.36, nbb also includes promesa, a library for dealing with promises, and the README notes that the plet macro is similar to promesa.core/let. The full puppeteer example is kept in the examples directory of the repository, and a promise-returning browser automation script is a realistic nbb workload given that the tool is aimed at short automation tasks rather than long-running services.

## Clojure dependencies through nbb.edn and the classpath flag

The Clojure side of the ecosystem is reachable, but it goes through babashka. The README explains that this feature relies on the presence of the bb executable in the system PATH, and the dependency declaration lives in an nbb.edn file in the project, written in the usual deps.edn style:

```clojure
{:deps {com.github.seancorfield/honeysql {:mvn/version "2.2.868"}}}
```

As with node_modules, nbb unpacks these dependencies into an.nbb directory and loads them from there. nbb unpacks these dependencies into an.nbb directory and loads them from there. That is a sound division of labour, and it also means a machine without babashka cannot use the feature at all.

Local.cljs files are handled separately, through the --classpath argument. The current directory is added to the classpath automatically, so a file at foo/bar.cljs relative to the current directory can be required as (:require [foo.bar:as fb]). The README also documents the naming convention that the tool shares with other Clojure tools, where a hyphen in a namespace name becomes an underscore in the directory name, so foo-bar in the namespace becomes foo_bar on disk.

Local.cljs loading is the one part of the dependency story that needs no external tool, and for a script that keeps its helpers next to itself, that is often the only dependency mechanism required. The API documentation in the doc directory lists the built-in Clojure libraries available to a script, which is the reference to check before assuming a namespace is present.

## Licence, versioning and the honest limits of an interpreter

The package is licensed EPL-1.0, and the repository carries the same licence in its LICENSE file. EPL-1.0 is a weak copyleft licence with a file-level exception, which in practice means modifying nbb itself can be done without publishing the whole of your application, but a fork has to keep the modified files under the licence and publish them. The description here covers the published terms rather than legal advice.

On versioning, the repository has no GitHub releases, so the version in package.json, 1.6.214, and the CHANGELOG.md file are the sources of truth. The last push was on 2026-09-25, and the README points at GitHub Issues for bugs, GitHub Discussions for questions and a channel on Clojurians Slack for general discussion.

The limits are worth stating in one place, because they decide the tool. Execution is slower than compiled ClojureScript, so a script doing heavy computation will feel it, and the README says so without hedging. Only a subset of the language is available, with deftype named as the example, which rules out records, protocols and reify. The Clojure dependency feature needs babashka installed separately. Startup is fast for an interpreter but not instant, at about 170ms for a baseline script and roughly 300ms more when npx is in the path.

Set against that, the wins are concrete: a 1.2MB artifact, no custom Node build, no compile step between editing a file and running it, npm interop with a $default convention that is documented, and macros that can be defined in the same file they are used from. For a script you will write once and run once, that is the right shape of tool.

## Conclusion

nbb is a good fit for throwaway scripts, REPL work and glue code where you want Clojure syntax and npm libraries on Node without setting up a ClojureScript build. It is a poor fit for hot loops, for code that needs deftype or other compile-time-only ClojureScript features, and for teams that cannot install the babashka bb executable that the Clojure dependency feature depends on. Before committing to it, measure the startup and execution cost against your own scripts with the time command, since the README puts baseline startup at about 170ms and npx adds roughly 300ms on top.

## FAQ

### What is nbb and how is it different from babashka?

nbb is a babashka-like tool for Node.js that evaluates ClojureScript with SCI, the same interpreter that powers babashka. babashka is a separate interpreter, while nbb runs on a stock Node.js installation and reaches npm libraries rather than JVM jars.

### How do I install nbb and run a ClojureScript expression?

Install it with npm install nbb -g, or omit -g for a local install, or run it without installing through npx nbb. Then pass an expression with nbb -e '(+ 1 2 3)' or a script file with nbb script.cljs. Node.js v14 or newer is required.

### Why do I need the $default suffix when requiring an npm library in nbb?

Nbb implements :require through dynamic import, the import() call in JavaScript, so the default object of a module has to be requested explicitly with $default. The :default foo syntax is shadow-cljs only and nbb does not support it.

### Can nbb load Clojure libraries from Maven, and what does it need?

Yes. You declare the dependencies in an nbb.edn file and nbb unpacks them into an .nbb directory. The feature relies on the babashka bb executable being present in the system PATH.

### What can nbb not do that compiled ClojureScript can?

Only a subset of ClojureScript is available, with deftype given as the example, so records, protocols and reify are out. Execution is also less performant than compiled ClojureScript, and a baseline script takes about 170ms to start.

## Sources

- [babashka/nbb on GitHub](https://github.com/babashka/nbb)
- [Issues](https://github.com/babashka/nbb/issues)
- [License: EPL-1.0](https://github.com/babashka/nbb/blob/main/LICENSE)
- [README](https://github.com/babashka/nbb/blob/main/README.md)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/babashka-nbb
