DEV Community

MarvelousWololo
MarvelousWololo

Posted on • Edited on

How to server-side render React, hydrate it on the client and combine client and server routes

How to server-side render React, hydrate it on the client and combine client and server routes

In this article, I would like to share an easy way to server-side render
your React application and also hydrate your Javascript bundle on the
client-side. If you don't know what "hydrate" is, I'll try to explain: imagine
that you render your React component to a string using the ReactDOMServer API,
you will send HTML to the client, that is static. In order to deal with the
dynamic events you've set in your component, you will have to attach this HTML
markup to its original React component. React does so by sending an identification
to the generated markup so it is able to resolve later which event should be
attached to which element in the DOM. (Kind of). You can read more at the
official docs
.

Here is the final code and demo

In my previous attempts to properly render my app on the server and hydrate it
on the client, I've got lost in the Webpack configuration: it has been
changing quite a bit in any major release, so often documentation and tutorials are obsolete. This is also my attempt to try to save you some time.

I tried to keep it as verbose as possible to ease the learning process, so I've divided it into seven parts:

  1. Initial Webpack configuration
  2. First server-side rendering
  3. Switch to Streams
  4. Combine the Express router with React Router
  5. Using Express query string
  6. Create a test environment
  7. (Try to) code split

Initial Webpack configuration

First we should install our dependencies:

npm i -E express react react-dom
Enter fullscreen mode Exit fullscreen mode

and our development dependencies:

npm i -DE webpack webpack-cli webpack-node-externals @babel/core babel-loader @babel/preset-env @babel/preset-react
Enter fullscreen mode Exit fullscreen mode

other tools that will helps us in development:

npm i -DE concurrently nodemon
Enter fullscreen mode Exit fullscreen mode

Let's configure Webpack. We will need two Webpack configurations, one for the
Node.js server code and another one for the client code. If you want to see the structure of our app, please
refer to the repository. Also, please note that:

  1. I'm using the ES2015 preset instead of the new env preset, you can change it on your own if you want to.
  2. I've also included the transform-class-properties Babel plugin so I don't need to .bind my classes methods everywhere. It's up to you if you want it, but it's on CRA by default.

Since I'm using the same module rules for both server and client I will extract
them to a variable js:

// webpack.config.js
const js = {
  test: /\.js$/,
  exclude: /node_modules/,
  use: {
    loader: "babel-loader",
    options: {
      presets: ["@babel/preset-env", "@babel/preset-react"],
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

Note that in both configurations I'm using different targets.

On the server configuration, there are two details I've missed in my previous attempts to do server-side rendering and by doing so I was not able to even build my app: The node.__dirname property and the use
of the Webpack plugin
webpack-node-externals.

In the first case I've set __dirname to false so when Webpack compile our server code it will not provide a polyfill and will keep the original value of
__dirname, this configuration is useful when we serve static assets with
Express, if we don't set it to false Express will not be able to find the
reference for __dirname.

The webpack-node-externals is used so Webpack will ignore the content of node_modules,
otherwise, it will include the whole directory in the final bundle. (I'm not
sure why it's not the default behavior and we need an external library for this.
My understanding is that if you have set your configuration target to
node
, it should have kept the
node_modules out of the bundle.)

Note: In both cases, I found the documentation really confusing so please don't take my word for it and check the docs yourself in case of further questions.

// webpack.config.js
const serverConfig = {
  mode: "development",
  target: "node",
  node: {
    __dirname: false,
  },
  externals: [nodeExternals()],
  entry: {
    "index.js": path.resolve(__dirname, "src/index.js"),
  },
  module: {
    rules: [js],
  },
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "[name]",
  },
};
Enter fullscreen mode Exit fullscreen mode

and our client configuration:

// webpack.config.js
const clientConfig = {
  mode: "development",
  target: "web",
  entry: {
    "home.js": path.resolve(__dirname, "src/public/home.js"),
  },
  module: {
    rules: [js],
  },
  output: {
    path: path.resolve(__dirname, "dist/public"),
    filename: "[name]",
  },
};
Enter fullscreen mode Exit fullscreen mode

Finally, we will export both configurations:

// webpack.config.js
module.exports = [serverConfig, clientConfig];
Enter fullscreen mode Exit fullscreen mode

You can find the final file here

First server-side rendering

Now we will create a component and will mount it in the DOM:

// src/public/components/Hello.js
import React from "react";

const Hello = (props) => (
  <React.Fragment>
    <h1>Hello, {props.name}!</h1>
  </React.Fragment>
);

export default Hello;
Enter fullscreen mode Exit fullscreen mode

Here is the file that will mount our component in the DOM, note that we are
using the hydrate method of react-dom and not render as is usual.

// src/public/home.js
import React from "react";
import ReactDOM from "react-dom";
import Hello from "./components/Hello";

ReactDOM.hydrate(
  <Hello name={window.__INITIAL__DATA__.name} />,
  document.getElementById("root")
);
Enter fullscreen mode Exit fullscreen mode

Then we can write our server code:

// src/index.js
import express from "express";
import path from "path";
import React from "react";
import ReactDOMServer from "react-dom/server";
import Hello from "./public/components/Hello";

const app = express();

app.use("/static", express.static(path.resolve(__dirname, "public")));

app.get("/", (req, res) => {
  const name = "Marvelous Wololo";

  const component = ReactDOMServer.renderToString(<Hello name={name} />);

  const html = `
  <!doctype html>
    <html>
    <head>
      <script>window.__INITIAL__DATA__ = ${JSON.stringify({ name })}</script>
    </head>
    <body>
    <div id="root">${component}</div>
    <script src="/static/home.js"></script>
  </body>
  </html>`;

  res.send(html);
});

app.listen(3000);
Enter fullscreen mode Exit fullscreen mode

Note that we are stringifying the content of name so we can reuse its value on
the client to hydrate our component.

We will then create a NPM script in order to run our project:

// package.json
"scripts": {
  "dev": "webpack && concurrently \"webpack --watch\" \"nodemon dist\""
}
Enter fullscreen mode Exit fullscreen mode

Here we are building and then
concurrently watching for
changes in our bundle and running our server from /dist. If we start our app without the
first build, the command will crash since there is no files in /dist yet.

If you npm run dev in your terminal your app should be available at localhost:3000.

Switch to Streams

Now we will switch to the stream API in order to improve our performance, if you
don't know what streams are about you can read more about them
here and
more specific to React
here.

Here's our new / route:

app.get("/", (req, res) => {
  const name = "Marvelous Wololo";

  const componentStream = ReactDOMServer.renderToNodeStream(
    <Hello name={name} />
  );

  const htmlStart = `
  <!doctype html>
    <html>
    <head>
      <script>window.__INITIAL__DATA__ = ${JSON.stringify({ name })}</script>
    </head>
    <body>
    <div id="root">`;

  res.write(htmlStart);

  componentStream.pipe(res, { end: false });

  const htmlEnd = `</div>
    <script src="/static/home.js"></script>
  </body>
  </html>`;

  componentStream.on("end", () => {
    res.write(htmlEnd);

    res.end();
  });
});
Enter fullscreen mode Exit fullscreen mode

Combine the Express router with React Router

We can use the Express router with the React Router library.

Install React Router:

npm i -E react-router-dom
Enter fullscreen mode Exit fullscreen mode

First we need to add a new Webpack entry in the clientConfig:

// webpack.config.js
  entry: {
    'home.js': path.resolve(__dirname, 'src/public/home.js'),
    'multipleRoutes.js': path.resolve(__dirname, 'src/public/multipleRoutes.js')
  }
Enter fullscreen mode Exit fullscreen mode

Then let's create two components as we did for Home. The first one will be almost the
same as the basic example in the React Router
docs
, let's call it MultipleRoutes:

// src/public/components/MultipleRoutes.js
import React from "react";
import { Link, Route } from "react-router-dom";

const Home = () => (
  <div>
    <h2>Home</h2>
  </div>
);

const About = () => (
  <div>
    <h2>About</h2>
  </div>
);

const Topics = ({ match }) => (
  <div>
    <h2>Topics</h2>
    <ul>
      <li>
        <Link to={`${match.url}/rendering`}>Rendering with React</Link>
      </li>
      <li>
        <Link to={`${match.url}/components`}>Components</Link>
      </li>
      <li>
        <Link to={`${match.url}/props-v-state`}>Props v. State</Link>
      </li>
    </ul>

    <Route path={`${match.url}/:topicId`} component={Topic} />
    <Route
      exact
      path={match.url}
      render={() => <h3>Please select a topic.</h3>}
    />
  </div>
);

const Topic = ({ match }) => (
  <div>
    <h3>{match.params.topicId}</h3>
  </div>
);

const MultipleRoutes = () => (
  <div>
    <ul>
      <li>
        <Link to="/with-react-router">Home</Link>
      </li>
      <li>
        <Link to="/with-react-router/about">About</Link>
      </li>
      <li>
        <Link to="/with-react-router/topics">Topics</Link>
      </li>
      <li>
        <a href="/">return to server</a>
      </li>
    </ul>

    <hr />

    <Route exact path="/with-react-router" component={Home} />
    <Route path="/with-react-router/about" component={About} />
    <Route path="/with-react-router/topics" component={Topics} />
  </div>
);

export default MultipleRoutes;
Enter fullscreen mode Exit fullscreen mode

and

// src/public/multipleRoutes.js
import React from "react";
import ReactDOM from "react-dom";
import { BrowserRouter as Router } from "react-router-dom";
import MultipleRoutes from "./components/MultipleRoutes";

const BasicExample = () => (
  <Router>
    <MultipleRoutes />
  </Router>
);

ReactDOM.hydrate(<BasicExample />, document.getElementById("root"));
Enter fullscreen mode Exit fullscreen mode

in our server we will import the new component and also the React Router
library. We will also create a wildcard route /with-react-router*, so every
request to /with-react-router will be handled here. E.g.: /with-react-router/one,
/with-react-router/two, /with-react-router/three.

// src/index.js
// ...
import { StaticRouter as Router } from "react-router-dom";
import MultipleRoutes from "./public/components/MultipleRoutes";
// ...
app.get("/with-react-router*", (req, res) => {
  const context = {};

  const component = ReactDOMServer.renderToString(
    <Router location={req.url} context={context}>
      <MultipleRoutes />
    </Router>
  );

  const html = `
  <!doctype html>
    <html>
    <head>
      <title>document</title>
    </head>
    <body>
      <div id="root">${component}</div>
      <script src="/static/multipleRoutes.js"></script>
    </body>
    </html>
  `;

  if (context.url) {
    res.writeHead(301, { Location: context.url });
    res.end();
  } else {
    res.send(html);
  }
});
Enter fullscreen mode Exit fullscreen mode

Note that we have used different routers from react-router-dom in the
client and the server.

By now you must have an app that have both client and server rendered routes. To
improve the navigation we will add a link to /with-react-router in our
Hello component:

// src/public/components/Hello.js
// ...
const Hello = (props) => (
  <React.Fragment>
    <h1>Hello, {props.name}!</h1>

    <a href="/with-react-router">with React Router</a>
  </React.Fragment>
);
Enter fullscreen mode Exit fullscreen mode

Using Express query string

As we have set a full Node.js application with Express we have access to all the
things that Node has to offer. To show this we will receive the prop name of
the Hello component by a query string in our / route:

// src/index.js
app.get('/', (req, res) => {
  const { name = 'Marvelous Wololo' } = req.query
// ...
Enter fullscreen mode Exit fullscreen mode

Here we are defining a default value for the variable name if req.query does
not provide us one. So, the Hello component will render any value you pass
for name at localhost:3000?name=anything-I-want-here

Create a test environment

In order to test our React components we will first install a few dependecies. I've chosen Mocha and Chai to run and assert our tests, but you could use any
other test runner/assert library. The down side of testing this environment is
that we have to compile the tests files too (I'm not sure if there's any other
way around it, I think not).

npm i -DE mocha chai react-addons-test-utils enzyme enzyme-adapter-react-16
Enter fullscreen mode Exit fullscreen mode

So I'll create a new Webpack config for tests, you'll note that the configuration is almost
exactly the same as we already have for the server files:

// webpack.tests.js
const webpack = require("webpack");
const nodeExternals = require("webpack-node-externals");
const path = require("path");

const js = {
  test: /\.js$/,
  exclude: /node_modules/,
  use: {
    loader: "babel-loader",
    options: {
      presets: ["@babel/preset-env", "@babel/preset-react"],
    },
  },
};

module.exports = {
  mode: "development",
  target: "node",
  node: {
    __dirname: false,
  },
  externals: [nodeExternals()],
  entry: {
    "app.spec.js": path.resolve(__dirname, "specs/app.spec.js"),
  },
  module: {
    rules: [js],
  },
  output: {
    path: path.resolve(__dirname, "test"),
    filename: "[name]",
  },
};
Enter fullscreen mode Exit fullscreen mode

I will create a test file app.spec.js and a specs directory in the root of the
project.

// specs/app.spec.js
import { expect } from "chai";
import Enzyme, { shallow } from "enzyme";
import Adapter from "enzyme-adapter-react-16";
import React from "react";
import Hello from "../public/components/Hello";

Enzyme.configure({ adapter: new Adapter() });

describe("<Hello />", () => {
  it("renders <Hello />", () => {
    const wrapper = shallow(<Hello name="tests" />);
    const actual = wrapper.find("h1").text();
    const expected = "Hello, tests!";

    expect(actual).to.be.equal(expected);
  });
});
Enter fullscreen mode Exit fullscreen mode

We will also create a new (long and ugly) NPM script to run our tests:

"scripts": {
  "dev": "webpack && concurrently \"webpack --watch\" \"nodemon dist\"",
  "test": "webpack --config webpack.test.js && concurrently \"webpack --config webpack.test.js --watch\" \"mocha --watch\""
}
Enter fullscreen mode Exit fullscreen mode

At this point, running npm test should pass one test case.

(Try to) code split

Well I honestly think that the new way to do code splitting with Webpack is a
little bit
difficult to understand, but I'll try anyway. Keep in mind that this is
not a final solution and you'll likely want to tweak with Webpack to extract the
best from it, but I'm not willing to go through the docs now for this. The
result I've got here is good enough for me. Sorry. Head to the docs in
case of questions.

So, if we add:

// webpack.config.js
// ...
optimization: {
  splitChunks: {
    chunks: "all";
  }
}
// ...
Enter fullscreen mode Exit fullscreen mode

to our clientConfig, Webpack will split our code into four files:

  • home.js
  • multipleRoutes.js
  • vendors~home.js~multipleRoutes.js
  • vendors~multipleRoutes.js

it even gives us a nice report when we run npm run dev. I think these files are
quite self-explanatory but still, we have files that are exclusive for a given
page and some files with common vendor code that are meant to be shared between
pages. So our script tags in the bottom of the / route would be:

<script src="/static/vendors~home.js~multipleRoutes.js"></script>
<script src="/static/home.js"></script>
Enter fullscreen mode Exit fullscreen mode

and for the /with-react-router route:

<script src="/static/vendors~home.js~multipleRoutes.js"></script>
<script src="/static/vendors~multipleRoutes.js"></script>
<script src="/static/multipleRoutes.js"></script>
Enter fullscreen mode Exit fullscreen mode

If you are curious, here are the differences in bundle size given you set the
configuration mode to production:

                            Asset      Size
                          home.js  1.82 KiB
                multipleRoutes.js  3.27 KiB
        vendors~multipleRoutes.js  24.9 KiB
vendors~home.js~multipleRoutes.js  127 KiB
Enter fullscreen mode Exit fullscreen mode

and development:

                            Asset      Size
                          home.js  8.79 KiB
                multipleRoutes.js  13.6 KiB
        vendors~multipleRoutes.js   147 KiB
vendors~home.js~multipleRoutes.js   971 KiB
Enter fullscreen mode Exit fullscreen mode

Well, I think that is it. I hope you have enjoyed this little tutorial and also I hope it might be useful for your own projects.

Top comments (10)

Collapse
 
roscioli profile image
Octavio Dimitri Roscioli • Edited

Question: Why are you setting up redux both server side and client side? Should the server and client redux stores be instantiated with the same reducer? What ever happened to Don't Repeat Yourself (DRY)? Aren't we passing the initial state to the front end anyway (so then we're creating a store on the front end with that)?

I am asking with pure curiosity... Learning SSR right now and I've found this demo to be extremely helpful. Just confused on this.

Would also love a little more clarification why there is a react-router-dom router set up on the front and back end, but that can be answered separately.

Collapse
 
marvelouswololo profile image
MarvelousWololo

Hey, bro! What's up? I'm actually not using Redux here. But initiate it on the server could be useful in case you want to dispatch some stuff on the server first. :)

I'm glad you found it useful!

The react-router is set up both on the server and client because you need a root provider for your React app in both cases. Cheers!

Collapse
 
biquet93 profile image
biquet93

hi here, im late sorry :)
why do you need react-router for client and server, if i cancel the client webpack config, and i'm using only the server webpack config it's working, but there is no code-splitting , and i don't understand why i can't display the tutorial anymore. I thought, 'src/index' was sufficient for all application's requests, what did i miss ?

Thread Thread
 
marvelouswololo profile image
MarvelousWololo

Hi biquet93!

It's okay, np. I think your question is related to the 'hydration' of the app so I will share a link to the official docs where they do a great job explaining it. OK? Other than that it might be helpful to read the first paragraph of the tutorial again. Please don't be discouraged if you don't quite grasp it at first, it is a non trivial subject.

Here it is: reactjs.org/docs/react-dom.html#hy...

Collapse
 
michaelcurrin profile image
Michael Currin • Edited

Thanks, this tutorial and code snippets helped a lot! All the other things I looked at where too complex and had extra code. I just needed to know how to combine string rendering and hydration.

A built an app based around your instructions. I hope it helps others here too.

GitHub logo MichaelCurrin / react-ssr-quickstart

Starter template for server-side and client-side rendering of a React app ⚛️ 📦

React SSR Quickstart ⚛️ 📦

Starter template for server-side and client-side rendering of a React app

GitHub tag License

Made with Node.js Package - react Package - express

Preview

Sample screenshot

Use this project

Use this template

About

Background

Based on tutorial:

We set up a client-side React app with some components including an incrementing counter.

On initial page load without JS running, a user or a search engine crawler will see an empty page. So we add a server-side Express app that return an HTML page which acts as a fully-rendered starting point that needs no JS to view in the browser.

We do this by calling ReactDOMServer.renderToString, which unfortunately freezes the app so that ignores user interaction. This is solved by calling React.hydrate on the client, so that the browser can make the initial HTML and turn it into a dynamic app in the usual SPA style.

The…

I went with make instead of concurrently, since make supports parallel jobs already.

Collapse
 
rahulatgit profile image
rahulAtGit

Hey that's a really good information out there. Can you explain more about renderToNodeStream and how hydrating state works during streaming.

Collapse
 
marvelouswololo profile image
MarvelousWololo

Hello rahulAtGit,

I'm sorry I didn't see your message before. I'm don't know whether you are familiar with the concepts of Node.js streams. So I will share some links that will hopefully help you. But in summary this method returns a Node.js Readable Stream. This has performance gains over i.e. renderToString since it doesn't wait to parse your React nodes into a string, instead it will sending you data as it is parsing. If you don't have a huge component I would advise you to use renderToString instead since it is much simpler.

Regarding the state while streaming, let me know what's your specific question. Cheers.

reactjs.org/docs/react-dom-server....
nodejs.org/api/stream.html
github.com/substack/stream-handbook

Collapse
 
francisrod01 profile image
Francis Rodrigues • Edited

The code split hasn't worked.
I'm following this repo to apply your tips:
github.com/ilkeraltin/react-ssr-news/

Collapse
 
permindersingh profile image
Perminder Singh Bhatia

I liked the way you described. This is really helpful.

Collapse
 
francisrod01 profile image
Francis Rodrigues

Any strategy to not add chunks into the index.html by harded-code?

Some comments may only be visible to logged-in visitors. Sign in to view all comments.