Gajus

@gajus

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 of isomorphic-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:

  1. Install isomorphic-webpack
  2. Setup isomorphic-webpack using the webpack configuration
  3. Export the application as a module.
  4. 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>
Free isomorphism.

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.

You just won all the loaders!

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>

It takes a magician to know a magician.

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?

More by Gajus

Topics of interest

More Related Stories