Webpack hot module replacement in server-rendered apps
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!