Featured image from photo by Greg Jeanneau on Unsplash
This is part 7 of my series on server-side rendering (SSR):
- 🤷🏻♂️ What is server-side rendering (SSR)?
- ✨ Creating A React App
- 🎨 Architecting a privacy-aware render server
- 🏗 Creating An Express Server
- 🖥 Our first server-side render
- 🖍 Combining React Client and Render Server for SSR
- [You are here] ⚡️ Static Router, Static Assets, Serving A Server-side Rendered Site
- 💧 Hydration and Server-side Rendering
- 🦟 Debugging and fixing hydration issues
- 🛑 React Hydration Error Indicator
- 🧑🏾🎨 Render Gateway: A Multi-use Render Server
Last time, we worked to combine the client website implementation and the server rendering architecture into a single solution. Unfortunately, where we ended up was a failing app. This week we are going to jump straight in where we left off with the goal of finally server-side rendering our website. I'll let you in on a little secret; we hit that goal this week1!
🗺 StaticRouter
At the end of our attempts to integrate our website with the server, we got this error:
Error: Invariant failed: Browser history needs a DOM
To fix this, our first instinct might be to provide a DOM using perhaps JSDOM. However, this additional dependency is not the right way to resolve this error. Instead, we can look to React Router. It turns out that besides the BrowserRouter
, React Router provides other router implementations such as HashRouter
, MemoryRouter
, and StaticRouter
. There are different scenarios where these different routers come into play – for example, the MemoryRouter
is really useful for testing, including storyboard style tests where one might want to verify that navigation would happen without actually having it happen. In the case of server-side rendering, it is the StaticRouter
component that is there to help us. StaticRouter
is pretty much what it says. It provides all the things React Router's infrastructure needs to render routes but with none of the navigation support. We tell it the location we are at and it ensures that is the route we render.
To get StaticRouter
into our app we have now come to the point where our client-side website implementation can no longer be oblivious to our server-side rendering implementation. As we shall find during our SSR adventures, this will not be the only time our client app must consider if its server-side rendering, but we can make it as seamless as possible. To start, let's modify our root App
component so that we can control whether it renders a BrowserRouter
or a StaticRouter
.
import React from 'react'; import {BrowserRouter, StaticRouter} from "react-router-dom"; export default function Router(props) { const {ssrLocation, children} = props; if (ssrLocation == null) { return <BrowserRouter>{children}</BrowserRouter>; } return <StaticRouter location={ssrLocation}>{children}</StaticRouter>; }
import React from 'react'; import {Link, Route, Switch} from "react-router-dom"; import Router from "./Router.js"; import logo from './logo.svg'; import './App.css'; function App(props) { const {ssrLocation} = props; return ( <Router ssrLocation={ssrLocation}> <div className="App"> <header className="App-header"> <img src={logo} className="App-logo" alt="logo" /> <div className="App-links"> <Link className="App-link" to="/">Home</Link> <Link className="App-link" to="/about">About</Link> <Link className="App-link" to="/contact">Contact</Link> </div> </header> <section className="App-content"> <Switch> <Route path="/about"> This is the about page! </Route> <Route path="/contact"> This is the contact page! </Route> <Route path="/"> This is the home page! </Route> </Switch> </section> </div> </Router> ); } export default App;
require("./require-patches.js"); const express = require("express"); const React = require("react"); const {renderToString} = require("react-dom/server"); const {getPageTemplate} = require("./get-page-template.js"); const App = require("../client/src/App.js").default; const port = 3000; const app = express(); const pageTemplate = getPageTemplate(); const renderPage = (reactComponent) => { const renderedComponent = renderToString(reactComponent); return pageTemplate.replace('<div id="root"></div>', `<div id="root">${renderedComponent}</div>`); }; app.get("/*", (req, res) => res.send( renderPage(<App ssrLocation={req.url} />), )); app.listen(port, () => console.log(`Example app listening on port ${port}!`));
The first thing I did was to create a new component in our client app called Router
. This component encapsulates the choice between using BrowserRouter
or StaticRouter
. It takes two props: children
and ssrLocation
. The first, children
is what the component will render as its children (this is a React idiom – to use the prop children
for any child components that a component will render). The second, ssrLocation
is the URL that will be used with the StaticRouter
when server-side rendering. If ssrLocation
is not specified, we assume we are client-side and use BrowserRouter
; if ssrLocation
is specified, we assume we are server-side and use ServerRouter
.
Second, I updated our App
component to use this new Router
component and to allow for the provision of an ssrLocation
prop. I have highlighted the more important lines of that change.
With the client-side app now smart enough to handle the two worlds, we need the server to provide the ssrLocation
prop. This change is highlighted in the server/index.js
.
If we run the server with yarn start
and visit localhost:3000
to see if it worked.
Success-ish! We can even visit a different route like localhost:3000/about
and see that it renders the about page. Brilliant, even though there is no nice spinning SVG and no styles, we rendered our app without an obvious error. Still, where is that logo and where are out styles? If we look at the server's console we see a couple of errors indicating that some files were requested but we failed to return them because we failed to decode the URL. Are these errors a clue to our missing CSS?
yarn run v1.21.1 $ NODE_ENV=development babel-watch index.js Example app listening on port 3000! URIError: Failed to decode param '%PUBLIC_URL%/manifest.json' at decodeURIComponent (<anonymous>) at decode_param (/hello-react-world/server/node_modules/express/lib/router/layer.js:172:12) at Layer.match (/hello-react-world/server/node_modules/express/lib/router/layer.js:148:15) at matchLayer (/hello-react-world/server/node_modules/express/lib/router/index.js:574:18) at next (/hello-react-world/server/node_modules/express/lib/router/index.js:220:15) at expressInit (/hello-react-world/server/node_modules/express/lib/middleware/init.js:40:5) at Layer.handle [as handle_request] (/hello-react-world/server/node_modules/express/lib/router/layer.js:95:5) at trim_prefix (/hello-react-world/server/node_modules/express/lib/router/index.js:317:13) at /hello-react-world/server/node_modules/express/lib/router/index.js:284:7 at Function.process_params (/hello-react-world/server/node_modules/express/lib/router/index.js:335:12) URIError: Failed to decode param '%PUBLIC_URL%/favicon.ico' at decodeURIComponent (<anonymous>) at decode_param (/hello-react-world/server/node_modules/express/lib/router/layer.js:172:12) at Layer.match (/hello-react-world/server/node_modules/express/lib/router/layer.js:148:15) at matchLayer (/hello-react-world/server/node_modules/express/lib/router/index.js:574:18) at next (/hello-react-world/server/node_modules/express/lib/router/index.js:220:15) at expressInit (/hello-react-world/server/node_modules/express/lib/middleware/init.js:40:5) at Layer.handle [as handle_request] (/hello-react-world/server/node_modules/express/lib/router/layer.js:95:5) at trim_prefix (/hello-react-world/server/node_modules/express/lib/router/index.js:317:13) at /hello-react-world/server/node_modules/express/lib/router/index.js:284:7 at Function.process_params (/hello-react-world/server/node_modules/express/lib/router/index.js:335:12)
If we run the server in production mode with yarn start:prod
and revisit localhost:3000
, we see the same HTML rendered with no logo nor CSS and this time, there are no server console errors. However, if we look in the browser console, we see some curious errors:
If we select the Network tab and refresh, then click on the response for one of these files, we can see exactly what is happening to cause the console errors, and see a clue as to why our logo is missing.
Our server is responding to every request as if it were the request for a page, when we really want it to return the files that were requested. In addition, we can see that instead of being written with tags, the SVG has been written out with HTML entities. These are two different problems. Let's fix the SVG one first.
🎩 Repetitive Magic
If you recall, last time around we taught NodeJS how to import SVG and CSS files with custom import extensions that looked like this:
const fs = require("fs"); const requireText = function (module, filename) { module.exports = fs.readFileSync(filename, 'utf8'); }; require.extensions[".svg"] = requireText require.extensions[".css"] = requireText;
This is then used in our App
component as follows (I have pared this down to just the import and usage for clarity):
import logo from './logo.svg'; <img src={logo} className="App-logo" alt="logo" />
That is not right. Of course our logo is not showing up because we're producing a nonsense source string. To see why our client app does not have this issue, let's run it in dev mode and see how that img
tag gets rendered.
<img src="/static/media/logo.5d5d9eef.svg" class="App-logo" alt="logo">
That is weird. That file path does not even exist. It turns out webpack is doing some magic work for us to ensure that the import of the SVG file becomes an actual URL to the static file on disk rather than some nonsense inline pseudo-HTML like we have from our server. So, what do we do?
Well, we could do a few things:
- Work on mimicking this behavior, continuing down our path of trying to import the client code inside of our server app, providing our own magic for dealing with SVG files.
- Change the build process for the client-side app so that the server can use the built version of the JavaScript and benefit from webpack's magic.
- Something else entirely.
The advantage to option 1 is that it would be an interesting problem to solve. We could change our custom import stuff to track what SVG gets imported and then work out some way of ensuring that its reference in the React causes the URL to appear instead of the content (perhaps by using a property accessor). The disadvantage is that this feels like work we should not need to do; like we're fighting against the current. There are already tools doing this magic work; why do we need to reimplement that magic? Why can we not just reuse it as it is?
With option 2, it means starting to unpack all the things that the create-react-app
tool set up for us, taking control of the webpack bundling configuration and all that good stuff, so that we can import the production builds of files. Even if we managed it, it would not work for development, since our render server is not requesting files from the webpack dev server. In addition, it seems like the problem is now dictating odd parts of our solution; why should the client build be optimized only for server use when it also has to work in the client, which may have different concerns? There has to be a better way and there is. However, before we discover that better way, our option 3 – something else, we should first consider the other problem; our render server is trying to render file requests as web pages. What we need is for our server to serve the files themselves instead.
📤 Serving Files
To be clear, in a real world situation, we may well use a CDN to orchestrate some of this work. Based on its rules, it would either server files and cached renders, or request a new render from our render server, and our render server would obtain any files it needs either from the CDN or from a local folder that replicates those files. However, we want to learn more and so let's assume for now that we don't have a CDN and our render server is going to be the server browsers talk to first. Therefore, it's going to need to know how to serve the different types of requests; files, pages, etc.
This is a solved problem, so it should be pretty easy to get working. Part of the issue is that our handler currently assumes everything is a request for HTML. Instead, it should probably be looking at the Accept
header in the request to determine what is needed and then act accordingly. However, we must consider both our final production behavior where files will be served from a static folder, and our development behavior where we are going to want to get those files from webpack's dev server so that we can benefit from its development time bundling and static file generation.
Production
Since development is going to be a little harder, let's work on production first, since that is not just easier, it's a lot easier. Thanks to the built-in static
middleware that Express provides, we can specify our build
folder as the source of static files and express does the rest.
if (process.env.NODE_ENV === "production") { app.use(express.static("../client/build")); } app.get("/*", (req, res) => res.send( renderPage(<App ssrLocation={req.url} />), ));
Before our route handler, we add a check for production, and if it passes, we add the middleware for the /static
route. If we refresh our production server, amazingly, everything appears to work2!
Development
If only development was as easy. I mean it could be, we could just defer all our calls to the client webpack dev server and we would have a working site, but we would not be testing out any actual server-side rendering. Instead, we need to proxy everything to the webpack dev server first, including the web pages. Why the web pages too? Well, if you may recall earlier in this post, our early attempts had two server-side errors where we failed to interpret URLs. This was because the index.html
template for development requires some string substitution that webpack handles for us. If our render server loads that template via webpack, not only will it give us the right substitutions, but it is going to embed the CSS and scripts for us too. Of course, since we also want to stick in some rendered HTML, we will need intercept those specific requests and add our own bits.
Before we can do any of that, we need to know the address of the client server. As it happens, it is currently the same as our render server; localhost:3000
. That's a bit of a problem, so let's move our render server to 3001
while we're at it. That way, both the client development server and our render server can coexist.
There are quite a few changes I made to get this working, all of them in the render server itself. Since the index.html
template for the development mode is served via the webpack dev server, I modified the template loading code to only support the production build template. I also updated the server to use port 3001
and I implemented changes to the route handler so that during development, we proxied requests to the webpack dev server. Finally, I modified our custom file importers to just import as their own filenames. I made this change because as we found out, reading the file contents was not helping us and in fact, may have actually been hindering our ability to debug. By having the filenames instead, it should give a better experience for us when we're debugging.
require("./require-patches.js"); const http = require("http"); const express = require("express"); const React = require("react"); const {renderToString} = require("react-dom/server"); const getProductionPageTemplate = require("./get-production-page-template.js"); const App = require("../client/src/App.js").default; const port = 3001; const app = express(); const renderPage = (pageTemplate, reactComponent) => { const renderedComponent = renderToString(reactComponent); return pageTemplate.replace('<div id="root"></div>', `<div id="root">${renderedComponent}</div>`); }; if (process.env.NODE_ENV === "production") { app.use(express.static("../client/build")); } app.get("/*", (req, res, next) => { /** * Production is easy. We'll load the template and render into it. */ if (process.env.NODE_ENV === "production") { res.send( renderPage(getProductionPageTemplate(), <App ssrLocation={req.url} />), ); next(); return; } /** * During development, we're going to proxy to the webpack dev server. */ if (process.env.NODE_ENV !== "production") { /** * Let's make life easier on ourselves and only accept UTF-8 when it's * a text/html request. We're in dev, so we don't need GZIP savings. */ const headers = Object.assign({}, req.headers); if (req.headers["accept"] && req.headers["accept"].indexOf("text/html") > -1) { headers["accept-encoding"] = "utf8"; } /** * Now call the client dev server, which we know is on port 3000. */ http.get( { port: 3000, path: req.url, headers: headers, }, (proxiedResponse) => { /** * If our original request was text/html, we want to render * react in there. So, let's gather that response and then * render the page. */ if (req.headers["accept"] && req.headers["accept"].indexOf("text/html") > -1) { let responseBody = ""; proxiedResponse.setEncoding("utf8"); proxiedResponse.on("data", (chunk) => { responseBody += chunk; }).on("end", () => { res.send( renderPage(responseBody, <App ssrLocation={req.url} />), ); next(); }).on("error", e => { res.status(500); res.send(e); }); return; } res.writeHead(proxiedResponse.statusCode, proxiedResponse.headers); proxiedResponse.pipe(res, {end: true}); next(); }, ); } }); app.listen(port, () => console.log(`Example app listening on port ${port}!`));
const fs = require("fs"); let productionTemplate; export const getProductionPageTemplate = () => { if (!productionTemplate) { productionTemplate = fs.readFileSync("../client/build/index.html").toString(); } return productionTemplate; };
const fs = require('fs'); const requireText = function (module, filename) { console.log(`Pretend import of ${filename}`) module.exports = filename; }; require.extensions['.svg'] = requireText require.extensions['.css'] = requireText;
After making these changes, if we fully reload our development server, it appears to work! Fantastic! We're done right? Well, not quite. Though it may not be obvious, especially when compared with some of our earlier errors, we have some problems remaining:
- The HTML we render on the server has the wrong SVG path in it. Because we're rendering the non-production version of the
App
component in the server, it has no access to the magic sauce that webpack introduces. This is going to be the case for any other similar imports we do. - We are not yet performing what React refers to as hydration. This means that all the work we did on the server is pretty much ignored once it reaches the client, and the React app is remounted from scratch. This may not seem important for our little app since it is already so fast (mainly because it does almost nothing), but for a bigger application all we have done is make serving the page slower with no actual benefit!
👋🏻 Until next time
Next time we will work on addressing these issues and finally have server-side rendering that works. Of course, even then our journey is not over. With a fundamental render server working, it will be prime territory for us to branch out into dealing with some gnarlier problems like server-side rendering parts of an app that rely on asynchronous operations like data requests, speeding up our app by only loading the parts we need, and how to optionally render components between server and client without getting rehydration errors.
As always, thanks for joining me on this journey through the lands of server-side rendering with React. Until next time, have a lovely week! 💙
2 thoughts on “⚡️ Static Router, Static Assets, Serving A Server-side Rendered Site”