JavaScript is a language and an ecosystem which constantly evolves. This changes the way we can write JavaScript and Node.js code, and it often demands special tooling to be able to do so. This tutorial shows how to set up a project with Babel and Webpack, which allows you to write modern ES6 JavaScript code for the browser and the server.

The example project setup

In the course of this tutorial, we are going to create a Node.js HTTP server with ES6 JavaScript code, and this server in turn serves a web page which loads a ES6 JavaScript application in the browser. Together, it’s not much more than a “Hello World” application, but it’s enough to force us to go full circle with our ES6 development setup.

Now, it’s not enough to write ES6-level JavaScript code to get this demo application running. That’s because neither Node.js nor our browser will support all ES6 features of the code we are going write.

This is why we need extra tooling, namely Babel and Webpack.

Instead of explaining what these do in detail beforehand, we will write our server and client application code using ES6 syntax, see how that fails to work out of the box, and then bring in Babel and Webpack to save the day - while also explaining how they work.

In the course of this tutorial, we will create the following project structure:

es6-demo
├── .babelrc
├── package.json
├── webpack.config.js
├── src
│   ├── backend
│   │   └── server.js
│   └── frontend
│       └── index.js
└── dist
    ├── index.html
    └── build.js

The famous node_modules folder is not shown here, but will of course be created once we start installing packages with NPM.

Building the app

We start on the server-side. Create the following folders:

mkdir -p es6-demo/src/backend
mkdir -p es6-demo/src/frontend
mkdir -p es6-demo/dist

Within project root folder es6-demo, run npm init, and answer all question with the default.

Then, within es6-demo/dist, create file index.html with the following content:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>ES6 Demo Application</title>
    </head>
    <body>
        <div id="content">Client code has not yet replaced the content.</div>

        <script src="bundle.js"></script>
    </body>
</html>

We need to serve this file when someone opens our application at http://127.0.0.1:8080. For this, we write a small Node.js HTTP server in file es6-demo/src/backend/server.js - and we do so using ES6 JavaScript:

import http from 'http';
import fs from 'fs';
import path from 'path';

const indexHtmlContent = fs.readFileSync(path.join(__dirname, '/../../dist/index.html'));

http.createServer((req, res) => {
  if (req.url === '/') {
    res.writeHead(200, { 'Content-Type': 'text/html' });
    res.end(indexHtmlContent);
  } else {
    res.writeHead(404, { 'Content-Type': 'text/plain' });
    res.end('Not found');
  }
}).listen(8080);

console.log('Server running at http://127.0.0.1:8080/');

This looks a lot like the JavaScript code we know, but we can spot several ES6-specific parts:

import instead of require

Out of the box, the Node.js module system is based on CommonJS, a very straightforward module loader which utilizes the require keyword - thus, pre-ES6 code would look like this:

var http = require('http'); 
var fs = require('fs'); 
var path = require('path'); 

ES6, on the other hand, works with the import keyword.

const variable desclaration

Whereas pre-ES6 JavaScript only knows variable name declarations using var, ES6 supports the keyword const - it should be used to declare a variable that is assigned a value only once, and then not changed or reassigned again.

Arrow function expression using =>

When we create an HTTP server in Node.js using the createServer method of the http object, we need to pass a function that will be triggered whenever an HTTP request is received by our server.

Pre-ES6, the notation for this looks as follows:

http.createServer(function(req, res) {
  res.writeHead(200, { 'Content-Type': 'text/plain' });
  res.end('Hello, World.');
}).listen(8080);

With the new ES6 arrow function expression, we can be more concise:

http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/plain' });
  res.end('Hello, World.');
}).listen(8080);

Running the ES6 server

The interesting question is: will this code run using the default Node.js interpreter (v8.6.0 as of this writing)?

Let’s give it a try:

$ > node ./src/backend/server.js
es6-demo/src/backend/server.js:1
(function (exports, require, module, __filename, __dirname) { import http from 'http';
                                                              ^^^^^^

SyntaxError: Unexpected token import
    at createScript (vm.js:74:10)
    ...

No, it doesn’t. Node.js does not (yet) support the new ES6 module system, and bails out at the import keyword which it simply doesn’t know.

We now need to introduce Babel into the mix to make this code run. Babel helps us because it is a transpiler. Let’s first clarify what that does NOT mean: Babel is NOT an extension to Node.js - it doesn’t “enhance” our Node.js installation by teaching it ES6. Instead of changing Node.js, Babel changes our code. It transpiles our ES6 code into code that follows the JavaScript language level that Node.js knows, that is, the ES6 predecessor (which is called ECMAScript 5th edition, or ES5).

Here is how our ES6 server.js file content looks after it went through Babel’s transpiling process:

'use strict';

var _http = require('http');

var _http2 = _interopRequireDefault(_http);

var _fs = require('fs');

var _fs2 = _interopRequireDefault(_fs);

var _path = require('path');

var _path2 = _interopRequireDefault(_path);

function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }

var indexHtmlContent = _fs2.default.readFileSync(_path2.default.join(__dirname, '/../../dist/index.html'));

_http2.default.createServer(function (req, res) {
  if (req.url === '/') {
    res.writeHead(200, { 'Content-Type': 'text/html' });
    res.end(indexHtmlContent);
  } else {
    res.writeHead(404, { 'Content-Type': 'text/plain' });
    res.end('Not found');
  }
}).listen(8080);

console.log('Server running at http://127.0.0.1:8080/');

While not looking exactly like the pre-ES6 code we would write by hand, we can clearly see how the import and const keywords are gone, and the fancy arrow function syntax is transpiled back into a good old anonymous function declaration.

This code can then be interpreted by Node.js.

How do we get there? We start by installing some Babel-related NPM modules:

npm install --save-dev babel-cli babel-preset-env

babel-cli is just a command line tool and some basic stuff that doesn’t do any actual transpilation. We need babel-preset-env, which is a bundle of several different Babel plugins which, at its core, gives us ES6-to-ES5 transpilation.

We need to configure Babel by creating the following .babelrc file in the root folder of our project:

{
  "presets": ["env"]
}

One way to use Babel in our project would be to simply transpile each ES6 code file we write by hand into its ES5 equivalent, and then start the transpiled file with Node.js, like this:

$> ./node_modules/.bin/babel src/backend/server.js > src/backend/server.es5.js
$> node src/backend/server.es5.js
Server running at http://127.0.0.1:8080/

This works, but is tiresome and error-prone. Babel makes this a lot easier for us by providing babel-node, a command-line tool which we can use directly to launch Node.js with our ES6 code file. Behind the scenes, Babel transpiles our code and feeds the result to Node.js:

$> ./node_modules/.bin/babel-node src/backend/server.js
Server running at http://127.0.0.1:8080/

So, Node.js backend server code and ES6: Check. On to the frontend!

Building and serving the frontend code for the browser

Next, we need to write, build, and serve some ES6 JavaScript code for the browser.

Our index.html file, which is now served by our Node.js backend server code, already has the line needed to kick off a JavaScript application in the client:

<script src="bundle.js"></script>

However, there is no bundle.js file yet - and actually, we are not going to write it. Instead, we write our own ES6 client code in file frontend/index.js, and the content of this file, transpiled where needed and bundled together with other JavaScript code it might need, will then be built using Webpack, resulting in file dist/bundle.js.

We will see what that means and how it works in a moment. Now, however, we need to get back to our server code and teach it to serve not only file index.html, but also file dist/bundle.js when it is asked for http://127.0.0.1:8080/bundle.js:

import http from 'http';
import fs from 'fs';
import path from 'path';

const indexHtmlContent = fs.readFileSync(path.join(__dirname, '/../../dist/index.html'));

http.createServer((req, res) => {
  if (req.url === '/') {
    res.writeHead(200, { 'Content-Type': 'text/html' });
    res.end(indexHtmlContent);
  } else if (req.url === '/bundle.js') {
    const bundleJsContent = fs.readFileSync(path.join(__dirname, '/../../dist/bundle.js'));
    res.writeHead(200, { 'Content-Type': 'application/javascript' });
    res.end(bundleJsContent);
  } else {
    res.writeHead(404, { 'Content-Type': 'text/plain' });
    res.end('Not found');
  }
}).listen(8080);

console.log('Server running at http://127.0.0.1:8080/');

Because we expect file dist/bundle.js to change regularly, we read it anew from disk every time it is requested - this way, we don’t need to restart our Node.js server everytime the file changes.

Do not (re)start the server just yet, though - file dist/bundle.js still doesn’t exist, and because we have not added any error handling to our server as this is just a demo, it would completely crash with Error: ENOENT: no such file or directory as soon as we request the webpage.

Next, we create our own frontend code. In file src/frontend/index.js, we write a very minimalistic application that simply replaces the text in our content div with “Hello, World!”, using jQuery:

import $ from 'jquery';

$('#content').html('Hello, World!');

Let’s find out what we need to get that working.

First of all, we use jQuery, which means we need to pull that in. Instead of requesting it from a CDN or manually downloading it into our dist folder, we do something counter-intuitive. We install it via NPM:

npm install --save jquery

This pulls in the latest version of jQuery into our node_modules folder. As you can see in our index.js code, we use the ES6 import module loader to make jQuery available under identifier $ in our application. But how can we load jQuery into the browser without a <script src="..."> tag in our index.html file, and how can we resolve the import statement although browsers do not yet support ES6 JavaScript?

Again, we need Babel, but now we also need Webpack.

Webpack describes itself as “a bundler for javascript and friends”. The reason it exists is that the way we want to organize our own code and its external dependencies, and the way that JavaScript and other assets are best served to a browser, are often very different, and this difference can be cumbersome to manage.

Webpack makes this a lot easier: we organize and write our own code the way we want, we refer to external dependencies like the NPM-installed jQuery library the way we want, and we have Webpack bundle it all together in one single bundle.js file which has all the content the browser needs, served in the format it likes. Because Webpack integrates with Babel, the bundled file is guaranteed to only contain browser-friendly ES5 code.

The price we pay for this is some setup work that we need to do, but which isn’t that complicated.

We start by installing Webpack and a plugin it needs to work together with Babel:

npm install webpack babel-loader --save-dev

Then, we put the following configuration in file webpack.config.js in the es6-demo root folder:

const path = require('path');

module.exports = {
  entry: {
    app: path.resolve(__dirname, 'src/frontend/index.js'),
  },
  module: {
    loaders: [
      {
        loader: "babel-loader",
        
        // Skip any files outside of your project's `src/frontend` directory
        include: [
          path.resolve(__dirname, 'src/frontend'),
        ],
        
        // Only run `.js` files through Babel
        test: /\.js?$/,
      },
    ],
  },
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist'),
  },
};

This tells Webpack several things:

  • the entry file of our frontend application is src/frontend/index.js
  • we want to use the loader babel-loader
  • this loader shall only transpile files in src/frontend - with jQuery, we are only using libraries in node_modules which are already ES5, so we don’t need to transpile these
  • we also want Babel to ignore any files that don’t end in .js
  • last but not least, we want Webpack to generate the final result into file bundle.js in folder dist

With this, we are all set to experience our final application.

First, (re)start the backend server in one terminal session:

./node_modules/.bin/babel-node src/backend/server.js

Next, in another terminal session, have Webpack build our frontend application bundle:

./node_modules/.bin/webpack

Finally, open a browser window at http://127.0.0.1:8080/ and enjoy the beauty of your first back-to-front ES6 application - that is, you should see a very spartan page that greets you with Hello, World!.

Let’s dissect what exactly happens.

As before, opening the page in your browser sends a request to the running Node.js backend server. The server code (which has been transpiled on-the-fly from ES6 to ES5 via babel-node) receives the request for URL /, and responds with the content of file dist/index.html.

This HTML file is rendered by the browser. The browser encounters the <script src="bundle.js"></script> line, and as a result, it sends another request to http://127.0.0.1:8080/bundle.js.

Again, our server receives that request, and responds with the contents of file dist/bundle.js. This file has been generated by Webpack.

Running ./node_modules/.bin/webpack made Webpack look at our main frontend code file in src/frontend/index.js, which it knows about because it is defined as the application entry point in webpack.config.js.

Because Webpack is configured to run all .js files in folder src/frontend through its babel-loader, our code is interpreted and transpiled via Babel. While doing so, Webpack recognizes that our code imports a library named jquery. In order to make the code of this library available to our own code, Webpack pulls in the contents of file node_modules/jquery/dist/jquery.js and adds it to file dist/bundle.js. Our own code is bundled into the file, too.

Finally, our browser receives the response from our server containing the contents of file bundle.js, and runs the contained JavaScript code, resulting in the Hello, World! page.


Learn more about writing web applications using Node.js with The Node Beginner Book - the first part of this step-by-step Node.js tutorial is available for free!