Develop Node app in ES6 without Nodemon and Babel

I recently started a new Node.js project, and as a habit, I began by installing two familiar packages: nodemon and babel. The purpose was that I needed a way to hot reload my app while writing it in ES6 Module.

A tool we have gotten to know since the beginning of time for hot reloading is nodemon.

Also, since the default configuration of Node.js only supports common.js, we need a way to transpile our code back to common.js. Surely, support for ES6 modules in Node is behind --experimental-modules and requires .mjs extension (which is intrusive in my opinion).

(The latest major version of Node.js has already allowed us to use ES6 modules without the tag, but this does not seem to be backported to previous majors, and using it still requires extension)

Most tutorials will suggest Babel for the job. However, I think it is way too much for our purpose (Babel is more suitable to be used for browsers). It also removes the benefits of using ES6 (tree shaking).

Rollup to the rescue

Introducing Rollup.

Rollup is a module bundler for JavaScript which compiles small pieces of code into something larger and more complex, such as a library or application.

(note: application)

Get started by installing rollup as a dev dependency.

yarn add rollup -D
// or
npm i rollup --save-dev

Next, create rollup.config.js. (You may use ES6 in this file)

export default {
  input: 'api/server.js',
  output: {
    file: 'bundle.js',
    format: 'cjs',
  },
};

By this config, we are taking our api/server.js (or wherever your main script is), and output a CommonJS version of it.

Even though it is CommonJS after all, the exported file has gone through treeshaking. Also, since everything is compiled into one file, our code may run a bit faster in Node by eliminating the need to require different modules.

Just for reference, here is my api/server.js, written in ES6.

import next from 'next';
import { createServer } from 'http';
import apolloServer from './apollo-server';
import app from './app';
import { connect as connectMongoDB } from './db/mongo';

// http
const port = process.env.PORT || '3000';
const httpServer = createServer();

// next
const nextApp = next({ dev: process.env.NODE_ENV !== 'production' });
const nextHandle = nextApp.getRequestHandler();

// apollo
apolloServer.applyMiddleware({ app, path: '/api' });
apolloServer.installSubscriptionHandlers(httpServer);

async function start() {
  await connectMongoDB();
  await nextApp.prepare();
  app.all('*', nextHandle);
  httpServer.on('request', app.handler);
  httpServer.listen({ port }, () => {
    console.log(`🚀  Apollo API ready at :${port}${apolloServer.graphqlPath}`);
    console.log(
      `🚀  Apollo WS ready at :${port}${apolloServer.subscriptionsPath}`
    );
  });
}

start();

Hot reloading

To achieve the functionality of nodemon, we add a rollup plugin called @rollup/plugin-run.

🍣 A Rollup plugin which runs your bundles in Node once they're built.

Using this plugin gives much faster results compared to what you would do with nodemon.

(In my experience using it, this plugin is faster than nodemon)

yarn add @rollup/plugin-run -D
// or
npm i @rollup/plugin-run --save-dev

(We will import the above package in rollup.config.js, which may be complained by eslint, you can either eslint-disable the warning or add the package as a regular dependency).

Back in rollup.config.js:

import run from '@rollup/plugin-run';

export const roll = rollup;

const dev = process.env.NODE_ENV !== 'production';

export default {
  input: 'api/server.js',
  output: {
    file: 'bundle.js',
    format: 'cjs',
  },
  plugins: [
    dev && run(),
  ],
};

We import @rollup/plugin-run and include it in plugins. Notice that this will only run in development (by checking process.env.NODE_ENV).

Add scripts to package.json

{
  "scripts": {
    "start": "node bundle.js",
    "build": "NODE_ENV=production rollup -c",
    "dev": "rollup -c -w",
  }
}

Our start script simply runs the output bundle.js.

Our build script runs rollup setting NODE_ENV to production. (you may need cross-env in Windows)

Our dev call rollup. The flag -c means using our config file rollup.config.js. The flag -w rebuilds our bundle if the source files change on disk. In fact, @rollup/plugin-run does not do hot-reloading but only runs the Node process every time rollup recompile.

What about .env

We often use .env in development. @rollup/plugin-run allows us to execute an argument. In rollup.config.js, edit our run() function.

run({
  execArgv: ['-r', 'dotenv/config'],
})

This allows us to do node -r (--require) dotenv/config. This usage can be seen here.

Integrate Babel

Even though we do not use Babel to transpile import/export to require/module.exports, there are cases when we still need it. For example, I use it for @babel/plugin-proposal-optional-chaining, which enables optional chaining (this proposal is 🔥 btw).

The plugin we need is rollup-plugin-babel

yarn add -D @babel/core rollup-plugin-babel
// or
npm i --save-dev @babel/core rollup-plugin-babel 

We can now add it to rollup.config.js.

import run from '@rollup/plugin-run';
import babel from 'rollup-plugin-babel';

const dev = process.env.NODE_ENV !== 'production';

export default {
  input: 'api/server.js',
  output: {
    file: 'bundle.js',
    format: 'cjs',
  },
  plugins: [
    babel(),
    dev &&
      run({
        execArgv: ['-r', 'dotenv/config'],
      }),
  ],
};

The default configuration of rollup-plugin-babel will read from .babelrc. However, if you are like me, who has .babelrc not for the node server but for framework such as React or Next.js, you can opt out. Doing so by edit babel():

babel({
  babelrc: false,
  plugins: ['@babel/plugin-proposal-optional-chaining'],
})

That's it!