Incredibly easy NPM only build step

In nowadays it is impossible to write frontend code without a build tool. We work with typescript and sass, bundle and optimise our code. Gulp was my choice for the last 3 years. But lately I have been growing frustrated with the overhead it brings. Doing what you want can be very hard, especially if what you need does not exist on a ready made recipe. And if you try to write your own plugins streams and buffers can scare you off.

For my open source javascript packages I always used node modules to build the code. This made me wonder, could I do the same for my website? It turns out, you can, and others had this idea as well.

Advantages of npm as a build tool

The biggest advantage for me is that you have one less dependency chain. You don’t need to download and learn a build tool. With npm, which you already use, you use simple cli commands or node scripts.

Using modules in the cli instead of via the build tool wrapper removes a layer of complications. You don’t need to wait for somebody to update or fix the wrapper. If the module can do it, so can you. Compared to the main module, a wrapper for a specific build tool is more likely to be unmaintained.

Most scripts start out as node scripts and afterwards might be adapted for your build tool. Using node assures you the best choice of tools and quickest update and fix rates.

The downsides of npm

Npm scripts are not quite as plug and play as gulp or grunt. Tutorial for the typical use cases make the entrance into a build tool easy. Only when you have more advanced requirements, will it become a hassle.
As of now there are far viewer tutorials and recipes on npm build steps compared to other build tools.

Some people are saying npm scripts are slower, because gulp uses streams. However as Cory House suggest the command line always had streaming.

  • The pipe (|) streams one commands output to the next commands input

And to be honest, on any normal projects, a couple milliseconds don’t matter all that much.

Let’s build it

All our work will be within package.json. We will accomplish the following:

  • running a node server that watches for file changes

Running a node server

I am currently using supervisor for development because it is dead simple. You could easily replace it with ts-node, forever, nodemon or any other server.

With the package installed we can add the script to our package.json. The -w flag defines the directories app,resources/templates that should be watches. In my case any changes to my node server files or my handlebars templates will trigger a server restart. The --extensions flag allows you to specify which file types should be watched. The last argument is the main app file that should be run by the server.

// package.json"scripts": {
"supervisor": "supervisor -w app,resources/templates --extensions node,js,hbs app.js"

Building our css

We want to compile Sass into css, remove old compile files and revision the new files. If we change our Sass files we want it to be recompiled. We will split the steps into individual scripts and compose them together. This makes it easier to read and reason about.

Compiling Sass files

All we need to install is the node-sass package. In my case the app.scss file that loads all other files is in resources/css/. The converted app.css file is saved to public/css/ as well as the source map. Check out the github project page to understand all flags and options.


"scripts": {
"css:compile": "node-sass --source-map public/css/ --output-style compressed -o public/css/ resources/css/app.scss"

Removing old files

While it is possible to check which files changed and only update those, I opted for the simple option. With the speed at which Sass compiles there is no reason to invest any time in micro optimisations. This means we can remove all css files with a simple `rm`.

// package.json"scripts": {
"css:clean": "rm -f public/css/*"

Revisioning files

I actually did not find any module that does revisioning the way I wanted it. Luckily with node it was easy to write the Node-file-rev module myself. After installing it, you provide the file(s) to the script and it creates the revisioned file as well as the manifest. You can specify the manifest directory and name with the --manifest flag. The --root flag allows you to specify the root directory to remove in the manifest (e.g. public/css/app.csscss/app.css).

// package.json"scripts": {
"css:rev": "node-file-rev public/css/app.css --manifest=public/rev-manifest.json --root=public/"

Composing CSS scripts together

A new build:css script combines the clean, compile and rev script using the && operator. With a && the next command will only execute if the first command exits with 0, meaning it was successful.

The build:css:watch script executes build:css after every update of a file in the specified folder. We are installing onchange for this. The -i flag makes onchange run the script once when it is started (without any change).

When adding the two scripts you will have the following five scripts for you css workflow.

// package.json"scripts": {
"css:clean": "rm -f public/css/*",
"css:compile": "node-sass --source-map public/css/ --output-style compressed -o public/css/ resources/css/app.scss",
"css:rev": "node-file-rev public/css/app.css --manifest=public/rev-manifest.json --root=public/",
"build:css": "npm run css:clean && npm run css:compile && npm run css:rev",
"build:css:watch": "onchange -i 'resources/css/*.scss' 'resources/css/*/*.scss' -- npm run build:css"

Building our Javascript

To compile our Typescript files we need to replicate the same logic as we had for out Sass. We also want to move some files from node_modules into public/js.

Compiling Typescript files

I am using rollup, but you could use webpack or any other tool you need to convert your files. There is definitely a cli version available. Just add another script to the package.json named js:compile and run the needed cli call, for rollup it is rollup --config.

The --config flag tells rollup to use the rollup.config.js file so you don't have to specify everything in the cli. Explaining my config would go to far for this article. In short I'm using rollup-plugin-typescript and rollup-plugin-uglify-es to convert and uglify my Typescript.

Removing old files & moving files

We use the same rm script to remove old javascript files.
The files we want to move are store in a variable in a config section of the package.json called moveFilesJs. You have to use a space separated list because arrays are not supported. In your script you can reference the files by using $npm_package_config_moveFilesJs. With this in place we can run a simple copy cp command.

// package.json"config": {
"moveFilesJs": "node_modules/@webcomponents/webcomponentsjs/bundles/webcomponents-sd-ce.js node_modules/@webcomponents/webcomponentsjs/bundles/ node_modules/fetch-inject/dist/fetch-inject.min.js"
"scripts": {
"js:clean": "rm -f public/js/*",
"js:move": "cp $npm_package_config_moveFilesJs public/js/"

Revisioning files

For the revisioning we can use the same Node-file-rev module and replace the path. Instead of defining a single file, we define a “glob” by using public/js/*.js. This will revision all javascript files in the folder.

// package.json"scripts": {
"js:rev": "node-file-rev public/js/*.js --manifest=public/rev-manifest.json --root=public/"

Composing JS scripts together

Like with the css, a build:js script combines the clean, compile, rev and move script using the && operator.

The build:js:watch script executes build:js when a file changes.

// package.json"scripts": {
"build:js:watch": "onchange -i 'resources/js/*.ts' 'resources/ts/*/*.ts' -- npm run build:js",
"build:js": "npm run js:clean && npm run js:compile && npm run js:rev && npm run js:move",
"js:clean": "rm -f public/js/*",
"js:move": "cp $npm_package_config_moveFilesJs public/js/",
"js:compile": "rollup --config",
"js:rev": "node-file-rev public/js/*.js --manifest=public/rev-manifest.json --root=public/"

Composing it all together

Finally we can combine both build scripts into a single one called build. By combining build and supervisor into the start script we can run everything with npm start.

Because both scripts keep running we install the ttab module to start each in its own terminal tab.

The -t flag allows you to specify the name for the tab.

// package.json"scripts": {
"build": "npm run build:js:watch & npm run build:css:watch",
"start": "node_modules/.bin/ttab -t 'Node Server' 'npm run supervisor' & node_modules/.bin/ttab -t 'Building assets' 'npm run build'"


Replacing a build tool like gulp with npm scripts is not as hard as it seems. While it can be a bit more inconvenient, it makes you less dependent on plugin authors. Scripts are less connected which lets you replace one part without touching the rest.
You can always write a node or bash script to do what you need and execute it via npm.

In the end it comes down to preferences and values. I value ease of maintenance and fewer dependencies over the convenience of a task runner.