Table of Contents
The motivation: why doing it?
Now we have a production build which works great, but when search engines look at your site they will see this:
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>Babel Webpack Boilerplate</title> <link rel="stylesheet" type="text/css" href="dist/main.css"> </head> <body> <div id="root"></div> <script type="text/javascript" src="dist/main-bundle.js"></script> </body> </html>
and will have no idea what this site is about and how to index it.
Good search engines like Google will render the JS and will get the right idea but there are also other benefits of having SSR.
Benefits:
– Quicker page render. Once the source code is there, with all assets (CSS, JS, Images, fonts, etc) is sent to the browser, it could start render it and it could show the site like it was already ready for use (although the events won’t be attached at this point and the site won’t be really ‘clickable’ the user will at least see a fully rendered site which gives him the perception of a fully ready site)
– SCO benefits
– Pages could be pre-rendered on the server and cached.
So. let’s dive into this:
SSR Checklist
This might be more complicated task that we would expect so let’s outline a checklist of what have to be done to achieve SSR:
- Fetch all GraphQL queries on the backend before start generating the source code.
Some components rely on having their data fetched from GraphQL before render.
For example, If you remember we dynamically fetch the page components layout from GraphQL so in order to serve the page source, we have to make the page query and return the list of all components for the current route. For example the ‘/home’ page should hare 2 components: header and Home components. - Make sure that bundle splitting will continue work.
- Fetch only assets that are necessary to be served for each particular route.
We have to pre-render the page, and figure out which JS and CSS bundles should be added in the source code. For example for the home page ‘/home’ we have to include:
<script src=’dist/header.js’/></script>
<link href=”/dist/header.css”/><script src=’dist/home.js’/></script>
<link href=”/dist/home.css”/> - Finally fetch the source code string and send it to the browser.
Adding server side rendering (SSR).
Let’s start with adding the server side rendering script, and the script that will run it.
Adding ssr build script
./package.json
"scripts": { "start-cli": "webpack-dev-server --hot --history-api-fallback --config webpack.cli.config.js", "start-api": "babel-node server-api.js", "start-middleware": "babel-node server-middleware.js", "clean": "rm -rf ./dist ./server-build", "lint": "eslint .", "build-dev": "webpack --mode development", "build-prod": "webpack --config webpack.prod.config.js", "build-ssr": "webpack --config webpack.server.config.js", "run-server": "node ./server-build/server-bundle.js", "start": "yarn clean; yarn build-prod; yarn build-ssr; yarn run-server" },
what we just did:
– (line 9) adding build-ssr
script to bundle the express server into a executable JS
– (line 10) adding start
script to run the express server which will:
1. send the page source to the browser.
2. serve the static production bundle.
3. serve all other assets (images, fonts, etc)
– remove run-prod-server
and build-and-run-prod-server
since we are replaced them with the new scripts.
– add `server-build` to the cleaning script since this is the location where the server bundle will be dumped.
Creating SSR config
Since most of the configuration will be similar to the production config, let’s start by copying the production config into a new file called ./webpack.ssr.config.js and do some adjustments specific for SSR.
./webpack.ssr.config.js
const path = require('path'); const webpack = require('webpack'); const nodeExternals = require('webpack-node-externals'); let config = require('./webpack.base.config.js'); const MiniCssExtractPlugin = require("mini-css-extract-plugin"); const OptimizeCSSAssetsPlugin = require("optimize-css-assets-webpack-plugin"); config.mode = "production"; config.devtool = ""; config.target = "node"; config.externals = [nodeExternals()]; config.entry = { server: './ssr-server.js' } config.output = { filename: '[name]-bundle.js', path: path.resolve(__dirname, 'server-build') } config.module.rules[1].use[0] = MiniCssExtractPlugin.loader; config.plugins = [ ... config.plugins, ... [ new MiniCssExtractPlugin({ filename: "[name].css" }), new OptimizeCSSAssetsPlugin({}), // on the server we still need one bundle new webpack.optimize.LimitChunkCountPlugin({ maxChunks: 1 }) ] ]; module.exports = config;
what we just did:
– (line 10) we told Webpack that this bundle will not run in the browser but in the ‘node’ environment.
– (line 11) we aded Webpack Node Externals which will remove the node_modules which we don’t need when execute in ‘node’ environment.
– (line 14) specifies a new entry point for the SSR, that will run the express server (similar to the one that we built for production)
– (line 31) on the server side we still need one bundle file.
Now let’s add the missing module:
yarn add webpack-node-externals --dev
Adding Express server
This is going to be the same Express server that we added in the previous chapter to serve the production requests, but settings will be much more complex to fit the SSR needs. Create ssr-server.js with the following content:
./ssr-server.js
import React from 'react'; import express from 'express'; import App from './src/components/App/ssr-index'; import Loadable from 'react-loadable'; import manifest from './dist/loadable-manifest.json'; import { getDataFromTree } from "react-apollo"; import { ApolloClient } from 'apollo-client'; import { InMemoryCache } from 'apollo-cache-inmemory'; import { renderToStringWithData } from "react-apollo" import { createHttpLink } from 'apollo-link-http'; import { getBundles } from 'react-loadable/webpack'; const PORT = process.env.PROD_SERVER_PORT; const app = express(); app.use('/server-build', express.static('./server-build')); app.use('/dist', express.static('dist')); // to serve frontent prod static files app.use('/favicon.ico', express.static('./src/images/favicon.ico')); app.get('/*', (req, res) => { const GRAPHQL_URL = process.env.GRAPHQL_URL; const client = new ApolloClient({ ssrMode: true, link: createHttpLink({ uri: GRAPHQL_URL, fetch: fetch, credentials: 'same-origin', headers: { cookie: req.header('Cookie'), }, }), cache: new InMemoryCache() }); // Prepare to get list of all modules that have to be loaded for this route const modules = []; const mainApp = ( <Loadable.Capture report={moduleName => modules.push(moduleName)}> <App req={req} client={client} /> </Loadable.Capture> ); // Execute all queries and fetch the results before continue getDataFromTree(mainApp).then(() => { // Once we have the data back, this will render the components with the appropriate GraphQL data. renderToStringWithData().then( (HTML_content) => { // Extract CSS and JS bundles const bundles = getBundles(manifest, modules); const cssBundles = bundles.filter(bundle => bundle && bundle.file.split('.').pop() === 'css'); const jsBundles = bundles.filter(bundle => bundle && bundle.file.split('.').pop() === 'js'); res.status(200); res.send(`<!doctype html> <html lang="en"> <head> <meta charSet="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Server Side Rendering and Bundle Splitting</title> <link href="/dist/main.css" rel="stylesheet" as="style" media="screen, projection" type="text/css" charSet="UTF-8" /> <!-- Page specific CSS bundle chunks --> ${ cssBundles.map( (bundle) => (` <link href="${bundle.publicPath}" rel="stylesheet" as="style" media="screen, projection" type="text/css" charSet="UTF-8" />`)).join('\n') } <!-- Page specific JS bundle chunks --> ${jsBundles .map(({ file }) => `<script src="/dist/${file}"></script>`) .join('\n')} <!-- =========================== --> </head> <body cz-shortcut-listen="true"> <div id="root"/> ${HTML_content} </div> <script> window.__APOLLO_STATE__=${JSON.stringify(client.cache.extract())}; </script> <script src="/dist/main-bundle.js"></script> </body> </html>`); res.end(); }); }).catch( (error) => { console.log("ERROR !!!!", error); }); }); Loadable.preloadAll().then(() => { app.listen(PORT, () => { console.log(`? Server is listening on port ${PORT}`); }); });
what we just did:
Clearly a lot of stuff so let’s take one step at a time.
– We created express server similar to the one we did for production in the previous chapter.
– (line 13,14) we created the server, telling it to run on the port that we will specify in the .env file later in this tutorial.
– (line 20) we are telling Express to listen to any request pattern, except for these above described in app.use
– (line 53) sending the html to the browser.
– We fetch the data for all queries in the app before continue.
– (lines 21-33) We created the Apollo client.
– (line 44) calling getDataFromTree
from react-apollo
will walk through the React tree to find any components that make GraphQL requests. It will execute the queries and return a promise.
– (line 46) calling renderToStringWithData
will render the app with the necessary GraphQL data.
– (line 90) since we already fetched all data, we are going to stringify it, and attach it to the window
object so it could be re-used on the client side. this part is very important. It does so called Store hydration. If we miss it we will end up making twice more queries to GraphQL: one set on the server side, and one set on the client side.
– We get a list of all components that will have to render for the particular route, and create appropriate SCRIPT and LINK tags to load JS and CSS only for these components.
– (line 38) we use Loadable.Capture
from react-loadable
to create an array of all components that exist in the particular route and store it in modules = []
– (lines 48 – 50) Will extract the CSS and JS bundle file lists
– ( lines 67 – 83) will traverse CSS and JS arrays and will create the appropriate CSS and JS tags to include the components that are about to render in this particular route.
– (line 104) is going to pre-load all the components before continue so we won’t see just the “loading” component.
One very important step that could be easily forgotten is to add react-loadable/babel
plug-in into .babelrc otherwise we are going to wonder why the assets list is never created.
./.babelrc
{ "presets": [ "@babel/preset-env", "@babel/preset-react" ], "plugins": [ "@babel/plugin-syntax-dynamic-import", "react-loadable/babel" ] }
Also let’s add PROD_SERVER_PORT
to our .env file
./env
APP_NAME=Webpack React Tutorial GRAPHQL_URL=http://localhost:4001/graphql PROD_SERVER_PORT=3006
and make it available on the front end so Express could pick it up.
./getEnvironmentConstants.js
const fs = require('fs'); // Load environment variables from these files const dotenvFiles = [ '.env' ]; // expose environment variables to the frontend const frontendConstants = [ 'APP_NAME', 'GRAPHQL_URL', 'PROD_SERVER_PORT' ]; function getEnvironmentConstants() { dotenvFiles.forEach(dotenvFile => { if (fs.existsSync(dotenvFile)) { require('dotenv-expand')( require('dotenv').config({ path: dotenvFile, }) ); } }); const arrayToObject = (array) => array.reduce((obj, item, key) => { obj[item] = JSON.stringify(process.env[item]); return obj }, {}) return arrayToObject(frontendConstants); } module.exports = getEnvironmentConstants;
Adding ./components/App/ssr-index.js
Now if we look again at ./ssr-server.js (line 3) we will see that we are not pointing the App module to the index.js but to ssr-index.js instead, and the reason for this is that on the server things will be slightly different.
First we can’t use BrowswerRouter
because clearly there is no browser in a node environment. We have to use StaticRouter
instead.
Second, we don’t need to instantiate a new ApolloClient since we already did this in ./ssr-server.js file above. We are just going to pass it as a client
param.
With that being said, let’s go ahead and create:
./components/App/ssr-index.js
import React from 'react'; import PageLayout from '../../containers/PageLayout'; import { StaticRouter, Route, Switch } from 'react-router-dom'; import { ApolloProvider } from 'react-apollo'; import { Provider } from 'react-redux'; import { createStore} from 'redux'; import reducers from '../../reducers'; import fetch from 'isomorphic-fetch'; import styles from './styles.scss'; const store = createStore(reducers, {}); export default ( {req, client} ) => { const context = {}; return ( <div className={styles.appWrapper}> <Provider store={store}> <ApolloProvider client={client}> <StaticRouter location={ req.url } context={context}> <Switch> <Route exact path="*" component={PageLayout} /> </Switch> </StaticRouter> </ApolloProvider> </Provider> </div> ); }
DOM Hydration
We did almost all necessary steps for SSR but if we run the app we will see that we are not taking the advantage of the server side rendering and the app will reload on the client side.
We still need to do two important things: DOM hydration and Store rehydration.
DOM hydration is going to attach the events to the existing HTML layout returned from the SSR. Adding it is pretty straight forward: we have to replace ReactDOM.render with ReactDOM.hydrate.
./src/index.js
import React from 'react'; import ReactDOM from 'react-dom'; import App from './components/App'; ReactDOM.hydrate(<App/>, document.getElementById('root')); if (module.hot) { module.hot.accept(); }
Apollo store rehydration
This is also pretty straight forward. If we go back in this tutorial we could haver recall that when we create ./ssr-server.js we fetched the data for all queries, and attached it to the window object (line 90)
Now, let’s use this:
./src/components/App/index.js
import React from 'react'; import PageLayout from '../../containers/PageLayout'; import { ApolloProvider } from 'react-apollo'; import { ApolloClient } from 'apollo-client'; import { HttpLink } from 'apollo-link-http'; import { InMemoryCache } from 'apollo-cache-inmemory'; import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'; import { Provider } from 'react-redux'; import { createStore} from 'redux'; import reducers from '../../reducers'; const styles = require('./styles.scss'); const store = createStore(reducers, {}); export default ( {req} ) => { const GRAPHQL_URL = process.env.GRAPHQL_URL; const client = new ApolloClient({ link: new HttpLink({ uri: GRAPHQL_URL }), cache: new InMemoryCache().restore(window.__APOLLO_STATE__), }); return ( <div className={styles.appWrapper}> <Provider store={store}> <ApolloProvider client={client}> <Router> <Switch> <Route exact path="*" component={PageLayout} /> </Switch> </Router> </ApolloProvider> </Provider> </div> ); }
Ready for a test flight
Finally we are ready to test flight this beast. yarn start
and navigate the browser there localhost:3006/home
The response time should be mind blowing with all prod settings and SSR in place.
Open the source code and you should see something like this:
<!doctype html> <html lang="en"> <head> <meta charSet="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Server Side Rendering and Bundle Splitting</title> <link href="/dist/main.css" rel="stylesheet" as="style" media="screen, projection" type="text/css" charSet="UTF-8" /> <!-- Page specific CSS bundle chunks --> <link href="/dist/2.css" rel="stylesheet" as="style" media="screen, projection" type="text/css" charSet="UTF-8" /> <link href="/dist/3.css" rel="stylesheet" as="style" media="screen, projection" type="text/css" charSet="UTF-8" /> <!-- Page specific JS bundle chunks --> <script src="/dist/2-bundle.js"></script> <script src="/dist/3-bundle.js"></script> <!-- =========================== --> </head> <body cz-shortcut-listen="true"> <div id="root"/> <div class="App-appWrapper" data-reactroot=""><div><div><div class="Header-wrapper"><h2> <!-- -->Webpack React Tutorial<!-- --> </h2><ul><li><a href="/home">HOME</a></li><li><a href="/greetings">GREETINGS</a></li><li><a href="/dogs-catalog">DOGS CATALOG</a></li><li><a href="/about">ABOUT</a></li></ul></div></div><div><div class="Home-wrapper">This is my home section!</div></div></div></div> </div> <script> window.__APOLLO_STATE__={"Page:home":{"id":"home","url":"/home","layout":[{"type":"id","generated":true,"id":"Page:home.layout.0","typename":"PageLayout"},{"type":"id","generated":true,"id":"Page:home.layout.1","typename":"PageLayout"}],"__typename":"Page"},"Page:home.layout.0":{"span":"12","components":[{"type":"id","generated":true,"id":"Page:home.layout.0.components.0","typename":"PageComponents"}],"__typename":"PageLayout"},"Page:home.layout.0.components.0":{"name":"Header","__typename":"PageComponents"},"Page:home.layout.1":{"span":"12","components":[{"type":"id","generated":true,"id":"Page:home.layout.1.components.0","typename":"PageComponents"}],"__typename":"PageLayout"},"Page:home.layout.1.components.0":{"name":"Home","__typename":"PageComponents"},"ROOT_QUERY":{"getPageByUrl({\"url\":\"/home\"})":{"type":"id","generated":false,"id":"Page:home","typename":"Page"}}}; </script> <script src="/dist/main-bundle.js"></script> </body> </html>
As you might notice, (lines 15-37) that only the bundles for the components that we need there are loaded.
Also you will see the Apollo client (The GraphQL queries) serialized and attached to the window object (line 44) which we are using to re-hydrate the client and avoid another GraphQL call.
Well, this could have been as far as a production ready stack, but two important things are missing: tests and caching. But this is good enough as an end of this chapter.