Server-Side Rendering

Server-side rendering (SSR) is used to generate the HTML for pages when the server receives a request for them. 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 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

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.

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
let express = require("express");

let 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 Curi.

// the wildcard matches every GET request
app.get("*", renderHandler);

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
function renderHandler(req, res) {

}

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

app.get("*", renderHandler)
let index = path.join(__dirname, "public", "index.html");

function renderHandler(req, res) {
  res.sendFile(index);
}

Router

A router instance will be created for every single request. The router will match the requested location to its routes and generate a response, which can be used to render the HTML.

Curi has two optimizations to make this more efficient:

  1. By wrapping the routes array in a prepareRoutes call, all of an application's routes are pre-compiled. Without prepareRoutes, the route pathes would need to be re-compiled for every request!
  2. We will use a lightweight history type created specifically for server-side rendering.
// renderer.js
import { createRouter } from "@curi/router";
import { createReusable } from "@hickory/in-memory";

let reusable = createReusable();

function handler(req, res) {
  let router = createRouter(reusable, routes, {
    history: { location: req.url }
  });
  router.once(({ response }) => {
    // render the response
  })
}

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 a history instance that only exists in memory.

npm install @hickory/in-memory

The server doesn't need a fully functional history object. Instead, the server only needs a history object that knows its location and how to generate URLs.

The createReusable function exported by @hickory/in-memory is made specifically for this job. createReusable takes history options and returns a history function.

createReusable creates internal functions for location parsing/stringifying ahead of time so that they don't need to be recreated for every request.

// handler.js
import { createRouter } from "@curi/router";
import { createReusable } from "@hickory/in-memory";

let reusable = createReusable();

When creating the router, we must pass a history option with the location of the request.

function handler(req, res) {
  let router = createRouter(reusable, routes, {
    history: { location: req.url }
  });
  // ...
}

Routes

As stated above, the prepareRoutes function is used to pre-compile routes, which means that they don't end up being re-compiled for every single request. If all of an application's routes are synchronous (they don't use route.resolve), then they don't need to do anything else for server-side rendering.

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() {
      return fetch("/test-data");
    }
  }
]);

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) {
  let router = createRouter(reusable, routes, {
    history: { location: req.url }
  });
  router.once(({ response }) => {
    // ...
  });
}

Once the response is generated, we are ready to render. This step will generate the HTML string for the application. 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/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.

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

function renderHandler(req, res) {
  let router = createRouter(reusable, routes, {
    history: { location: req.url }
  });
  router.once(({ response }) => {
    let Router = createRouterComponent(router);
    let markup = renderToString(
      <Router>
        <App />
      </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 to properly function.

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!

The meta property of a response is useful for server-side rendering. For example, routes can set meta.title to be the page's title, which can be inserted into the generated HTML.

import { renderToString } from "react-dom/server";
import { createRouterComponent } 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) {
  let router = createRouter(reusable, routes, {
    history: { location: req.url }
  });
  router.once(({ response }) => {
    let Router = createRouterComponent(router);
    let markup = renderToString(
      <Router>
        <App />
      </Router>
    );
    let html = insertMarkup(markup, response.meta.title);
    res.send(html);
  });
}

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 redirect property. redirect.url is that full URL (pathname, query, and hash).

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

function renderHandler(req, res) {
  let router = createRouter(reusable, routes, {
    history: { location: req.url }
  });
  router.once(({ response }) => {
    if (response.redirect) {
      res.redirect(301);
      return;
    }
    // otherwise, render
  });
}