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:
- 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.
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.
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.
The server's setup code can be placed anywhere, but we will follow Node's convention and save it in a server.js
file.
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.
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.
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.
Using the above static file handler, all static file requests in HTML/JavaScript should begin with /static
.
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.
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.
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:
- By wrapping the routes array in a
prepareRoutes
call, all of an application's routes are pre-compiled. WithoutprepareRoutes
, 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.
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.
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.
When creating the router, we must pass a history
option with the location of the request.
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.
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.
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
.
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.
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 renderToString
and 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.
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
).