Build a ClojureScript Application Running on Node Using Nix
To build a ClojureScript application, we need to pull dependencies from two package managers. That is, JavaScript packages from npm and Clojure packages from deps.edn. After pulling the dependencies, we can then let shadow-cljs compiles the ClojureScript files into a single javascript and make the result a npm package. In this post, we rely on nixpkgs's builtin fetchNpmDeps and buildNpmPackage for the npm side, and clj-nix for the nix side.
Note
- changelog Jan 17, 2026: use a new method to build the nix package, supports git repos, reduces output size.
Prerequisites
I will skip the details that are not unique to Nix. The following contents assume we already have a working shadow-cljs project that can produce a node.js target when running npm run release. Which is basically:
- a
:node-scripttarget inshadow-cljs.edn, with:mainfunction specified - a
releasescript in package.json, most probablyshadow-cljs release script - an entry script specified in
binin package.json.
Prefetch and generate lock file for dependencies
During the build phase of nix package, the process cannot perform any network request, as that will defeat the whole point of determinism. The common practice is to use a lock file. Which is essentially a recipe of all the necessary resources (and their checksums) for the building process. We can then prefetch all the resources into the nix store and tell the building process where to find the necessary resources during the actual building.
On the npm side, we do it by running the following command. It will output a sha256 code, we save it for later.
nix run nixpkgs#prefetch-npm-deps package-lock.json
On the Clojure side, we execute the following command. It will generate a deps-lock.json file. We will refer it in the flake.nix.
nix run github:jlesquembre/clj-nix#deps-lock
Build with shadow-cljs and mkCljLib
We need to add clj-nix's overlays to our flake.nix to be able to use mkCljLib.
pkgs = import nixpkgs {
inherit system;
overlays = [
clj-nix.overlays.default
];
};
First, we create a node_modules derivative which we will use later.
deps-hash = "deps-hash";
npm-deps = pkgs.buildNpmPackage(finalAttrs: {
src = self;
pname = "package-npm-deps";
version = "0.0";
dontNpmBuild = true;
npmDepsHash = deps-hash;
installPhase = ''
mkdir $out
cp -r ./node_modules $out/node_modules
'';
});
We will use mkCljLib to setup Clojure dependencies for us, while we copy JavaScript dependencies from the earlier step manually.
build = pkgs.mkCljLib {
projectSrc = self;
name = "espoir";
buildCommand = "
# copy node_modules from earlier step to build directory
cp -r ${npm-deps}/node_modules ./node_modules
${pkgs.nodejs}/bin/npm run release
";
installPhase = ''
mkdir -p $out
cp -R * $out/
'';
};
In the buildCommand, we setup the JavaScript dependencies, and call npm run release which in turn calls shadow-cljs to build the project.
In installPhase, we override the install process provided by mkCljLib. Instead, we simply use all the files in the build directory as output.
Package results as a npm package
We can directly feed output from the earlier example to builNpmPackage. Since we already build the package, we will set dontNpmBuild = true;.
The install process provided by buildNpmPackage will package necessary dependencies and generate an executable calling the entry script.
packages.default = pkgs.buildNpmPackage {
pname = "espoir";
src = build;
dontNpmBuild = true;
version = "0.0.1";
npmDepsHash = deps-hash;
};
Tips
- Check imakira/espoir/blob/main/flake.nix for a working example.