Node.js Packages

Updated

September 12, 2026

Node.js Packages

Node is the desk frontend. The boring default is: buildNpmPackage + npmDepsHash from package-lock.json, or pnpm.fetchDeps when the lockfile is pnpm — not npm install in buildPhase.

Go moved to its own chapters. This page is Node only.

Mental model

Same two-step FOD as Go:

package-lock.json  →  npmDepsHash FOD  →  npm ci --offline  →  $out
Lock Builder
package-lock.json buildNpmPackage
pnpm-lock.yaml pnpm.fetchDeps + pnpm hooks (26.05)
Yarn yarn-specific fetchers; do not mix

Commit the lockfile. npm install without a lock is not a pin. npm install in buildPhase hits the sandbox (EAI_AGAIN).

Worked examples

Case 1: buildNpmPackage

Save as node_app.nix:

# node_app.nix
{ buildNpmPackage, lib }:

buildNpmPackage {
  pname = "desk-web";
  version = "1.0.0";
  src = lib.cleanSource ./.;
  npmDepsHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
  installPhase = ''
    runHook preInstall
    mkdir -p $out/dist
    cp -r dist/* $out/dist/
    runHook postInstall
  '';
}
nix-build -E 'with import <nixpkgs> {}; callPackage ./node_app.nix {}'

First run prints got: sha256-…. Paste it into npmDepsHash. npm ci uses the lock, not the latest registry. Never leave lib.fakeHash on main.

Case 2: A CLI with a bin

If package.json has "bin": { "desk-web": "bin/cli.js" }, buildNpmPackage often wraps it:

ls result/bin
./result/bin/desk-web --help

If you only ship static dist/ for nginx, you do not want nodejs in the runtime closure. Check:

nix path-info -Shr ./result | grep nodejs || echo 'node not in runtime (good for static dist)'

Case 3: pnpm

Save as pnpm.nix:

# pnpm.nix
{ lib, stdenv, pnpm, nodejs }:

stdenv.mkDerivation (finalAttrs: {
  pname = "desk-web";
  version = "1.0.0";
  src = lib.cleanSource ./.;
  pnpmDeps = pnpm.fetchDeps {
    inherit (finalAttrs) pname version src;
    hash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
    fetcherVersion = 2;
  };
  nativeBuildInputs = [ nodejs pnpm.configHook ];
  buildPhase = ''
    pnpm run build
  '';
  installPhase = ''
    mkdir -p $out
    cp -r dist $out/
  '';
})

fetcherVersion and option names move; the 26.05 nixpkgs doc for pnpm.fetchDeps wins if this snippet drifts. Do not mix npm lock + pnpm fetcher.

Case 4: Node in a devShell, not in the output

Save as flake.nix:

# flake.nix
{
  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
  outputs = { self, nixpkgs }:
    let
      pkgs = nixpkgs.legacyPackages.x86_64-linux;
    in
    {
      packages.x86_64-linux.desk-web = pkgs.callPackage ./node_app.nix { };
      devShells.x86_64-linux.default = pkgs.mkShell {
        packages = [ pkgs.nodejs pkgs.pnpm ];
      };
    };
}
nix develop --command node --version

CI builds with Case 1. Humans pnpm test inside nix develop. Do not npm install -g on the laptop.

Case 5: Native addons

node-gyp needs python3, gcc, headers:

# addon.nix fragment
{
  nativeBuildInputs = [ pkgs.python3 pkgs.pkg-config pkgs.nodejs ];
  buildInputs = [ pkgs.openssl ];
}

If the addon can be skipped (optionalDependencies), prefer a pure-JS path for the desk image. Native addons are a CGO-shaped exception — document gcc in the package comment.

# node_app.nix fragment
{
  npmBuildScript = "build"; # package.json script name; default is often "build"
  makeCacheWritable = true; # some postinstall scripts write into node_modules
  forceGitDeps = true;      # only if the lock has git: URLs
}

NODE_ENV=production in installPhase is how you drop devDependencies from the runtime tree. A static dist/ served by nginx should not contain node_modules at all — cp -r dist only. nodejs in nativeBuildInputs (build) vs not in the runtime closure is the same split as gcc vs glibc.

The trap

The trap is npm install in buildPhase. Sandbox: EAI_AGAIN / network disabled. Hash the lockfile FOD.

The other trap is no lockfile (package-lock.json gitignored). Then npmDepsHash is a lie next week.

The boring rule

  • Lockfile in git. npmDepsHash / pnpm.fetchDeps hash.
  • buildNpmPackage for npm. pnpm fetcher for pnpm. Do not mix.
  • Probe hashes; never leave fakeHash on main.
  • Toolchain in the devShell; artifacts from the derivation.
  • Native addons are a CGO-shaped exception — document gcc.
  • Static dist/ should not keep nodejs in nix path-info -Shr. npmBuildScript / makeCacheWritable only when the log says you need them.

Try this

  1. Tiny package.json + lock; fakeHash; paste got.
  2. Change a dep in package.json without npm install; watch the FOD mismatch or npm ci fail.
  3. nix path-info -Shr result on a built dist; confirm nodejs is or is not in the runtime closure (static dist/ should not need node if nginx serves files).
  4. git check-ignore -v package-lock.json — it must not be ignored.