Server-Side Rendering

Server-side rendering (SSR) allows an application to generate the HTML for pages on the server. While not strictly necessary for single-page applications, server-side rendering can potentially be beneficial by:

  1. Speeding up the initial render time.
  2. Making it easier for web crawlers to view the application's content (which may improve SEO).

This guide will cover how to setup server-side rendering and some of the issues that you may run into.

Reusing Code

Being able to reuse code on the client and server is one of the benefits of JavaScript. If you are using syntax in your client-side code that Node doesn't know how to parse, such as import/export or JSX, you may quickly run into issues.

The Babel package @babel/node lets Babel compile your code on the fly to syntax that Node understands. Anywhere that you would call node <command>, you should call babel-node <command> instead.

npm install --save-dev @babel/node
Warning: @babel/node should only be used in development. For production, the server's modules should be pre-compiled (using Babel).

Web Framework

In order to render JavaScript on the server, you will need to use Node. This guide will be using the Express web framework.

Note: There are ways to mix Node server-side rendering with non-Node frameworks, but that is outside of the scope of this guide.

Familiarity with Express is not expected, so to get you started, this guide will provide some code snippets for a basic setup.

npm install express

The server's setup code can be placed anywhere, but we will follow Node's convention and save it in a server.js file.

// server.js
const express = require("express");

const app = express();

// ...

app.listen("8080", () => {
  console.log("Server is running.");
});
# tell node to start the server
# (development only!)
babel-node server.js

With the server ready to go, we can start configuring it to render a single-page application.

Request Matching

A web framework receives requests from the client and returns responses.

In the client-side application, we define the routes that are valid for the application. Similarly, the server needs to define which request paths are valid so that it can properly respond to them.

Server paths are given handler functions. These can do a variety of things, but we will only be using them to send responses.

app.use("/hi", function(req, res) {
  res.send("Hey!");
})

Client-Side Routes

Instead of telling the server about every single valid client-side route, a wildcard path is used to match every request. Determining what to render for the request will be done by a Curi router, which we will create in the wildcard's handler function.

// the wildcard matches every GET request
app.get("*", renderHandler);
Note: The * wildcard handler is similar to the Curi path (.*). Express and Curi both use path-to-regexp for path matching. However, Express uses an old version. path-to-regexp removed support for the barebones * pattern in the version that Curi uses, which is why we have to use (.*) in Curi routes.

Static Assets

Page requests aren't the only requests that the framework will handle. Requests for static resources, like scripts, stylesheet, and images shouldn't be handled by Curi. Express provides a static() method to map request locations "real" (files exist on the server) locations.

app.use("/static", express.static());

Using the above static file handler, all static file requests in HTML/JavaScript should begin with /static.

<img src="/static/img/circle.png" />

Path Order

Express matches against paths in the order that they are registered, so the static files path needs to be defined before the wildcard path.

Any other non-page paths, like APIs, would also need to be defined before the catch-all.

app.use("/static", express.static());
app.use("/api", dataHandler);
app.get("*", renderHandler);

Render Handler

The render handler function receives the request object and a response object. The response object is used to build and send a response to the user.

// renderer.js
export default function renderHandler(req, res) {

}

// server.js
const renderHandler = require("./renderer");

app.get("*", renderHandler)
Note: If you are setting up a server without server-side rendering, the renderHandler function could use res.sendFile() to return a universal HTML file for every route.
const index = path.join(__dirname, "public", "index.html");
          
export default function renderHandler(req, res) {
  res.sendFile(index);
}

Router

A router instance will be created for every single request. This is a big reason why we wrap the routes array in a prepareRoutes call. Without prepareRoutes, the route pathes would need to be re-compiled for every request!

The router will match the requested location to its routes and generate a response. Once the response is generated, the handler can render the application.

// renderer.js
function handler(req, res) {
  const router = curi(history, routes);
  router.once(({ response }) => {
    // render the response
  })
}

Where do the history and routes come from?

History

On the client-side, a single-page application uses @hickory/browser to create a history instance. However, that uses browser only APIs. On the server, the @hickory/in-memory package is used to create an equivalent history instance.

npm install @hickory/in-memory
An in-memory history takes an array of locations. For server-side rendering, we want to pass it the location from the request.
// handler.js
import InMemory from "@hickory/in-memory";

function handler(req, res) {
  const history = InMemory({ locations: [req.path] });
  const router = curi(history, routes);
  // ...
}

Routes

Ideally, you will be able to re-use your client side routes on the server, but if the client routes use browser only APIs, you may need to adapt the routes to work on the server.

// handler.js
import routes from "../client/routes";

One approach to client/server routes is to keep two copies: one for the client and one for the server. However, this should be a last resort because it can lead to inconsistencies if you update one file but not the other.

A more reusable approach would be to use "universal" wrappers around any environment specific APIs. For example, the isomorphic-fetch package could be used to support fetch() in the browser and Node.

// routes.js
import fetch from "isomorphic-fetch";
import { prepareRoutes } from "@curi/router";

export default prepareRoutes([
  {
    name: "Test",
    path: "test",
    resolve: {
      data: () => fetch("/test-data")
    }
  }
]);

Automatic Redirects

Curi automatically redirects to a new location when a response with a redirectTo property is generated. On the client, this is convenient because it saves you from having to detect the redirect and manually redirecting yourself. However, on the server it can cause issues.

The issue happens because when Curi automatically redirects, another response is created for the location that Curi redirects to. If this response is ready before you try to render the current response, you'll render the redirected location's response instead of the initial response.

Curi's automaticRedirects option lets you disable automatic redirects when its value is false. This lets you be certain that you are rendering using the initial response.

function renderHandler(req, res) {
  const history = InMemory({ locations: [req.path] });
  const router = curi(history, routes, {
    automaticRedirects: false
  });
}

Handling the Response

When the router is created, it will start generating a response by matching its history object's current location. If the application has any asynchronous routes, the response may not be ready immediately. The safest approach is to use router.once to wait for the response.

function renderHandler(req, res) {
  const history = InMemory({ locations: [req.path] });
  const router = curi(history, routes, {
    automaticRedirects: false
  });
  router.once(({ response }) => {
    // ...
  });
}

The next step is to render the application to generate an HTML response string that will be sent to the user. How exactly you do this depends on what UI renderer you are using, but the process is approximately the same for most renderering libraries.

Here, we will assume that you are using React. The react-dom package (through its server module), provides a renderToString method, which will render an application as a string.

Rendering with React on the server is essentially the same as rendering on the client. We create a <Router> and use renderToString (instead of ReactDOM.render) to render the component, passing it a render-invoked function.

import { renderToString } from "react-dom/server";
import { curiProvider } from "@curi/react-dom";
         
function renderHandler(req, res) {
  const history = InMemory({ locations: [req.path] });
  const router = curi(history, routes, {
    automaticRedirects: false
  });
  router.once(({ response }) => {
    const Router = curiProvider(router);
    const markup = renderToString(
      <Router>
        {({ response }) => {
          const { body:Body } = response;
          return <Body response={response} />;
        }}
      </Router>
    );
  });
}

Rendering with renderToString only generates an HTML string for the application. We are missing the <html>, <head>,<body>, <script>, etc. tags that are required for the full HTML page.

We can write a function that takes the string created by renderToStringand inserts it into the full HTML string for a page.

For a React application, the markup string should be set as the child of its container element. If you render into the #root element on the client, the HTML should have a #root element.

Any JavaScript scripts that need to be rendered should also be included in the HTML. Make sure that their paths are absolute; if the path is relative, then you will run into errors resolving the location for nested routes!

If your routes set title strings on the response, you can also pass that value to the markup insertion function and set the title in the HTML string.

import { renderToString } from "react-dom/server";
import { curiProvider } from "@curi/react-dom";

function insertMarkup(markup, title) {
  return `<!doctype html>
<html>
  <head>
    <title>${title} | My Site</title>
  </head>
  <body>
    <div id="root">${markup}</div>
    <script src="/static/js/bundle.js"></script>
  </body>
</html>`;
}

function renderHandler(req, res) {
  const history = InMemory({ locations: [req.path] });
  const router = curi(history, routes, {
    automaticRedirects: false
  });
  router.once(({ response }) => {
    const Router = curiProvider(router);
    const markup = renderToString(
      <Router>
        {({ response }) => {
          const { body:Body } = response;
          return <Body response={response} />;
        }}
      </Router>
    );
    const html = insertMarkup(markup, response.title);
    res.send(html);
  });
}
Note: If you server render a React application, you should use ReactDOM.hydrate instead of ReactDOM.render on the client.

Redirect Responses

If a route matches and it redirects, you can handle it without rendering the application. A response is a redirect if it has a redirectTo property. redirectTo.url is that full URL (pathname, query, and hash).

import { renderToString } from "react-dom/server";
import { curiProvider } from "@curi/react-dom";

function renderHandler(req, res) {
  const history = InMemory({ locations: [req.path] });
  const router = curi(history, routes, {
    automaticRedirects: false
  });
  router.once(({ response }) => {
    if (response.redirectTo) {
      res.redirect(301);
      return;
    }
    // otherwise, render
  });
}