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:
- Speeding up the initial render time.
- 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.
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.
Familiarity with Express is not expected, so to get you started, this guide will provide some code snippets for a basic setup.
The server's setup code can be placed anywhere, but we will follow Node's convention and save it in a
With the server ready to go, we can start configuring it to render a single-page application.
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.
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.
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.
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.
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.
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:
- By wrapping the routes array in a
prepareRoutescall, all of an application's routes are pre-compiled. Without
prepareRoutes, the route pathes would need to be re-compiled for every request!
- We will use a lightweight history type created specifically for server-side rendering.
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.
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.
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.
When creating the router, we must pass a
history option with the location of the request.
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.
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.
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
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.
renderToString only generates an HTML string for the application. We are missing the
<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
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.
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.url is that full URL (