Best practice for NodeJS development

I’m looking for the “currently ideal” flake setup for a NodeJS project (currently attempting vuepress project).

I currently use a fake that basically just sets up NodeJS, and then leaves the rest up to the developer in the devShell, meaning he mostly has to make sure that npm i is run at the appropriate times.

My issue with this is that this requires a certain amount of NodeJS knowlegde, while actual “development” is just updating markdown files. I’d prefer anyone be able to do this, regardless of main tech stack.

To that end, I’ve looked into node2nix and some other similar tools. The latest commit to node2nix is about 2 years old, and it plainly fails to create a devShell because it can’t parse current lock files. So this seems mostly broken. Also, its usage is known to cause problems like slow evaluation of nix expressions due to the large amount of code it generates.

Therefore, it seems to me that there is no “truly clean” way to abstract away the tech stack details and only use nix abstractions. Is that right? How do you handle NodeJS development?

I’ll post my flake below. For now, it only really defines a 85% workable devShell (auto-installing deps if no node_modules is found, which doesn’t handle dependency updates) (the package is untested)

{
  description = "Flake containing VuePress infrastructure";

  # Inputs
  inputs = {
    # Use the NixOS 24.05 branch
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";

    # flake-utils for cross-platform support
    flake-utils.url = "github:numtide/flake-utils";
  };

  # Outputs
  outputs = {
    self,
    nixpkgs,
    flake-utils,
  }:
    flake-utils.lib.eachDefaultSystem (
      system: let
        pkgs = import nixpkgs {inherit system;}; # Import nixpkgs for the current system
      in {
        # Define the development shell
        devShells = {
          default = pkgs.mkShell {
            # Install Node.js (default version from nixpkgs 24.05)
            buildInputs = [
              pkgs.nodejs # Default Node.js package (from NixOS 24.05)
            ];

            # Automatically run npm install when entering the shell
            shellHook = ''
              if [ ! -d "node_modules" ]; then
                echo "Installing npm dependencies..."
                npm install
              fi
              echo "VuePress development environment is ready."
              echo -e "\e[1;32mTo start the dev server, run: npm run docs:dev\e[0m"
            '';
          };
        };

        # Define a package for building the VuePress site
        packages = {
          default = pkgs.stdenv.mkDerivation {
            pname = "mydocs";
            version = "0.0.1";

            # src = ./.;

            # Build VuePress using npm
            buildPhase = ''
              npm install
              npm run build
            '';

            installPhase = ''
              mkdir -p $out
              cp -r .vuepress/dist/* $out/
            '';

            meta = with pkgs.lib; {
              description = "VuePress documentation static site";
              license = licenses.mit;
              platforms = platforms.all;
            };
          };
        };
      }
    );
}

I’ve haven’t tried importNpmLock and importNpmLock.hooks.linkNodeModulesHook yet (they were added only “recently”), but they look promising for local development.
See Nixpkgs Reference Manual for usage guide.

Though, if it doesn’t work, for whatever reason (e.g. a package does not like being in the nix store), I’d just go with using imperative methods for development work, and buildNpmPackage with npmDepsHash for actual packaging.
(don’t forget to update npmDepsHash when your npm change)

I’m not sure what’s the best method, maybe others know better

You are right, this works beautifully! If I understand it right, it allows defining a devShell that creates a node_modules directory inside of which the actual dependencies are linked to the store. This means that npm should still work to add new dependencies (not sure about updating one), while the devShell automatically installs anything that is not already present. Should be at least 95% of where I want to be and possibly as close as is possible. Here’s my updated flake (again, the packages aren’t testet, yet)

{
  description = "Flake containing VuePress infrastructure";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
    flake-utils.url = "github:numtide/flake-utils";
  };

  outputs = {
    self,
    nixpkgs,
    flake-utils,
  }:
    flake-utils.lib.eachDefaultSystem (
      system: let
        pkgs = import nixpkgs {inherit system;};
      in {
        devShells = with pkgs; {
          default = mkShell {
            buildInputs = [
              nodejs
              importNpmLock.hooks.linkNodeModulesHook
            ];

            npmDeps = importNpmLock.buildNodeModules {
              npmRoot = ./.;
              inherit nodejs;
            };

            shellHook = ''
              echo "VuePress development environment is ready."
              echo -e "\e[1;32mTo start the dev server, run: npm run docs:dev\e[0m"
            '';
          };
        };

        packages = with pkgs; {
          default = buildNpmPackage {
            pname = "mydocs";
            version = "0.0.1";
            src = ./.;

            npmDeps = importNpmLock {
              npmRoot = ./.;
            };

            npmConfigHook = importNpmLock.npmConfigHook;
          };
        };
      }
    );
}

— Edit

When it works, it works great. But it seems to be very flaky. For an unknown reason, when I manually delete the node_modules and then re-enter the devShell, it won’t be re-linked and I can’t for the life of me figure out how to do it again

— Edit 2

I observed more stuff. Firstly, let’s describe my directory layout.

-- project-root
  |-- .git
  |-- vuepress-project
    |-- flake.nix
    |-- package{,-lock}.json
    |-- ...
  • Okay, so first, deleting node_modules and entering devShell again does nothing.
  • When explicitly running nix develop --configure inside vuepress-project, the shell hook is run, but in the parent directory (git root). So I now have project-root/node_modules (which is useless)
  • When I explicitly put linkNodeModulesHook as a command into the devShell’s shellHook, everything works fine. But I think this shouldn’t be necessary according to the documentation

I’ve been trying to figure out a good approach for some front-end stuff, so thought I would post my findings here. I’ve found js2nix and dream2nix as some additional options:

js2nix seemed promising, adding the JavaScript dependencies to the nix store, but there’s not many public projects using it. I did find GitHub - olebedev/prettier.nix, but I couldn’t see much in the way of documentation regarding the the workflow is with generating and managing the package.json and yarn.lock files. I’m assuming you’d need to use yarn for that (the prettier.nix project adds that to the dev shell).

Not sure if that’s of any use for you, just thought I’d share seeing as it’s something currently on my mind!

  • When I explicitly put linkNodeModulesHook as a command into the devShell’s shellHook, everything works fine. But I think this shouldn’t be necessary according to the documentation

That logic is a bit screwy and should be changed, or at the very least documented. Currently the hook only works automatically if no shellHook is defined, and needs to be manually called if you’ve defined one.
That behaviour was accidentally inherited from another hook in the Python ecosystem which I used as a template.

1 Like