With development of Javascript frameworks and plenty of new features, introduced in HTML5, single page applications had become very popular among web developers and finally allowed to separate frontend development from backend.

In this article I am going to give step-by-step guide to create an SPA with webpack.

Let’s begin!

Basic installation

Let’s prepare our project πŸ˜ƒ

$ mkdir spa-tutorial
$ cd spa-tutorial
$ npm init

After filling in the form we get the following contents of our package.json file:

{
  "name": "spa-tutorial",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "Roman Kinyakin <[email protected]>",
  "license": "UNLICENSED"
}

Next, we need to decide about our project structure. Basically, you can use any configuration you like, but for this tutorial next directories should be created:

.
β”œβ”€β”€ build/
β”‚Β Β  └── base.config.js
β”œβ”€β”€ dist/
β”œβ”€β”€ src/
β”‚Β Β  └── index.js
β”œβ”€β”€ static/
β”œβ”€β”€ package-lock.json
└── package.json
  • build directory is used to store our web pack configuration and build-related scripts.
  • dist directory is ignored in git and used to store compiled bundles.
  • src directory is the main directory for source code.
  • static directory is used to serve non-packed assets.

Webpack configuration for JS

Hereafter flag -D or --save-dev is used to install dependencies required for building application or development purposes. Libraries required in the application itself are installed in default way.

$ npm install -D webpack webpack-dev-server babel-core babel-loader babel-preset-env rimraf

These are basic dependencies required to build Javascript applications.

We are going to use Webpack Dev Server for development purposes with Hot reloading.

Babel transpolar will help us to convert ES6 syntax to ES5.

Rimraf is only required to clean up our dist directory before each build.

Now we need to create configuration file build/base.config.js

// Define this constant for easier usage
const isProd = process.env.NODE_ENV === 'production'

const { resolve } = require('path')

const config = {
    // Include source maps in development files
    devtool: isProd ? false : '#cheap-module-source-map',

    entry: {
        // Main entry point of our app
        app: resolve(__dirname, '..', 'src', 'index.js'),
    },

    output: {
        // As mentioned before, built files are stored in dist
        path: resolve(__dirname, '..', 'dist'),

        // In our case we serve assets directly from root
        publicPath: '/',

        // We add hash to filename to avoid caching issues
        filename: '[name].[hash].js',
    },

    resolve: {
        extensions: ['*', '.js'],
        modules: [
            resolve(__dirname, '..', 'node_modules'),
        ],
    },

    module: {
        rules: [
            {
              test: /\.js$/,
              loader: 'babel-loader',

              // Dependencies do not require transpilation
              exclude: /node_modules/
            },
        ],
    },

    plugins: [],
}

if (!isProd) {
    config.devServer = {
        contentBase: resolve(__dirname, '..', 'static'),
        hot: true,
        publicPath: '/',
        historyApiFallback: true,
    }
}

module.exports = config

Next we modify scripts section of package.json

"scripts": {
    "dev": "webpack-dev-server --hot --inline --config build/base.config.js",
    "build": "rimraf ./dist && NODE_ENV=production webpack --config build/base.config.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  • dev command will launch development server serving files from static directory and build files.
  • build command will prepare files for deployment.

Now, running npm run dev should give following output:

$ npm run dev

> [email protected] dev /Users/roman.kinyakin/Code/experiments/spa-tutorial
> webpack-dev-server --hot --inline --config build/base.config.js

Project is running at http://localhost:8080/
webpack output is served from /
Hash: 720478b3253846bdadce
Version: webpack 3.5.5
Time: 732ms
                          Asset    Size  Chunks                    Chunk Names
    app.720478b3253846bdadce.js  353 kB       0  [emitted]  [big]  app
app.720478b3253846bdadce.js.map  417 kB       0  [emitted]         app

...

webpack: Compiled successfully.

HTML frontend

In order to display our app in browser, we need an HTML file and html-webpack-plugin is going to help us with that.

Wepback plugin is a library, which modifies build process by integrating hooks on different stages. Plugins serve different purposes and can modify existing files as well as create new ones.

$ npm i -D html-webpack-plugin

Now we add plugin to config file:

// ... in the beginning of config

const { resolve } = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')

// ... inside config object

    plugins: [
         new HtmlWebpackPlugin(),
    ],

// ...

After this index.html file will be included in build files with following contents:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>Webpack App</title>
  </head>
  <body>
  <script type="text/javascript" src="/app.514e30e39581269a34ab.js"></script></body>
</html>

As you can see, JS entry has been automatically added inside <body> tag.

Custom HTML template

In most cases frameworks require some additional tags inserted into html in order to launch. For this purpose HTML plugin supports custom templates in ejs format.

base.config.js:

new HtmlWebpackPlugin({
    title: 'SPA tutorial',
    template: resolve(__dirname, '..', 'src', 'html', 'index.ejs'),
}),

Now create a new file:

β”œβ”€β”€ src
β”‚Β Β  β”œβ”€β”€ html
β”‚Β Β  β”‚Β Β  └── index.ejs

With following contents: index.ejs:

<!DOCTYPE html>
<html>
  <head>
    <meta http-equiv="Content-type" content="text/html; charset=utf-8"/>
    <title><%= htmlWebpackPlugin.options.title %></title>
  </head>
  <body>
    <div id="app">
      Application will be mounted here
    </div>
  </body>
</html>

All required scripts will be still injected inside <body> as before, however now we can modify contents of HTML intself as well as <head> contents.

Assets bundle

Next step is to make our webpack config accept styles, images and other assets.

Currently we have only one way: store them inside static directory, however there is much better way.

The most commonly used assets are:

  • css files as well as less, sass or styl (we are going to use sass in this tutorial)
  • images
  • fonts
  • video files

In order to serve them we need to install additional loaders.

Webpack loaders are libraries, which allow to include various file types in builds. By default web pack only supports ES5 style Javascript files.

npm i -D style-loader css-loader sass-loader node-sass url-loader file-loader
  • style-loader: the basic loader, required to inject <style> tags in HTML. Styles are being injected only when CSS is required in your modules.
  • css-loader: loader for .css files. The difference from style loader is that first injects styles into HTML, while css-loader is responsible for CSS files handling and transformations.
  • sass-loader: transforms .sass and .scss files into CSS.
  • node-sass: peer dependency for SASS loader
  • url-loader: provides a way to load files via URL or DataURL (for smaller files) instead of injecting into JS
  • file-loader: same as previous, except it does not use DataURLs

Now let’s setup our loaders.

Every loader rule matches filename patterns by regular expressions, which allows to create flexible configurations for different file types or file names.

base.config.js

rules: [
    {
        test: /\.js$/,
        loader: 'babel-loader',
        exclude: /node_modules/,
    },
    {
        test: /\.css$/,
        loader: ['style-loader', 'css-loader']
    },
    {
        test: /\.scss|\.sass$/,
        loader: ['style-loader', 'css-loader', 'sass-loader'],
    },
    {
        test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
        loader: 'url-loader',
        query: {
            limit: 10000,
            name: 'images/[name].[hash:7].[ext]'
        }
    },
    {
        test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
        loader: 'url-loader',
        options: {
            limit: 1000,
            name: 'fonts/[name].[hash:7].[ext]'
        }
    },
    {
        test: /\.(webm|mp4)$/,
        loader: 'file-loader',
        options: {
            name: 'videos/[name].[hash:7].[ext]'
        }
    },
],

As you can see, we defined different rules for file types.

  • CSS files are loaded via 2 loaders: CSS loader transforms files and Style loader injects them into <style> tags.
  • SCSS/SAAS files are first compiled into CSS and then injected in the same way.
  • PNG/JPEG/GIF/SVG images with size less than 10 KO are loaded via DataURL and with larger size with URL images/[name].[hash:7].[ext] where [hash:7] is randomly generated 7-characters hash.
  • Fonts are loaded the same way, except their size for DataURL is limited to 1 KO.
  • Videos are always loaded with URL and never use DataURLs.

Now we can create some styles for our application πŸ‘

β”œβ”€β”€ src
β”‚Β Β  β”œβ”€β”€ html
β”‚Β Β  β”‚Β Β  └── index.ejs
β”‚Β Β  β”œβ”€β”€ index.js
β”‚Β Β  └── styles
β”‚Β Β      β”œβ”€β”€ main.css
β”‚Β Β      └── theme.scss

index.js

import './styles/main.css'
import './styles/theme.scss'

main.css

body {
    font-size: 12px;
    background: #fefefe;
}

theme.scss

$text-color: #111111;

#app {
    color: $text-color;
}

Let’s launch our app and see the result 🀞

$ npm run dev

If you now open your dev server url http://localhost:8080 and inspect the code, you will see the following code:

<html><head>
    <meta http-equiv="Content-type" content="text/html; charset=utf-8">
    <title>SPA tutorial</title>
  <style type="text/css">body {
    font-size: 12px;
    background: #fefefe;
}
</style><style type="text/css">#app {
  color: #111111; }
</style></head>
  <body>
    <div id="app">
      Application will be mounted here
    </div>
  <script type="text/javascript" src="/app.38353efaa594a799a3d8.js"></script>

</body></html>

All assets had been generated and injected properly!

CSS extraction

<style> tags are good for development, but in production it is better to keep big stylesheets in separate files. extract-text-webpack-plugin will help us with that.

$ npm i -D extract-text-webpack-plugin

base.config.js

// ... in the beginning of config

const { resolve } = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const 

// ... inside config object

    module: {
        rules: [
            //...
            {
                test: /\.css$/,
                use: ExtractTextPlugin.extract({
                    fallback: 'style-loader',
                    use: 'css-loader',
                }),
            },
            {
                test: /\.scss|\.sass$/,
                use: ExtractTextPlugin.extract({
                    fallback: 'style-loader',
                    use: ['css-loader', 'sass-loader'],
                }),
            },
            //...
        ],
    },

// ...

    plugins: [
        new HtmlWebpackPlugin({
            title: 'SPA tutorial',
            template: resolve(__dirname, '..', 'src', 'html', 'index.ejs'),
        }),
        new ExtractTextPlugin({
            filename: 'style.[hash].css',
            disable: !isProd,
        }),
    ],

What has changed? Instead of using CSS and SASS loaders directly, now we first pass them through ExtractTextPlugin.extract() method, which launches loaders the same way and stores result in style.[hash].css file. However, if the plugin had been disabled, style-loader is used as fallback option (dev mode).

$ npm run build

Hash: c6d074729445ca09b4aa
Version: webpack 3.5.5
Time: 894ms
                         Asset       Size  Chunks             Chunk Names
   app.c6d074729445ca09b4aa.js     3.3 kB       0  [emitted]  app
style.c6d074729445ca09b4aa.css   82 bytes       0  [emitted]  app
                    index.html  380 bytes          [emitted]

Externals and libraries

Of course, you are going to use different libraries in your project. In most cases, it is enough to install the library as dependency, but here we are going to use a vary common case with jQuery + Bootstrap, which requires some specific installations.

$ npm i jquery [email protected] popper.js

index.js

import './styles/main.css'
import './styles/theme.scss'

import $ from 'jquery'
import 'bootstrap'

$(window).on('load', () => {
    $('#app').html('<h1>We are ready!</h1>')
})

But if we run the app now, we will receive quite unfortunate error:

Uncaught ReferenceError: jQuery is not defined

This happens because Bootrap does not support npm dependencies. We need to export jQuery variable into global object manualy.

Not all plugins support jQuery as npm dependency, which makes them cause issues with resolving the variable.

base.config.js

const { ProvidePlugin } = require('webpack')

// .. inside config object
    plugins: [
        new ProvidePlugin({
            $: 'jquery',
            jQuery: 'jquery',
            Popper: 'popper.js',
        }),
        //...
     ],

This will let web pack know that jQuery and $ variables should be included from jquery library and Popper variable from popper.js

theme.scss

@import "~boostrap/scss/bootstrap.scss";

So simple? Yes! Just include any file from any library and it will be compiled automatically.

Routing and templates

In most cases you are going to use some powerful frameworks like React, Angular or Vue. Purpose of this tutorial is to explain basic approaches, wo we are going to use simpler libraries and custom router.

npm i path handlebars
npm i -D text-loader

Here we need new type of loader: text-loader. It allows to require dependencies as strings, which is necessary for our Handlebars templates to work properly.

Now we set up our config for templates

base.config.js:

// ... inside config file
    resolve: {
        // ...
        alias: {
            handlebars: 'handlebars/dist/handlebars.min.js',
        }
    },
// ...
   module: {
        rules: [
            // ...
            {
                test: /\.handlebars$/,
                loader: 'text-loader',
            },
        ],
    },

Next we create route handlers and templates:

β”œβ”€β”€ src
β”‚Β Β  β”œβ”€β”€ about.js
β”‚Β Β  β”œβ”€β”€ home.js
β”‚Β Β  β”œβ”€β”€ html
β”‚Β Β  β”‚Β Β  β”œβ”€β”€ about.handlebars
β”‚Β Β  β”‚Β Β  β”œβ”€β”€ home.handlebars
β”‚Β Β  β”‚Β Β  └── index.ejs
β”‚Β Β  β”œβ”€β”€ index.js
β”‚Β Β  └── styles
β”‚Β Β      β”œβ”€β”€ main.css
β”‚Β Β      └── theme.scss

index.js

import './styles/main.css'
import './styles/theme.scss'

import $ from 'jquery'
import 'bootstrap'
import Navigo from 'navigo'

import HomePage from './home'
import AboutPage from './about'

const router = new Navigo()

router
    .on('/', HomePage)
    .on('/about', AboutPage)
    .resolve()

$(window).on('load', () => {
    $(document).on('click', '[data-path]', (e) => {
        e.preventDefault()
        router.navigate($(e.target).attr('href'))
    })
})

We use Navigo router in order to resolve paths. Home and About pages are imported from their files and after window loads, we set up links to work as Navigo links.

home.handlebars

<div class="entry">
  <h1>Hello, {{user}}!</h1>
  <div class="body">
    This is Home page.<br>
    Go to <a href="/about" data-path>About page</a>
  </div>
</div>

home.js

import $ from 'jquery'
import { compile } from 'handlebars'
import template from './html/home.handlebars'

export default (ctx, next) => {
    let user = 'Jonh'
    $('#app').html(compile(template)({
        user,
    }))
}

Home page handler injects user name as variable, renders template and puts it into our app container.

Chunks

Now we have very simple example of SPA. But as it will grow up, our app.js entry will become huge and hard to load.

In order to make it better, we can split our code into async loaded chunks.

Webpack can automatically split your code into chunks if file are loaded via promises.

Just replace pages definitions:

index.js

const HomePage = () => System.import('./home').then(module => module.default())
const AboutPage = () => System.import('./about').then(module => module.default())
$ npm run build

> [email protected] build /Users/roman.kinyakin/Code/experiments/spa-tutorial
> rimraf ./dist && NODE_ENV=production webpack --config build/base.config.js

Hash: 6bcce77c5e562c64a428
Version: webpack 3.5.5
Time: 2164ms
                         Asset       Size  Chunks                    Chunk Names
     0.6bcce77c5e562c64a428.js    76.8 kB       0  [emitted]
     1.6bcce77c5e562c64a428.js    76.8 kB       1  [emitted]
   app.6bcce77c5e562c64a428.js     487 kB       2  [emitted]  [big]  app
style.6bcce77c5e562c64a428.css     135 kB       2  [emitted]         app
                    index.html  380 bytes          [emitted]

Now home page and about page are separated into their own chunks with all dependencies included!

What’s next?

In next article I describe how to analyze bundles contents, create chunks manually and minify production bundles.

Repository with this tutorial sources available on GitHub