An isomorphic web app gives you the best of both server side rendering and single page application (SPA).
To get the code up and running on localhost:3000
$ git clone https://github.com/xiaoyunyang/isomorphic-router-demo.git$ cd isomorphic-router-demo$ npm install$ npm start
This is the repository to check out the code: https://github.com/xiaoyunyang/isomorphic-router-demo
There have been many articles written about the benefits of an isomorphic web app, like this, this, this, and this. There’s a book being written about it. I like to think of an isomorphic web app as SPA 2.0. It’s SPA with server side rendering.
A SPA is a client side (browser rendered) application that bootstraps the entire website after initial load. This means when you visit example.com using your browser, example.com’s server sends a HTML template and some javascript for your browser to execute the code that renders the actual content of the webpage. Because the tight coupling of the code that creates the DOM and the DOM, SPAs can handle complex DOM manipulation.
We are all familiar with the features of a SPA: quick response to user input, highly interactive webpages (think google docs), and ability to use it offline once the page loads. Most importantly for a startup founder such as myself who’s trying to quickly create a prototype of a website with some dummy data, a SPA lets you build a website independently from a server application. In many cases, you can get away with not building a server application at all if you used a sophisticated front end library like React, Amazon S3 for hosting, and data you store in a CSV file. That’s exactly what I did for LooseLeaf.
This separation of concerns improves productivity initially when you are prototyping a MVP for your website, but there’s a point of diminishing returns for a website deployed as a SPA that talks to a server with an API for data. The main disadvantages of this approach are:
because the website is bootstrapped, it takes some time for the page content to display itself after the initial load. Initial load occurs when you type the example.com into your browser and press enter. Whatever the browser gets back from the initial load is whatever the server sends.
If the server sends a blank HTML template and javascript to render stuff into that template, then the user will see a blank page and a maybe a page loading animation. How long the user has to wait until something is displayed scales with the complexity of the webpage and how fast their internet service is so on a mobile device, pages tend to load much slower.
Search engines and social sharing are two of the most important means of acquiring new users.
Think of search engine optimization (SEO) as ways to get Google to rank your webpage higher on the list to relevant query searches. For Google to rank your webpage content, it needs to know what content’s in your webpage. Google deploys an army of crawlers, which are just programs that make requests to webpages, look at the response, scrap content off the HTML, and look at how to rank that webpage amongst other webpages on the internet based on relevance. These crawlers don’t generally run JavaScript or wait around for a long time for the page to render itself. If your webpage gives the crawler blank pages on initial load, then Google will not know what your page is about to accurately place your webpage high up on the hits list when a relevant search query is entered on google.com.
The same thing happens with social media sites like Facebook and Twitter sharing who have their own army of crawlers to render a preview of the page based on meta tags in the header of HTML. The header is rendered on the server side and don’t change when the content changes based on dynamic loading when the webpage is bootstrapped in the browser. This means if you have a website that sells books and a SPA that uses the same template HTML to render different pages for different books, then when you share a link to the page for a particular book on Facebook, the preview will display a generic preview about your website which says something like it’s a place which sells thousands of titles, but will not display any unique information for the particular book. This article did a good job laying out the limitation of a SPA in its ability to generate unique header for social sharing and how to use server rendering to solve that.
If you are reading this, that means I’ve convinced you that a simple SPA is not the way to go. A pure server side application is not the way to go either because from a development standpoint, we want to be able to build our client application and server application separately. From a user experience standpoint, once a SPA is fully loaded, user experience may greatly exceed that of a server-rendered webpage. Also I don’t want the entire page to reload every time I click a button.
So the shortcoming of a pure SPA is in the initial load. The shortcoming of the pure server rendering solution is with what happens after the initial load. What can we do to get the best of both worlds? 🤔
Client side rendering and server side rendering complement each other. We can build an isomorphic web app that enhances the capability of a server rendered page with a SPA. The isomorphic web app starter project I’m about to introduce takes advantage of the fact that JavaScript is used to build both the client application and the server application. This promotes code reusability as we can use the same code to render the SPA as well as the HTML for the server to send for the initial load.
All the code is contained in this repository. I’ll be walking through snippets of code from there for the remainder of this article.
xiaoyunyang/isomorphic-router-demo_isomorphic-router-demo - demo project to show you how to set up an isomorphic webapp using React Router 4 and…_github.com
The stack for this starter project include Node, Express, React, React Router 4, and react-router-config, babel, and Webpack 4. I’m not using any third party universal application middleware or frameworks such as React Universal Component or Loadable Component.
The project is divided into server-specific code, client-specific code for rendering the SPA, and shared code, which support both server and client rendering.
~/isomorphic-router-demo$ tree -l 3 --ignore 'node_modules'
/isomorphic-router-demo
├── build| └── main.bundle.js├── client| └── main.js├── iso-middleware| └── renderRoute.js├── package.json├── .babelrc├── .env├── server| ├── run.js| └── server.js├── shared| ├── App.js| ├── components| | ├── About.js| | ├── HTML.js| | ├── TopNav.js| | ├── Home.js| | ├── Main.js| | └── NotFound.js| └── routes.js└── webpack.config.js
The main entry point for the shared code is the <App>
component:
// shared/App.js
import React from 'react';import TopNav from './components/TopNav';import Main from './components/Main';
const App = () => (<div><TopNav /><Main /></div>);
export default App;
It’s a pretty standard top React component, which uses sub-components to render different pages.
<TopNav>
defines navigation around the app using React Router’s <Link>
component:
// shared/components/TopNav.js
import React from 'react';import { Link } from 'react-router-dom';
export default () => (<nav><div className="nav-wrapper"><a href="/" className="brand-logo">Demo</a><ul id="nav-mobile" className="right"><li><Link to="/">Home</Link></li><li><Link to="/about">About</Link></li><li><Link to="/foo">Foo</Link></li></ul></div></nav>);
The mapping for which page to serve based on the route is contained in routes.js
, which is imported into the <Main>
component.
// shared/routes.jsimport Home from './components/Home';import About from './components/About';import NotFound from './components/NotFound';
const routes = [{path: '/',exact: true,component: Home},{path: '/about',component: About},{path: '*',restricted: false,component: NotFound}];
export default routes;
In the <Main>
component, the [react-router-config](https://github.com/ReactTraining/react-router/tree/master/packages/react-router-config)
renderRoutes function is used to generates the <Route>
component based on the path to component mapping defined in routes
.
import React from 'react';import { Switch } from 'react-router-dom';import { renderRoutes } from 'react-router-config';
import routes from '../routes';
const Main = () => (<Switch>{renderRoutes(routes)}</Switch>);
export default Main;
As defined in routes
, the pages you can render are Home, About, and NotFound, as shown below:
Home Page
About page
NotFound page
The idea is we want the server to send the HTML for the Home page when the we type localhost:3000
into the browser and press enter.
We begin with mounting the middleware function for rendering the app to the server application as shown in server.js
as below:
// server/server.js
import express from 'express';import renderRouterMiddleware from '../iso-middleware/renderRoute';
// ...
app.get('*', renderRouterMiddleware);
// ...
renderRouterMiddleware
contains all the logic for creating the HTML string using the components in the shared
folder.
renderRouterMiddleware
is one of the most important file in our project, because it has the logic for making the app isomorphic.
For the most part, the server side rendering part of the code is pretty boilerplate, but has the secret ingredients for server side rendering of an HTML that makes it possible for the client application take over subsequent to the initial load. Specifically, the <HTML>
component, which is imported here for the server side rendering contains the tie to the client application. But before I show you the code <HTML>
we need to go over a few other things.
Side Note: Another thing worth noting is that for server rendering, we want to wrap our <App>
in React Router’s <StaticRouter>
component before converting everything to using React’s renderToString
function. For client rendering, which we are going to discuss next, we want to use <BrowserRouter>
.
The server code below provides the browser all the code that the browser needs to render a SPA:
// server/server.js
// ...
const buildPath = path.join(__dirname, '../', 'build');app.use('/', express.static(buildPath));app.use(express.static(__dirname));
// ...
This block of code is telling the server to serve static assets from the build folder, to localhost:3000/
.
As shown in the File Structure above, there’s only one file — main.bundle.js
— in the build folder. If you type localhost:3000/main.bundle.js
into your browser, you’ll see a bunch of JavaScript, which contains code from our shared
folder that has been transpiled down from ES6 to an earlier version of JavaScript.
main.bundle.js
is created by Webpack. In [package.json](https://github.com/xiaoyunyang/isomorphic-router-demo/blob/master/package.json)
, scripts have been set up to execute a build before starting the server so the main.bundle.js
is rebuilt every time we start the server.
The build definition is in our webpack.config.js
file, which defines ./client/main.js
as the entry for the build.
main.js
and everything it uses are bundled up into main.bundle.js
. Here’s the code for main.js
:
// client/main.js
import React from 'react';import ReactDOM from 'react-dom';import { BrowserRouter } from 'react-router-dom';import App from '../shared/App';
const renderRouter = Component => {ReactDOM.hydrate(<BrowserRouter><Component /></BrowserRouter>, document.getElementById('root'));};
renderRouter(App);
The first thing you probably noticed is thatReactDOM.hydrate
is used instead of ReactDOM.render
. This is because we want to attach the client rendered app to the server rendered HTML’s root
div. Although our app, which uses React v16, will work with ReactDOM.render
, React gives you a warning which says: “ ReactDOM.render() to hydrate server-rendered markup will stop working in React v17. Replace the ReactDOM.render() call with ReactDOM.hydrate() if you want React to attach to the server HTML.”
Remember I said earlier the <HTML>
component is the tie between server rendered HTML and the client rendered application?
<HTML>
, which is used by the server to render the HTML for the initial load, creates a root
div and dynamically loads main.bundle.js
in a script tag. This is what makes this isomorphic app work!
After starting up the app using npm start
, type localhost:3000
into your browser address bar, press enter, and you’ll see the home page is rendered after the page load wheel spins a bit in the browser tab.
The page load wheel spinning indicates the server has done some work to deliver this page to you. If you clicked About and Foo from the NavBar, you’ll see the About page and NotFound page load up without any page load wheel spinning in the browser tab. This tells you that the SPA mode has been kicked in and is handling the page navigation based on click events. In fact, the app can even run when you stop the server. Go ahead and stop the server from the terminal to see that you can still click around to load the pages just like before…but with one difference:
Home Page with Server Stopped
Instead of a message from the server and a random quote, you see the word “Loading”.
This is by design. I’ve tried to make the app more interesting by having it deliver a random inspirational quote to the Home page every time you navigate to the Home page via client app TopNav or loading it directly from the server.
This is also to demonstrate a common design pattern in modern web applications whereby parts of the page content is loaded after the page has loaded via an asynchronous fetch from an API.
The <Home>
component fetches some data from two API endpoints right after it mounts.
// shared/components/Home.js
import React from 'react';import fetch from 'isomorphic-fetch';
class Home extends React.Component {constructor(props) {super(props);this.state = {resHello: 'Loading...',resQuote: 'Loading...'};}componentDidMount() {// Get hello messagethis.callApi('http://localhost:3000/api/hello').then(res => this.setState({ resHello: res.express })).catch(err => console.log(err));
// Get random quote
const rand = Math.random();
this.callApi(\`[http://localhost:3000/api/quote/${rand}\`](http://localhost:3000/api/quote/$%7Brand%7D`))
.then(res => this.setState({ resQuote: res.express }))
.catch(err => console.log(err));
}
callApi = async function (endpoint) {
const response = await fetch(endpoint);
const body = await response.json();
if (response.status !== 200) throw Error(body.message);
return body;
}render() {console.log('rendering: Home');return (<div className="container"><h1>Home page</h1><h6>{`Message from the server: ${this.state.resHello}`}</h6><h5>Random Quote</h5><blockquote>{this.state.resQuote}</blockquote></div>);}}
export default Home;
The server code responsible for delivering data to those API end points are shown here:
// server/server.js
import apiVersion1 from './api/api1';
// ...app.use('/api', apiVersion1);
// ...
and here:
// server/api/api1.js
import express from 'express';
const api = express.Router();
// const quotes = ... too long to write it out here
api.get('/quote/:rand', (req, res) => {const rand = parseFloat(req.params.rand);if (Number.isNaN(rand)) {res.send({ express: 'Bad request.' });return;}const randomQuote = quotes[randomInd(rand)];res.send({ express: `${randomQuote}` });});
module.exports = api;
For simplicity, I hardcoded the quotes
JSON directly in the api code but you can have the quotes be in a quotes.json
file or stored in a database and use express middleware to fetch them before use.
An isomorphic web app supports both server side rendering and dynamic rendering of webpages. This provides a great amount of flexibility to develop your web apps and to get great user interface as well as good SEO and quick load time. But with great power, comes great responsibility. As web developers, we have to decide how to partition our web apps into smaller isomorphic applications, SPAs, or server rendered pages. We have to decide what to render dynamically in the browser and what to do only on the server side based on what makes more sense for the app we are building.
Once again, this is the repo for the isomorphic starter project:
https://github.com/xiaoyunyang/isomorphic-router-demo
I read many tutorials to set up this project with the latest and greatest stack. Attributions are provided in the repo’s README file.
I hope this article and starter project can help more people understand the motivation and concept behind an isomorphic web app and start using it.