Zac Fukuda
022

Tutorial - Universal App with React Router

The source code is available on Github

This tutorial guides you through how to create an universal app with React Router and Express.js.

The term “universal” means, in short, “one fits all.” And in here, the “one” stands for Javascript, and the “all” stands for the server and client side. Its implementation is still yet in an early stage, Javascript is getting capable of doing more and more things, and it is phenomenal.

Motivation

This tutorial has two following distinctions from other tutorials on React Router or the universal app.

1. Centralized routes

With the support of React Router Config, all routes are managed in src/routes.js, and the rendering code is optimized. This also enables us to render data components on the server.

2. Rendering data component with Fetch API

Rather than the data pre-defined as a variable inside Javascript file, the app fetches books data that is saved as in JSON file and renders data component on both front and server side.

About the App

Simple Universal App with React Router
Screenshot of Book page

We are going to create simple static-page website, covering how to redirect or show the 404 page, and how to render the component using fetch(). The app has six pages in total.

  • Home [/]: Display simple text.
  • Book [/book]: Display the list of books, which is retrieved by fetch().
    • Book Detail [/book/:slug]: Display the detail of the book which matches the given slug.
  • About [/about]: Display simple text.
  • Movie [/movie]: Redirect to the Book page because the page doesn’t exist.
  • 404 [/foo]: Show 404 page because the page doesn’t exist.

Prerequisite

In order to follow this tutorial, the reader must…

  • Have Node.js & NPM installed on your PC.
  • Understand the basic of the server side Javascript programming.
  • Understand the basic of React.

File Preparation

To create our project directory, we use the create-react-app module. If you have’t installed the module globally, please open the terminal and run:

$ npm install -g create-react-app

Let’s create a project with:

$ create-react-app universal-app-react-router
$ cd universal-app-react-router

The final file structure of this app will be as follows:

.
├── build
├── node_modules
├── package.json
├── public
│   ├── books.json
│   ├── favicon.ico
│   ├── index.html
│   └── manifest.json
├── scripts
│   └── build2.js
├── src
│   ├── components
│   │   ├── Book
│   │   │   ├── All.js
│   │   │   ├── Single.js
│   │   │   └── index.js
│   │   ├── About.js
│   │   ├── Header.js
│   │   ├── Home.js
│   │   ├── Main.js
│   │   ├── NotFound.js
│   │   └── RedirectWithStatus.js
│   ├── App.js
│   ├── index.css
│   ├── index.js
│   └── routes.js
├── view
│   └── index.ejs
├── .babelrc
├── server.js
├── webpack.config.js
└── yarn.lock

There are some unnecessary files generated by the create-react-app. Please delete those files executing:

$ rm src/registerServiceWorker.js src/logo.svg src/App.test.js src/App.css

Let’s create the json file called books.json, which contains the data of books that will be fetched by the app.

$ touch public/books.json

And add the data:

books.json
{
	"books": [
		{
			"id": "1",
			"slug": "david-and-goliath",
			"title": "David and Goliath",
			"author": "Malcolm Gladwell",
			"description": "In David and Goliath, Malcolm Gladwell challenges how we think about obstacles and disadvantages, offering a new interpretation of what it means to be discriminated against, suffer from a disability, lose a parent, attend a mediocre school, or endure any number of other apparent setbacks."
		},
		{
			"id": "2",
			"slug": "eat-pray-love",
			"title": "Eat, Pray, Love",
			"author": "Elizabeth Gilbert",
			"description": "Elizabeth Gilbert’s Eat Pray Love touched the world and changed countless lives, inspiring and empowering millions of readers to search for their own best selves. Now, this beloved and iconic book returns in a beautiful 10th anniversary edition, complete with an updated introduction from the author, to launch a whole new generation of fans. "
		},
		{
			"id": "3",
			"slug": "the-future",
			"title": "The Future",
			"author": "Al Gore",
			"description": "From his earliest days in public life, Al Gore has been warning us of the promise and peril of emergent truths—no matter how “inconvenient” they may seem to be. As absorbing as it is visionary, The Future is a map of the world to come, from a man who has looked ahead before and been proven all too right."
		},
		{
			"id": "4",
			"slug": "steve-jobs",
			"title": "Steve Jobs",
			"author": "Walter Isaacson",
			"description": "Based on more than forty interviews with Steve Jobs conducted over two years—as well as interviews with more than 100 family members, friends, adversaries, competitors, and colleagues—Walter Isaacson has written a riveting story of the roller-coaster life and searingly intense personality of a creative entrepreneur whose passion for perfection and ferocious drive revolutionized six industries: personal computers, animated movies, music, phones, tablet computing, and digital publishing. Isaacson’s portrait touched millions of readers."
		},
		{
			"id": "5",
			"slug": "capital-in-the-twenty-first-century",
			"title": "Capital in the Twenty-First Century",
			"author": "Thomas Piketty",
			"description": "In Capital in the Twenty-First Century, Thomas Piketty analyzes a unique collection of data from twenty countries, ranging as far back as the eighteenth century, to uncover key economic and social patterns. His findings will transform debate and set the agenda for the next generation of thought about wealth and inequality."
		}
	]
}

Stylesheet

If you want to add the minimal CSS, please grab the styles at index.css from Github and add it to src/index.css.

Client-Side Single Page App

Before diving into creating an universal appf, let us first create a client side single page application. Despite the fact that it’s called the “universal,” in order to make a single page app into universal, you need to concern what’s going to happen when the app will do the server-side rendering.

First, let’s create files and install dependencies:

$ touch src/routes.js
$ mkdir src/components && mkdir src/components/books
$ cd src/components
$ touch Header.js Main.js Home.js About.js RedirectWithStatus.js NotFound.js
$ touch books/index.js books/All.js books/Single.js
$ cd ../../
$ yarn add react-router-dom react-router-config es6-promise isomorphic-fetch

Now keep adding the codes below to each file.

src/index.js
import React from 'react'
import { render } from 'react-dom'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './index.css'

render((
	<BrowserRouter>
		<App />
	</BrowserRouter>
), document.getElementById('root'))

The <BrowserRouter> is one of <Router> components that uses the history API based on the URI requested, so that users can go back and forth with browser’s history. There is also a <HashRouter> which uses the hash portion of the URL. The usually the <BrowserRouter> nesting the <App> component does the jpb.

src/App.js
import React from 'react'
import Header from './components/Header'
import Main from './components/Main'

const App = () => (
	<div>
		<Header />
		<Main />
	</div>
)

export default App
src/components/Header.js
import React from 'react'
import { Link } from 'react-router-dom'

const Header = () => (
	<header>
		<h1>Universal React App</h1>
		<nav>
			<ul>
				<li><Link to='/'>Home</Link></li>
				<li><Link to='/book'>Book</Link></li>
				<li><Link to='/about'>About</Link></li>
				<li><Link to='/movie'>Movie/Redirect</Link></li>
				<li><Link to='/foo'>404</Link></li>
			</ul>
		</nav>
	</header>
)

export default Header
src/routes.js
import Home from './components/Home'
import Book from './components/Book/'
import BookAll from './components/Book/All'
import BookSingle from './components/Book/Single'
import About from './components/About'
import RedirectWithStatus from './components/RedirectWithStatus'
import NotFound from './components/NotFound'
import { polyfill }  from 'es6-promise'
import fetch from 'isomorphic-fetch'

polyfill()

// Custom function to load the data on the server-side
const loadData  = (match) => {
	// Alert a warning if not an absolute url
	return fetch('http://localhost:3000/books.json')
	.then(res => res.json())
}

const routes = [
	{ path: '/',
		exact: true,
		component: Home,
	},
	{ path: '/book',
		component: Book,
		routes: [
			{
				path: '/book',
				exact: true,
				component: BookAll,
				loadData: loadData
			},
			{
				path: '/book/:slug',
				component: BookSingle,
				loadData: loadData
			}
		]
	},
	{ path: '/about',
		component: About,

	},
	{
		path: '/movie',
		component: RedirectWithStatus,
		status: 301,
		to: '/book'
	},
	{
		path: '*',
		component: NotFound
	}
]

export default routes

The routes.js is the centralized route file which makes the app more manageable and simpler. The loadData is a custom function that will be used on the server-side to fetch books data. The function is assigned to the routes regarding the Book Page.

Although the latest Google Chrome supports fetch() API by default, Node.js v6.11.0 doesn’t. Therefore, we need to install isomorphic-fetch. The isomorphic-fetch is a fetching module designed to be used on both server and client side. For the server-side use only, there is an alternative module called node-fetch.

src/components/Main.js
import React from 'react'
import { Switch } from 'react-router-dom'
import { renderRoutes } from 'react-router-config'
import routes from '../routes'

const Main = () => (
	<main>
		<Switch>
			{renderRoutes(routes)}
		</Switch>
	</main>
)

export default Main

The renderRoutes is a method that returns the list of Route components based on the given routes value. In typical way without the renderRoutes, the inside of Switch would be looked like:

<Route exact path='/' component={Home}/>
<Route path='/book' component={Book}/>
<Route path='/about' component={About}/>
<RedirectWithStatus status={301} from="/movie" to="/book"/>
<Route path='*' component={NotFound}/>

Since the code the above uses React Router Config, the finished code is in the advanced form. However, I believe the Route is the most important component in React Route, so understanding how to work with it is crucial.

Generally, the main role of Route is to render the assigned view when a location matches its path value. The Route also accepts exact and strict values, which define the strictness of path value to the location. To more understand the use of exact and strict values, please check out React Training’s documentation.

The Route components shouldn’t have be always nested with the Switch. But without Switch, all Route components that matches the location will be rendered. So most of the case you want to wrap with Switch. For example in the code above, if we remove Switch and exact value from the first Route, the the first Route component, i.e. Home, will be rendered on the every page.

/src/components/Home.js
import React from 'react'

const Home = () => (
	<div>
		<h2>Home</h2>
		<p>Hi, </p>
		<p>If you are seeing this message, your universal application is properly running. Please keep on checking by cliking the link buttons inside the header, which were generated from the <em>Link</em> element of React Router.</p>
	</div>
)

export default Home

I will explain about the Book components later. So please continue.

/src/components/About.js
import React from 'react'

const About = () => (
	<div>
		<h2>About</h2>
		<p>This application is a simple universal app built with React Router and Express.js.</p>
		<p>Although the performance of the front-end driven website is incredible—faster, less CPU and memory usage—the one of the biggest concerns is the SEO. In usual way, the search engines don’t recognize the content that is dynamically generated on the client side, and this fact causes that however remarkable content you provide to users, your app just return the blank page to the search engines. This isn’t right. To be your app indexed by Google, you also have to render the content on the server-side just for the robots.</p>
		<p>A <em>Universal App</em> is a application that is a single-page application, yet also renders the content on the server side. Good for users, good robots.</p>
	</div>
)

export default About
/src/components/RedirectWithStatus.js
import React from 'react'
import { Route, Redirect } from 'react-router-dom'

const RedirectWithStatus = ({route}) => (
	<Route render={({ staticContext }) => {
		if (staticContext)
			staticContext.status = route.status
		return <Redirect to={route.to}/>
	}}/>
)

export default RedirectWithStatus

The RedirectWithStatus component is mostly based on the sample from React’s Training, but since the app uses React Router Config, a bit of modification has been done.

/src/components/NotFound.js
import React from 'react'
import { Route } from 'react-router-dom'

const Status = ({ code, children }) => (
	<Route render={({ staticContext }) => {
		if (staticContext)
			staticContext.status = code
		return children
	}}/>
)

const NotFound = () => (
  <Status code={404}>
    <div>
      <h1>Sorry, can’t find that.</h1>
    </div>
  </Status>
)

export default NotFound

Books – Data-fetch Component

Now it’s time to create the components regarding books.

/src/components/Book/index.js
import React from 'react'
import { Switch } from 'react-router-dom'
import { renderRoutes } from 'react-router-config'

const Book = ({route}) => (
	<Switch>
		{renderRoutes(route.routes)}
	</Switch>
)

export default Book

When you use renderRoutes, the value of the matched route is passed to the rendered component. Thus here, the route.routes is an array which has the routes of /book and /book/:slug, and renderRoutes will render two Routes.

/src/components/Book/All.js
import React, { Component } from 'react'
import { Link } from 'react-router-dom'
import { polyfill }  from 'es6-promise'
import fetch from 'isomorphic-fetch'

polyfill()

class BookAll extends Component {
	constructor(props) {
		super(props)
		this.state = this.props.staticContext || { books: [] }
	}

	componentDidMount() {
		fetch('/books.json')
		.then(res => res.json())
		.then((json) => {
			this.setState({
				books: json.books
			})
		})
	}

	render() {
		let books = this.state.books.map(book => (
			<li key={book.id}>
				<Link to={`/book/${book.slug}`}><b>{book.title}</b></Link> by <em>{book.author}</em>
			</li>
		))
		return (
			<div>
				<div>
					<h2>Books</h2>
					<ul>{books}</ul>
				</div>
			</div>
		)
	}
}

export default BookAll
/src/components/Book/Single.js
import React, {Component} from 'react'
import { Link } from 'react-router-dom'
import { polyfill }  from 'es6-promise'
import fetch from 'isomorphic-fetch'

polyfill()

class BookSingle extends Component {
	constructor (props) {
		super(props)
		let book = {}
		let slug = this.props.match.params.slug

		// Set 'book' if it’s on server-side
		if (this.props.staticContext) {
			 book = this.props.staticContext.books.find(book => book.slug === slug)
		}
		 
		this.state = { book: book, slug: slug }
	}

	componentDidMount () {
		let slug = this.state.slug
		fetch('/books.json')
		.then(res => res.json())
		.then((json) => {
			let book = json.books.find(book => book.slug === slug)
			this.setState({book: book})
		})
	}

	render () {
		let book = this.state.book
		return (
			<div>
				<h3>{book.title}</h3>
				<h5 style={{textAlign: 'right'}}><em>{book.author}</em></h5>
				<p>{book.description}</p>
				<p style={{color:'#aaa'}}>
					<small>The text above was copied and pasted from Amazon.com.</small>
				</p>
				<br/>
				<p><Link to='/book'>Back to all books</Link></p>
			</div>
		)
	}
}

export default BookSingle

Inside of constructor of both BookAll and BookSingle, the property called staticContext is passed from the parental component, and the state is set based on that prop. This staticContext is a unique prop that is only being used on the server-side to pass the specific data throughout the app for triggering a certain event like redirection.

Running the Client-side App

Now you can run the client-side app with:

$ yarn start

After executing the command, this automatically open the browser window at localhost:3000, and it refreshes the page every time you modify the files under the src directory. This will save the vast amount of time in development.

Universal App (Server-side)

Basically, you have to create one new file and need two new dependencies for the server-side app.

$ touch server.js
$ yarn add express ejs

And the inside of server.js looks like this:

server.js
import fs from 'fs';
import path from 'path'
import express from 'express'
import React from 'react'
import ReactDOMServer, { renderToString } from 'react-dom/server'
import { StaticRouter } from 'react-router-dom'
import { matchRoutes } from 'react-router-config'
import App from './src/App'
import routes from './src/routes'

const app = express()
const viewPath = process.env.DEVELOPMENT ? 'view' : 'build'

// Set view engine & serve static assets
app.set('view engine', 'ejs')
app.set('views', path.join(__dirname, viewPath))
app.use(express.static(path.join(__dirname, 'build')))

// Always return the main index.html, so react-router render the route in the client
app.get('*', (req, res) => {
	const branch = matchRoutes(routes, req.url)
	const promises = []

	branch.forEach( ({route, match}) => {
		if (route.loadData)
			promises.push(route.loadData(match))
	})

	Promise.all(promises).then(data => {
		// data will be an array[] of datas returned by each promises.
		// console.log(data)

		const context = data.reduce( (context, data) => {
			return Object.assign(context, data)
		}, {})

		const html = renderToString(
			<StaticRouter location={req.url} context={context} >
				<App/>
			</StaticRouter>
		)

		if(context.url) {
			res.writeHead(301, {Location: context.url})
			res.end()
		}
		return res.render('index', {html})
	})
})

// Run server
const port = process.env.PORT || 3000
app.listen(port, err => {
	if (err) return console.error(err)
	console.log(`Server listening at http://localhost:${port}`)
})

The matchRoutes is an API that returns an array of all routes that match the current requested URL. The application creates an array of Promises if the matched route has loadData value, and having passed the array to the Promise.all(), retrieving the certain data on the process, the renderToString generates and returns HTML components by passing the data fetched in the proceeding process to StaticRouter. The context data is accessible through staticContext prop by each component—remember we define state based on staticContext in the book components—and that’s what makes this app universal.

Again, how I load, or fetch, the data is based on the sample from React Training and React Router Config. There might be the dozen of other ways to load the data, so please find the way that suits your situation on your own.

Build, Run, & Develop

Scripts

In addition to the react-scripts commands, we have to add new NPM scripts to build and run the app.

  • build
    After the React Scripts’ default build, this generates index.ejs into build directory based on the built index.html.
  • watch
    Build non-hashed bundled Javascript and CSS files from src to build, watching the file changes.
  • server
    Run the production app.
  • server-dev
    Run the development app, serving the index.ejs from view directory. Non-hashed files must be built with watch before the initial run.
  • server-dev:watch
    Run the development app with generating non-hashed files as watching file changes. Usually this script is to be used instead of watch and server-dev when you develop the server-side application.

The the part of package.json will be like as follows:

  "scripts": {
    …
    "build": "react-scripts build && node scripts/build2.js",
    …
    "watch": "webpack --watch",
    "server": "nodemon server.js --watch server.js --watch src --exec babel-node",
    "server-dev": "DEVELOPMENT=true nodemon server.js --watch server.js --watch src --exec babel-node",
    "server-dev:watch": "npm-run-all --parallel server-dev watch"
  },

Now we’re going to add the codes to build and run the app.

Build

Please create a new file:

$ mkdir scripts && touch scripts/build2.js

And add the following code:

scripts/build2.js
/**
 * Custom Node script to generate index.ejs based on the index.html file.
 */

const path = require('path');
const fs = require('fs');
const htmlPath = path.resolve('build/index.html');
const ejsPath = path.resolve('build/index.ejs');

// PROGRESS start
console.log('Start generating an index.ejs based on the index.html...');

// READ index.html
let html = fs.readFileSync(htmlPath, 'utf8');
let ejs = html.replace('<div id="root">', '<div id="root"><%- html %>');
console.log('Reading the index.html file completed! Proceeding to writing...')

// WRITE index.ejs
fs.writeFileSync(ejsPath, ejs, 'utf8');
console.log('The index.ejs has been saved! Deleting the existing index.html...');

// DELETE index.html
fs.unlinkSync(htmlPath);
console.log("The Process completed!\r\n");

This task generates the index.ejs file to the build and remove the existing index.html. Now run:

$ yarn build

And this creates the production files boundling the files from public and src to the build.

Run

In order to watch the file chages of server.js and the files under src, we’ll use Nodemon instead of default Node CLI, and since the server.js is written in the ES6 syntax, so we need to execute with the babel-node.

$ yarn add --dev nodemon babel-cli babel-preset-es2015 babel-preset-react

And create .babelrc:

$ touch .babelrc

Then add the Babel’s presets:

.babelrc
{
  "presets": ["react", "es2015"]
}

Finally, let’s start running the server with:

$ yarn server

The app will be running at localhost:3000.

Since the app is now to be run with Express.js, we can no longer use the start command of React Scripts. Nor will the browser be automatically opened or refreshed.

Develop

This section is optional to follow, but if you want to know how to develop the server-side application, I recommend to do so.

First, please add dependencies and create webpack.config.js:

$ yarn add --dev babel-core babel-loader webpack npm-run-all
$ touch webpack.config.js

And the inside of webpack.config.js will look like this:

const path = require("path");
const webpack = require('webpack');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const autoprefixer = require('autoprefixer');

module.exports = {
	devtool: 'source-map',
	entry: [
		'./src/index.js',
	],
	output: {
		path: path.resolve(__dirname, 'build'),
		filename: 'static/js/[name].js',
		chunkFilename: 'static/js/[name].chunk.js',
		publicPath: '/',
	},
	module: {
		strictExportPresence: true,
		rules: [
			{
				test: /\.(js|jsx)$/,
				include: path.resolve(__dirname, 'src'),
				loader: 'babel-loader',
				options: {
					compact: true,
					cacheDirectory: true,
				}
			},
			{
				test: /\.css$/,
				loader: ExtractTextPlugin.extract({
					publicPath: path.resolve(__dirname, 'build'),
					fallback: 'style-loader',
					use: [
						{
							loader: 'css-loader',
							options: {
								importLoaders: 1,
								minimize: true,
								sourceMap: true,
							},
						},
						{
							loader: 'postcss-loader',
							options: {
								ident: 'postcss',
								plugins: () => [
									require('postcss-flexbugs-fixes'),
									autoprefixer({
										browsers: [
											'>1%',
											'last 4 versions',
											'Firefox ESR',
											'not ie < 9', // React doesn't support IE8 anyway
										],
										flexbox: 'no-2009',
									}),
								]
							},
						}
					],
				})
			} // test: /\.css$/
		] // rules
	},
	plugins: [
		new ExtractTextPlugin({
			filename: 'static/css/[name].css',
		}),
	],
	node: {
		dgram: 'empty',
		fs: 'empty',
		net: 'empty',
		tls: 'empty',
	},
}

You might have realized that in the development mode, the index.ejs will be served from view directory.

$ mkdir view && touch view/index.ejs

And add the code:

view/index.ejs
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <title>React App</title>
    <link rel="manifest" href="/manifest.json">
    <link rel="shortcut icon" href="/favicon.ico">
    <link rel="stylesheet" href="/static/css/main.css">
  </head>
  <body>
    <div id="root"><%- html %></div>
    <script src="/static/js/main.js"></script>
  </body>
</html>

Although there are two commands watch and server-dev. most of the time server-dev:watch will do the job.

$ yarn server-dev:watch

This time again the app will be run at localhost:3000, and the task will keep watching the file changes, generating the non-hashed bundled files to the build.

To be Fixed

The app you just created is called “universal,” so that sounds like the the app will do the best performance, however, it’s not. There are some parts that must be improved.

1. Rendering Twice

The essence of the universal app is to use the same code for both client and server side, but occasionally it causes to affect the user experience in a bad way. For example, the app we just created renders the component on the server-side, and after the HTML delivered to the client, the app will re-render the components that have been already rendered. Which means that when you open the /book or /book/:slug page, the app will fetch data twice, one on the server, another from the browser. Surely, this is not what we want, and we need to fix this.

2. Fetching Same Data Multiple Times

Imagine that one user visits the pages in the order of Book, Home, and Book again. When the Book page is opened for the first time, it makes to fetch the book data, but when the second time the Book page is opened, the data does’t have to be fetched because we have fetched them already at the initial open. Nevertheless, the app will try to fetch the data every time the Book page is opened. And this is the second thing to be fixed. I haven’t give a close look to the Redux, but I think that’s the library we are looking for in order to keep the data persistently throughout the different Route components.

Other Tutorials

Thanks to all worldwide generous developers who share and provide the great tutorials and sample codes. This article is based the following source and they must partly be credited for the publishing of this article.