React SSR

React SSR


Five weeks ago, our friend Vincent Composieux wrote an awesome article about migrating a React client-side application to server-side with Next.JS. But, sometimes you want to do it the plain vanilla way in order to take control over your workflow. Therefore, this article's purpose is to climb up the steps of developing an SSR React app from scratch.

A Reminder of SSR's benefits

Let me state the obvious, SSR provides a better SEO as compared to a traditional client-side rendered SPA (single page application). Considering the fact that most SPAs are asynchronous, which means that they need time to load their data, they cannot be fully indexed by search engine crawlers, because these crawlers won’t wait for them to render. This fact can be taken as a given which must be unraveled. SSR also provides a faster access to your content, thanks to server rendered markup which doesn’t depend on your assets to be loaded.

There are, however, some trade-offs that you have to think about. High availability is gonna cost you, big time. As a matter of fact, you are rendering your large app in your server. This needs serious resources in terms of CPU because you're way beyond serving only static assets. Interoperability is another headache to consider. You won't be able to provide a decent experience unless you overcome browser-specific code.

I’m gonna spare you the theatrics and get straight to the point. We're going to build a React app using SSR.

Before we get started...

Here are the prerequisites that you need:

I’m not gonna go through all the dependencies, but you can find them in the package.json.

Step 1: Setup the project

First things first, let's take a look at package.json scripts section:

... "scripts": { "start": "concurrently -k -p \"[{name} - {time}]\" -c \"cyan,magenta\" -n \"client,server\" \"yarn start-client\" \"yarn start-server\"", "build": "yarn run clean && webpack -p --progress", "clean": "rm -rf ./public/*", "start-client": "webpack --debug --watch", "start-server": "nodemon --exec ts-node src/server/index.ts --watch ./src -e ts,tsx" }, ...

As you can see, we are concurrently running 2 instances of Node, one for the server and the other for webpack. We aren't using webpack-dev-server because we don't need it. We just have to watch the files and compile them to be bundled. This will allow us to serve them statically from the public path.

Webpack

As we've seen earlier, we're gonna split our code into 2 bundles, main.js and vendor.js. The webpack config is as follows:

const path = require('path'); const webpack = require('webpack'); const NODE_ENV = process.env.NODE_ENV ? process.env.NODE_ENV.toLowerCase() : 'development'; const mode = NODE_ENV === 'development' ? 'dev' : 'prod'; module.exports = { devtool: 'source-map', entry: { main: path.join(__dirname, 'src', 'client', `index.${mode}`), }, plugins: [ new webpack.optimize.CommonsChunkPlugin({ name: 'vendor', filename: 'vendor.js', minChunks(module) { const context = module.context; return context && context.indexOf('node_modules') >= 0; }, }), ], output: { path: path.join(__dirname, 'public'), filename: '[name].js', chunkFilename: '[name].js', publicPath: '/', library: '[name]', libraryTarget: 'umd', umdNamedDefine: true, }, resolve: { extensions: ['.js', '.jsx', '.ts', '.tsx'], }, module: { rules: [ ... ], }, };

In the output we specify the path to the directory into which Webpack is gonna place the bundles path.join(__dirname, 'public'). This is the same place from where the server will serve the files.

The kind of output you will have is similar to this:

$ yarn start yarn start v0.27.5 $ concurrently -k -p "[{name} - {time}]" -c "cyan,magenta" -n "client,server" "yarn start-client" "yarn start-server" [server - 2017-10-11 12:57:29.361] [nodemon] 1.12.1 [server - 2017-10-11 12:57:29.368] [nodemon] to restart at any time, enter `rs` [server - 2017-10-11 12:57:29.368] [nodemon] watching: /Users/kamal/code/eleven-labs/articles/ssr/the-wilson-post/src/**/* [server - 2017-10-11 12:57:29.368] [nodemon] starting `ts-node src/server/index.ts` [client - 2017-10-11 12:57:29.619] [client - 2017-10-11 12:57:29.619] Webpack is watching the files… [client - 2017-10-11 12:57:29.619] [client - 2017-10-11 12:57:30.226] ts-loader: Using typescript@2.5.3 and /Users/kamal/code/eleven-labs/articles/ssr/the-wilson-post/tsconfig.json [server - 2017-10-11 12:57:30.508] ==> 🌎 Listening on port 9001. Open up http://localhost:9001/ in your browser. [client - 2017-10-11 12:57:38.220] Hash: 86f91e22ceb773dceb73 [client - 2017-10-11 12:57:38.220] Version: webpack 3.6.0 [client - 2017-10-11 12:57:38.220] Time: 8609ms [client - 2017-10-11 12:57:38.220] Asset Size Chunks Chunk Names [client - 2017-10-11 12:57:38.220] main.js 21.8 kB 0 [emitted] main [client - 2017-10-11 12:57:38.220] styles.js 447 bytes 1 [emitted] styles [client - 2017-10-11 12:57:38.220] vendor.js 2.29 MB 2 [emitted] [big] vendor [client - 2017-10-11 12:57:38.220] main.js.map 21.2 kB 0 [emitted] main [client - 2017-10-11 12:57:38.220] styles.js.map 682 bytes 1 [emitted] styles [client - 2017-10-11 12:57:38.220] vendor.js.map 2.76 MB 2 [emitted] vendor

Client Structure

Let's look at the client's structure:

├── src | ├── client | | ├── actions | | ├── assets | | ├── components | | ├── containers | | ├── epics | | ├── reducers | | ├── routes | | ├── services | | ├── store | | ├── constants.ts | | ├── index.dev.tsx | | └── index.prod.tsx | ├── ...

This is a typical React/Redux app that follows Dan Abramov's recommendations about presentational and container components. We're going to use redux-observable to handle side effects, and react-router to show how routing can be done.

Step 2: The Routing

We are going to build an application called "the-wilson-post". It's a kind of article scheduler. It will help our communications manager with handling the massive amount of article requests from Eleven Labs engineers.

Let's look at the routes:

// routes/index.ts import App from '../containers/App'; import Home from '../containers/Home'; export default [ { component: App, routes: [ { path: '/', exact: true, component: Home, }, ], }, ];

This module exports a simple array of routes structured as a tree. This array will be fed to the renderRoutes function in the react-router-config module. renderRoutes aims to mount the route that matches the requested page URL. Below you can see how it's done in the entry file of the application:

// index.prod.jsx import * as React from 'react'; import { render } from 'react-dom'; import { Provider } from 'react-redux'; import { renderRoutes } from 'react-router-config'; import { BrowserRouter } from 'react-router-dom'; import { INITIAL_STATE } from './constants'; import routes from './routes'; import createStore from './store/prod'; const store = createStore(INITIAL_STATE); render( <Provider store={store}> <BrowserRouter> {renderRoutes(routes)} </BrowserRouter> </Provider>, document.getElementById('root'), );

Notice: BrowserRouter uses the HTML5 history API, which makes it easier for engineers to handle UI state by using the native functions it provides like: pushState and replaceState.

In React Router, routes are simple components that render their children. These children might be simple components and/or routes. That is, everything in React Router is a component. In this article, we are using react-router v4 which introduced the Dynamic Routing concept that's based on the idea of routing while rendering.

renderRoutes is a function that takes as an argument a list of route objects. Each one of these objects contains a path and a component property among other properties. renderRoutes goes through the list and matches the current requested URL to mount the appropriate component. Which makes it eligible to guarantee the dynamic aspect of routing.

Our route objects also contains a children property, which is composed of child routes. This allows us to build components that can mount these child routes, as it can be seen in the following example where we are creating the main page component:

// client/containers/App.tsx import * as React from 'react'; import { renderRoutes, RouteConfig } from 'react-router-config'; interface Props { route?: RouteConfig; } export default class App extends React.Component<Props, object> { render() { const { route } = this.props; return ( <div className="wilson-post-app"> <nav> <h1>The Wilson Post</h1> <div> <Link to="/schedule-post">Schedule a post</Link> </div> </nav> {route && renderRoutes(route.routes)} </div> ); } }

Notice: You can do the same thing by using <Switch /> and <Route /> components. It is recommended to see how it's done in React Router's docs.

Here is the generated tree:

<Provider> <BrowserRouter> <Router> <Switch> <Route> <App> ... <Switch> <Route> <Home> ... </Home> </Route> </Switch> </App> </Route> </Switch> </Router> </BrowserRouter> </Provider>

Step 3: The Pages

In the previous example we've seen the app component that dynamically includes child routes. In case we reached the / in the URL, the inclusion triggers the mounting of the Home component, which will display a list of articles. There will be another page that will allow us to schedule a post. Here are the final routes that we will have:

// client/routes/index.ts import App from '../containers/App'; import Home from '../containers/Home'; import Scheduler from '../containers/Scheduler'; import NotFound from './NotFound'; export default [ { component: App, routes: [ { path: '/', exact: true, component: Home, }, { path: '/schedule-post', exact: true, component: Scheduler, }, { path: '*', component: NotFound, }, ], }, ];

You will find these pages in the source code. We will talk about the NotFound route a little bit later.

Step 4: The API

We're gonna use JSON server for the sake of simplicity and fast scaffolding. We will mount it as an express middleware into the server:

// server/api/index.ts import { create, router } from 'json-server'; import * as path from 'path'; const server = create(); const apiEndpoints = router(path.join(__dirname, 'posts.json')); server.use(apiEndpoints); export default server;

For more info, see my article on json-server.

Step 5: The server

The server is a classic Express app that mounts the API middleware, along with the routes middleware, the responsibility of which is to transform React components into a string, in order to serve it to the client.

Let's look at the structure:

├── src | ├── client | | ├── ... | ├── server | | ├── api | | | ├── index.ts | | | └── posts.json | | ├── middlewares | | | ├── 404.ts | | | └── 500.ts | | ├── routes | | | └── index.tsx | | ├── templates | | | └── index.ejs | | └── index.ts

I think it's useless to show the entire file here, so the following is the most important part:

// server/index.ts ... app.use('/static', serveStatic(publicPath)); app.use('/api', api); app.use('/', routes); // error handlers app.use(handler500(app.get('env'))); app.use(handler404); ...

We are serving the static assets —namely the webpack bundles: vendor.js, main.js and styles.js— along with the API and the routes. We are also handling errors by using 404 and 500 error middlewares.

Step 6: Render React components in the server

The holy grail here is the renderToString function from react-dom/server. It takes a react component and generates its HTML string:

// server/routes/index.tsx import * as Express from 'express'; import * as React from 'react'; import { renderToString } from 'react-dom/server'; import { Provider } from 'react-redux'; import { renderRoutes } from 'react-router-config'; import { StaticRouter } from 'react-router-dom'; import routes from '../../client/routes'; import { getPosts } from '../../client/services/posts'; import createStore from '../../client/store/dev'; export default async (req: Express.Request, res: Express.Response) => { const posts = await getPosts(); const store = createStore({ posts }); const content = renderToString( <Provider store={store}> <StaticRouter location={req.url}> {renderRoutes(routes)} </StaticRouter> </Provider>, ); res.render('index', { content, data: store.getState() }); };

We are using the StaticRouter instead of BrowserRouter, because the location never changes in the server. And, by creating the store, we provide the app with initial data from the API by calling the getPosts service from the client. These data are finally passed to the template index.ejs in order to make it available to the client's store:

<!-- server/templates/index.ejs --> <!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <title>The Wilson Post</title> <!-- HTML5 shim, for IE6-8 support of HTML elements --> <!--[if lt IE 9]> <script src="http://html5shim.googlecode.com/svn/trunk/html5.js"></script> <![endif]--> </head> <body> <div id="root"><%- content %></div> <script> window.__INITIAL_STATE__ = <%- JSON.stringify(data) %>; </script> <script src="/static/vendor.js"></script> <script src="/static/styles.js"></script> <script src="/static/main.js"></script> </body> </html>

Step 7: Handling 404

Handling 404 is a little bit tricky, it actually works with our current code. When you visit an URL that the router can't match, it triggers the asterisk * route and mounts its component, matching the NotFound route:

// client/routes/index.ts ... { path: '*', component: NotFound, }, ...

But, there is a problem here; unlike the client, the server won't be aware whether it was a 404, and will serve the page with a 200 response status. To fix it, we need to ask the router to tell the server what really happened during route matching, so that the server can update the response status based on the router's result. We can do this by using the StaticRouter's context property. The idea consists of passing a context object to the StaticRouter so that it can be updated with the real status in the NotFound component:

Here is the updated version that uses the context:

// server/routes/index.tsx ... interface StaticRouterContext { status?: number; } export default async (req: Express.Request, res: Express.Response) => { const posts = await getPosts(); const store = createStore({ posts }); const context: StaticRouterContext = {}; const content = renderToString( <Provider store={store}> <StaticRouter location={req.url} context={context}> {renderRoutes(routes)} </StaticRouter> </Provider>, ); if (context.status === 404) { res.status(404); } res.render('index', { content, data: store.getState() }); };

But, how are we gonna update the context? Here is the NotFound component that we need to display:

// client/routes/NotFound.tsx import * as React from 'react'; export default (): JSX.Element => ( <div className="not-found"> Not found </div> );

This component needs to change the context passed from the server. To do it, we will use the Route component to have access to the context property, so that we can change it:

We're gonna write a generic Status component that will do the work:

// client/routes/Status.tsx import * as React from 'react'; import { Route, RouteComponentProps } from 'react-router-dom'; interface Props { status?: number; children?: React.ReactNode; } export default ({ status, children }: Props): JSX.Element => ( <Route render={({ staticContext }: RouteComponentProps<any>) => { if (staticContext) { staticContext.status = status; } return children; }} /> );

Then we use it in our NotFound component, and pass the right status to it:

// client/routes/NotFound.tsx import * as React from 'react'; import Status from './Status'; export default (): JSX.Element => ( <Status status={404}> <div>Not found</div> </Status> );

This way, when the server is done matching the routes, it will end up with the context having the right status, so that it can set it to the response:

// client/routes/index.tsx ... if (context.status === 404) { res.status(404); } ...

Now, when you go somewhere you're not supposed to, the server knows what status to set in the response, and will still serve you the application in order for the client to display the NotFound component.

Step 8: Redux

Redux is agnostic to the environment. This makes it pretty easy to handle, as it's used exactly the same way as in the client. We create a store and pass it to the Provider component. Then, we get its state in order to give it to the client's store. This is when it gets a little bit awkward to me. We wind up doing some messy code trying to pass the data to the client. We do this by giving the data to the index.ejs template in order to set it as a global variable:

... <script> window.__INITIAL_STATE__ = <%- JSON.stringify(data) %>; </script> ...

Notice: I don't see how this can be done otherwise. But if someone has any idea about a better and cleaner way to do this, please let me know :-)

Here is an update that makes it possible for the client's store to take advantage of __INITIAL_STATE__ data:

// client/index.dev.tsx ... const win: ExtendedWindow = window as ExtendedWindow; const state = win && win.__INITIAL_STATE__ ? win.__INITIAL_STATE__ : INITIAL_STATE; const store = createStore(state); render( <Provider store={store}> <BrowserRouter> {renderRoutes(routes)} </BrowserRouter> </Provider>, document.getElementById('root'), );

This way we are sure we have the same display in the server and the client.

Conclusion

SSR can be a piece of cake, at least before approaching the intricacies of the routing. You can find the entire code in Github. And, please! Feel free to give your feedback :-)

Thanks for reading!

Author(s)

Kamal Farsaoui

Kamal Farsaoui

Web developer / Previously Founder & CEO at CSI, Co-Founder & CTO at Neiio / Coffee snob

View profile

You wanna know more about something in particular?
Let's plan a meeting!

Our experts answer all your questions.

Contact us

Discover other content about the same topic

How to use ESLint to improve your workflow

How to use ESLint to improve your workflow

ESLint ecosystem can be super handy for JS and TS codebases, but are you using it right? Keep reading to learn about useful ESLint rules and a get a little bit more out it.