Developing isomorphic applications using webpack
tl;dr; I have set out to develop a program that enables rendering of any client-side code base server-side using webpack. The end result is https://github.com/gajus/isomorphic-webpack Proceed to “Hello, World”.
The past 6 months I have been working on a project with a requirement to serve the content on the server-side. The unnamed project is part of a website in Alexa Top 100 Global Site list. The website itself extensively utilises Edge Side Includes (ESI) to assemble the page content “at the edges of the Internet”. The requirement to serve the content server-side comes from utilising the ESI.
This was a unique challenge: I was working as part of a larger frontend team; the code will need to be maintained by the frontend team. Therefore, my focus has been to use frameworks known to frontend teams and avoid backend specific frameworks as much as possible. I have proceeded to develop the application using React and webpack.
I have had the application up and running, but there was one problem–none of the existing isomorphic-rendering solutions worked out of the box with the code base. The existing solutions required obscure configuration, ran multiple node processes (making it a pain to containerise the application), and didn’t work with all of the webpack loaders (e.g. style-loader
).
I have set out to develop one program to address all of the above. I have called it https://github.com/gajus/isomorphic-webpack. The rest of this post introduces to how it works and how to use it.
“Hello, World!”
Lets start with the “Hello, World” and build on that example.
Our example is a React application. It uses react-dom
to render ReactElement
and append the resulting DOM to the #app
element.
/src/app/index.js
import React from 'react';
import ReactDOM from 'react-dom';const app = <div>Hello, World!</div>;ReactDOM.render(app, document.getElementById('app'));
webpack
is configured to use babel-loader
to load the JavaScript files.
/src/webpack.config.js
import path from 'path';export default {
context: __dirname,
entry: {
app: [
path.resolve(__dirname, './app')
]
},
module: {
loaders: [
{
include: path.resolve(__dirname, './app'),
loader: 'babel-loader',
test: /\.js$/
}
]
},
output: {
filename: '[name].js',
path: path.resolve(__dirname, './dist')
}
};
A server-side script is using webpack
to compile the application and express
to server the contents.
/src/bin/server.js
Note:
webpack-dev-middleware
is not a dependency ofisomorphic-webpack
. Here it is used only to serve client-side application.
import express from 'express';
import webpack from 'webpack';
import webpackDevMiddleware from 'webpack-dev-middleware';
import webpackConfiguration from '../webpack.configuration';const compiler = webpack(webpackConfiguration);const app = express();app.use(webpackDevMiddleware(compiler, {
noInfo: false,
publicPath: '/static',
quiet: false,
stats: 'minimal'
}));app.get('/', (req, res) => {
res.send(`
<!doctype html>
<html>
<head></head>
<body>
<div id='app'></div> <script src='/static/app.js'></script>
</body>
</html>
`);
});app.listen(8000);
This isn’t an isomorphic application. Making an HTTP request simply responds with the string hard-coded in the server-side script.
$ curl http://127.0.0.1:8000<!doctype html>
<html>
<head></head>
<body>
<div id='app'></div> <script src='/static/app.js'></script>
</body>
</html>
An isomorphic application would evaluate the React application code and respond with the rendered application.
If you want to just checkout the code, use the following commands:
$ git clone git@github.com:gajus/isomorphic-webpack-demo.git
$ cd isomorphic-webpack-demo
$ git reset --hard f66783c89040c0fc19a19df961cbb2633f27348d
$ npm install
$ npm start
Isomorphic “Hello, World!”
What does it take to make the above application isomorphic?
The following changes need to be made to our code base:
- Install
isomorphic-webpack
- Setup
isomorphic-webpack
using the webpack configuration - Export the application as a module.
- Use
react-dom/server
renderToString
to render the application.
Here is how that changes our example application:
/src/app/index.js
needs to export the application:
import React from 'react';
import ReactDOM from 'react-dom';const app = <div>Hello, World!</div>;if (typeof ISOMORPHIC_WEBPACK === 'undefined') {
ReactDOM.render(app, document.getElementById('app'));
}export default app;
ISOMORPHIC_WEBPACK
is a constant used to differentiate between Node.js and browser environment. Presence of the constant indicates that it is a Node.js environment.
The server-side script needs to initialise the createIsomorphicWebpack
compiler and use react-dom/server
renderToString
to render the contents of the application:
import express from 'express';
import webpack from 'webpack';
import webpackDevMiddleware from 'webpack-dev-middleware';
import {
renderToString
} from 'react-dom/server';
import {
createIsomorphicWebpack
} from 'isomorphic-webpack';
import webpackConfiguration from '../webpack.configuration';const compiler = webpack(webpackConfiguration);createIsomorphicWebpack(webpackConfiguration);const app = express();app.use(webpackDevMiddleware(compiler, {
noInfo: false,
publicPath: '/static',
quiet: false,
stats: 'minimal'
}));const renderFullPage = (body) => {
return `
<!doctype html>
<html>
<head></head>
<body>
<div id='app'>${body}</div> <script src='/static/app.js'></script>
</body>
</html>
`;
};app.get('/', (req, res) => {
const appBody = renderToString(require('../app').default); res
.send(renderFullPage(appBody));
});app.listen(8000);
createIsomorphicWebpack
overrides Node.js module resolution system, i.e. all require()
calls that refer to resources that are part of the webpack
bundle will be handled by isomorphic-webpack
.
This made our application isomorphic. Making an HTTP request responds with the rendered React application:
$ curl http://127.0.0.1:8000/<!doctype html>
<html>
<head></head>
<body>
<div id='app'>
<div data-reactroot="" data-reactid="1" data-react-checksum="1607472067">Hello, World!</div>
</div><script src='/static/app.js'></script>
</body>
</html>
If you want to just checkout the code, use the following commands:
$ git reset --hard 4fb6c11d488405a7c9b7f5a7cda4abec2396be00
$ npm install
$ npm start
Using Webpack loaders
Loaders allow you to preprocess files as you
require()
or “load” them. [..] Loaders can transform files from a different language like, CoffeeScript to JavaScript, or inline images as data URLs. Loaders even allow you to do things like require() css files right in your JavaScript!
– https://webpack.github.io/docs/loaders.html
For the purpose of this demonstration, I am going to show how to use style-loader
with css-loader
.
First, we need to update our webpack configuration.
/src/webpack.config.js
import path from 'path';export default {
context: __dirname,
entry: {
app: [
path.resolve(__dirname, './app')
]
},
module: {
loaders: [
{
include: path.resolve(__dirname, './app'),
loader: 'babel-loader',
test: /\.js$/
},
{
loaders: [
{
loader: 'style-loader',
query: {
sourceMap: 1
}
},
{
loader: 'css-loader',
query: {
importLoaders: 1,
localIdentName: '[path]___[name]___[local]',
modules: 1
}
}
],
test: /\.css$/
}
]
},
output: {
filename: '[name].js',
path: path.resolve(__dirname, './dist')
}
};
Note: There is nothing
isomorphic-webpack
specific in the above configuration. I am including the configuration only for completeness of the example.
Next, create a style sheet.
/src/app/style.css
.greetings {
color: #f00;
}
Update the application to use the style sheet:
import React from 'react';
import ReactDOM from 'react-dom';
import style from './style.css';const app = <div className={style.greetings}>Hello, World!</div>;if (typeof ISOMORPHIC_WEBPACK === 'undefined') {
ReactDOM.render(app, document.getElementById('app'));
}export default app;
Finally, restart the application and make an HTTP request.
$ curl http://127.0.0.1:8000/<!doctype html>
<html>
<head></head>
<body>
<div id='app'>
<div class="app-___style___greetings" data-reactroot="" data-reactid="1" data-react-checksum="72097819">Hello, World!</div>
</div> <script src='/static/app.js'></script>
</body>
</html>
As you see, the server responds with the evaluated value of the class
attribute, app-___style___greetings
.
If it feels like you haven’t learned anything new in this section, then thats because there isn’t anythingisomorphic-webpack
specific. There are no isomorphic-webpack
specific changes to the configuration or the application. That is a truly universal code base.
If you want to just checkout the code, use the following commands:
$ git reset --hard 90d6e2708719a1727f2f8afd06f8b47432707b88
$ npm install
$ npm start
Routes
This section describes an experimental implementation. I have not tested this in production. Proceed with caution. It is pretty cool, though.
react-router
documentation already includes a section about server rendering. You could follow that path… (but it requires to write server-side specific code) or you could trick react-router
into thinking that the script is running in a browser and avoid making any changes to your application.
By default, the createIsomorphicWebpack
does not evaluate scripts in node_modules
directory. This is done for performance reasons: few scripts depend on the browser environment. However, react-router
(and history
) do depend on browser environment.
I am going to tell createIsomorphicWebpack
to evaluate react-router
(and history
) as if it is running in a browser. This is done using nodeExternalsWhitelist
configuration.
createIsomorphicWebpack(webpackConfiguration, {
nodeExternalsWhitelist: [
/^react\-router/,
/^history/
]
});
Now react-router
and history
packages are included in the webpack bundle and will be executed using the faux browser environment.
However, we aren’t done yet. We need to tell what is the window
URL when evaluating the code. Bundle code can be evaluated using evalCode
function (evalCode
is a property of the createIsomorphicWebpack
result), e.g.
const {
evalCode
} = createIsomorphicWebpack(webpackConfiguration, {
nodeExternalsWhitelist: [
/^react\-router/,
/^history/
]
});app.get('/*', (req, res) => {
evalCode(req.protocol + '://' + req.get('host') + req.originalUrl);
const appBody = renderToString(require('../app').default);
res.send(renderFullPage(appBody));
});
Now, lets make some requests:
$ curl http://127.0.0.1:8000/hello-world<!doctype html>
<html>
<head></head>
<body>
<div id='app'><div class="app-___style___greetings" data-reactroot="" data-reactid="1" data-react-checksum="72097819">Hello, World!</div></div><script src='/static/app.js'></script>
</body>
</html>$ curl http://127.0.0.1:8000/hello-magic<!doctype html>
<html>
<head></head>
<body>
<div id='app'><div data-reactroot="" data-reactid="1" data-react-checksum="1580012444">Hello, Magic!</div></div><script src='/static/app.js'></script>
</body>
</html>
If you want to just checkout the code, use the following commands:
$ git reset --hard 2959593fe217abada30d6ebe2c510e07a477c76b
$ npm install
$ npm start
Conclusion
There are already many articles that discuss the pros and cons of server-side rendering (e.g. You’re Missing the Point of Server-Side Rendered JavaScript Apps). In my specific case, I needed server-side rendering to enable Edge Side Includes (ESI). I have achieved this by first writing the client-side application and then using isomorphic-webpack to render the application server-side.
Evaluate the pros and cons of server-side rendering and if the pros outweigh the cons, then consider using isomorphic-webpack to make your application render server-side.
Where to go next?
- Pull the https://github.com/gajus/isomorphic-webpack-demo
- Read the FAQ of the
isomorphic-webpack
. There are some useful tips such as how to stall the HTTP request handling whileisomorphic-webpack
has not completed a compilation.
Hacker Noon is how hackers start their afternoons. We’re a part of the @AMI family. We are now accepting submissions and happy to discuss advertising & sponsorship opportunities.
If you enjoyed this story, we recommend reading our latest tech stories and trending tech stories. Until next time, don’t take the realities of the world for granted!