Webpack hot module replacement in server-rendered apps

Get the benefits of webpack-dev-server without building an SPA.

Glynn Forrest
Friday, July 31, 2020

The asset bundler Webpack can do a lot of things, but one of its most compelling features is hot module replacement (HMR): injecting changes to CSS and JavaScript without reloading the page.

Many people assume HMR is only available for single-page applications (SPAs) run with webpack-dev-server, but it’s actually fairly simple to add live reloading to other applications too. In this post I’ll show you how.

How webpack-dev-server works

Let’s dive into HMR a bit to understand how it works. Given a simple webpack config for an SPA:

module.exports = {
  mode: 'development',
  output: {
    filename: '[name].js',
    path: path.resolve(__dirname, 'public'),
  },
  entry: {
    app: './js/app.js',
  },
  devServer: {
    contentBase: path.resolve(__dirname, 'public'),
    inline: true,
    hot: true,
  },
  module: {
    rules: [
      //...
    ]
  }
  //...
};

a simple entrypoint:

import '../css/app.css';

document.getElementById('app').innerText = 'Hello world';

a basic HTML page:

<!DOCTYPE html>
<html>
  <head>
    <title>My SPA</title>
  </head>
  <body>
    <div id="app"></div>
    <!-- paths will be added by devServer.inline -->
  </body>
</html>

and webpack-dev-server:

webpack-dev-server --config webpack.config.js

http://localhost:8000/
webpack output is served from /public/
Content not from webpack is served from /public/

Visiting http://localhost:8000 in the browser will return the HTML with a path to the built app.js file included:

  <!DOCTYPE html>
  <html>
    <head>
      <title>My SPA</title>
    </head>
    <body>
      <div id="app"></div>
      <!-- paths will be added by devServer.inline -->
+     <script type="text/javascript" src="/app.js"></script>
    </body>
  </html>

Whenever we update app.js or app.css, the changes will be automatically sent to the browser without refreshing the page. This is handled by webpack-dev-server - when it builds app.js, it’ll include a client that listens for updates. When the client is notified by the server (normally by websocket), it’ll fetch the part of the asset that changed (known as a “chunk”), and inject it into the page, replacing the chunk of the asset that was already there.

Updating a server rendered application to use webpack-dev-server

Let’s replicate that in a typical server rendered application built in a framework such as Symfony, Rails, or Django.

Current config

Let’s assume the project has a webpack setup already.

With this webpack config:

let extractCss = require('mini-css-extract-plugin');

module.exports = {
  mode: 'development',
  output: {
    filename: '[name].js',
    path: path.resolve(__dirname, 'public'),
  },
  entry: {
    app: './js/app.js',
  },
  plugins: [
    new extractCss({
      filename: '[name].css'
    })
  ]
};

this HTML template:

<!DOCTYPE html>
<html>
  <head>
    <title>My server rendered app</title>
    <link rel="stylesheet" type="text/css" href="/app.css" />
  </head>
  <body>
    <h1>My server rendered app</h1>
    <script type="text/javascript" src="/app.js"></script>
  </body>
</html>

and running webpack like this:

webpack-dev-server --config webpack.config.js --watch

The assets will be built to the public/ folder, and rebuilt whenever they change.

Assuming the app is listening on localhost:7000 and serves files from public/, visiting http://localhost:7000 in the browser will return the HTML from the app server, and load the built webpack files from the public folder. To see the reloaded assets in the browser when they change, we have to refresh the page each time.

Add webpack-dev-server

Now add webpack-dev-server to the webpack config:

  let extractCss = require('mini-css-extract-plugin');

  module.exports = {
    mode: 'development',
    output: {
      filename: '[name].js',
      path: path.resolve(__dirname, 'public'),
    },
    entry: {
      app: './js/app.js',
    },
+   devServer: {
+     contentBase: path.resolve(__dirname, 'public'),
+     inline: true,
+     hot: true,
+   },
    plugins: [
      new extractCss({
        filename: '[name].css'
      })
    ]
  };

update the application template:

  <!DOCTYPE html>
  <html>
    <head>
      <title>My server rendered app</title>
-     <link rel="stylesheet" type="text/css" href="/app.css" />
+     <link rel="stylesheet" type="text/css" href="http://localhost:8000/app.css" />
    </head>
    <body>
      <h1>My server rendered app</h1>
-     <script type="text/javascript" src="/app.js"></script>
+     <script type="text/javascript" src="http://localhost:8000/app.js"></script>
    </body>
  </html>

and start webpack-dev-server:

webpack-dev-server --config webpack.config.js

http://localhost:8000/
webpack output is served from /public/
Content not from webpack is served from /public/

We now have two servers running: the app server on localhost:7000, and webpack-dev-server on localhost:8000.

Now reload the page at http://localhost:7000. In theory, this would load assets from webpack-dev-server, and we’d get live updates when they change. Unfortunately, we’ve got a problem, CORS!

Allow cross-origin resource sharing

The webpack config needs updating to allow the client to load resources from a different host:

  let extractCss = require('mini-css-extract-plugin');

  module.exports = {
    mode: 'development',
    output: {
      filename: '[name].js',
      path: path.resolve(__dirname, 'public'),
    },
    entry: {
      app: './js/app.js',
    },
    devServer: {
      contentBase: path.resolve(__dirname, 'public'),
      inline: true,
      hot: true,
+     // Allow hot module replacements to be pulled from another host (localhost:7000)
+     headers: {
+       'Access-Control-Allow-Origin': '*'
+     },
    },
    plugins: [
      new extractCss({
        filename: '[name].css'
      })
    ]
  };

Now refresh the page.

Success! app.js should be loaded successfully, with changes automatically pushed to the browser without refreshing the page.

Not so with app.css however, let’s fix that next.

Injecting CSS

In development, webpack actually builds CSS into a JavaScript file; app.css gets bundled into app.js in our case. For production, a plugin such as mini-css-extract-plugin is used to extract those styles into a separate file. That’s what we’re doing here in our config, but it isn’t compatible with HMR.

Update the webpack config to only extract CSS in a production build:

  let extractCss = require('mini-css-extract-plugin');
+
+ // change to 'production' via process.ENV, or use a different config file
+ let mode = 'development'

- module.exports = {
-   mode: 'development',
+ let config = {
+   mode,
    output: {
      filename: '[name].js',
      path: path.resolve(__dirname, 'public'),
    },
    entry: {
      app: './js/app.js',
    },
    devServer: {
      contentBase: path.resolve(__dirname, 'public'),
      inline: true,
      hot: true,
      // Allow hot module updates to be pulled from another host (localhost:7000)
      headers: {
        'Access-Control-Allow-Origin': '*'
      },
    },
    plugins: [
-     new extractCss({
-       filename: '[name].css'
-     })
    ]
  };
+
+ if (mode === production) {
+   config.plugins.push(
+     new extractCss({
+       filename: '[name].css'
+     })
+   );
+ }

+ module.exports = config;

and remove the <link> tag entirely:

  <!DOCTYPE html>
  <html>
    <head>
      <title>My server rendered app</title>
-     <link rel="stylesheet" type="text/css" href="http://localhost:8000/app.css" />
    </head>
    <body>
      <h1>My server rendered app</h1>
      <script type="text/javascript" src="http://localhost:8000/app.js"></script>
    </body>
  </html>

Success! CSS changes are now injected into the browser whenever they occur.

Development vs production

This setup works well in development, but not in production. If using a template language, the app server could change the tags that are used in different environments. For example, using Twig:

<!DOCTYPE html>
<html>
  <head>
    <title>My server rendered app</title>
    {% if not is_dev() %}
      <link rel="stylesheet" type="text/css" href="/app.css" />
    {% endif %}
  </head>
  <body>
    <h1>My server rendered app</h1>
    {% if is_dev() %}
      <script type="text/javascript" src="http://localhost:8000/app.js"></script>
    {% else %}
      <script type="text/javascript" src="/app.js"></script>
    {% endif %}
  </body>
</html>

In this example, is_dev would be a user-defined function or variable.

Extra credit

This outlines the basics of HMR with a traditional server-side application. There are many other ways to improve upon it, however:

Dynamic ports

If you run multiple projects at once, hard-coding port numbers won’t always work. What if port 8000 is already used when we start webpack-dev-server? It will use a different port, and we’ll have to update it in the HTML template.

A good option is to write the port name to a file when the server starts:

  devServer: {
    onListening(server) {
      fs.writeFile(
        path.resolve(__dirname, '.webpack-dev-server-port'),
        server.listeningApp.address().port,
        function (error) {
          if (error) {
            console.error(error);
          }
        }
      );
    }
  }

This file could then be used by the application server to generate the correct port number in <script> and <link> tags.

Use a manifest file

A manifest file is a great way to map generated webpack assets to URLs.

With the webpack-manifest-plugin, manifest.json can be generated when the assets are built. The application server could then read this file to create links to the required assets. Here’s an example for Symfony: https://symfony.com/doc/4.4/frontend/encore/versioning.html#loading-assets-from-entrypoints-json-manifest-json

By default, webpack-dev-server doesn’t write to disk, but you can override it with more devServer configuration:

  devServer: {
    // Always write manifest.json to disk so it can be picked up by
    // the application
    writeToDisk(filePath) {
      return /manifest.json$/.test(filePath);
    }
  },

It’s also a good idea to include [hash] in the generated webpack file names, which the manifest plugin can help with too. See our post about asset cache busting for more information.

Get in touch

Have you found any interesting ways to combine webpack-dev-server with another app server? Let us know on Twitter or send us an email!

More from the blog

Asset cache busting in Symfony applications cover image

Asset cache busting in Symfony applications

Different techniques to send the newest css and javascript files to your users.


Glynn Forrest
Friday, January 31, 2020

The search for the perfect setup script cover image

The search for the perfect setup script

How to onboard your developers as smoothly as possible.


Glynn Forrest
Monday, August 31, 2020

Logged out ajax requests in Symfony applications cover image

Logged out ajax requests in Symfony applications

Handling logged out ajax requests properly using Symfony’s security features.


Glynn Forrest
Monday, June 29, 2020

Subscribe to our mailing list

Receive periodic updates about our products, services, and articles.

View recent emails