For the longest time, largely because of the size and tech stacks of projects that I have worked on, I've treated the client and server of an application as strictly disparate entities. The client is it's own self contained application that often lives on a different host with it's own configuration, code repository and CI/CD pipelines. In a PaaS environment (like Azure) I think this pattern makes sense because spinning up and down new instances is easy and doesn't come with the same maintenance as perhaps IaaS where your containers and operating systems need to be maintained, patched and updated yourself.
I recently started working on a personal link service, like bit.ly or similar that allows me to better manage my links on social media. Mostly to allow me to retro-actively fix stale links or provide a more consistent link experience. I know there are plenty of open source variants that do this but I wanted to use the opportunity to roll my own. Mostly for fun, partly to learn.
As with all side projects I want this to be cheap when I deploy it, vanity domains and hosting add up really quickly when you don't monetize any of your work. It got me thinking. I already host my blog, I'm about to run another node server and it also needs some sort of admin panel so that I can enter and edit links. Perhaps I can consolidate all of these onto the one server, save costs and learn a little bit about hosting files in node.
You might have other motivations for serving a Single Page Application (SPA) from your Express Server though.
You might need to deploy to a more traditional server that requires patching and maintenance and you really want to minimize the amount of infrastructure that requires that level of up-keep. (I'm not going to lie, this is another motivation for me).
Alternatively you might want your spa to live at
your-domain.tld/app rather than
app.your-domain.tld. The former is trivial to do if it's being served by your API which we will step through now.
Here is a really simple Express Server, you can send a get request to the
/ping endpoint and be returned the
pong message to know the server is alive.
I even have an extremely flat folder structure for this demo as you can see below.
I'm going to assume some prior knowledge for node and Express here to keep this post short. In general though Express applications are built by a series of middlewares which execute against your request in order (and that order matters). The same is true for evaluating which endpoint actually receives the request, it matches the first route that satisfies the request even if a more specific one is defined later.
The natural consequence of maintaining middleware order could be to continue to add all your routes to your main Express Server file (normally
index.js) to try and preserve the order you want them evaluated in. That gets messy though so where possible you want to compose your major routes with the Express router. For example let's say that we want to add some "admin" routes for my new admin portal. I could do the following and tell my app to use a different file to manage any routes that start with
Now I can separate my code out into logical units with more ease and also add and remove arbitrary admin routes without constantly having to go and refactor my main file that is largely just configuring the application. But how might we define a router in a new file and specify some routes?
Hopefully the code here is fairly self documenting. We create a new Express Router. We create a new route definition and then handle the request like we were in our
index.js file, except instead of appending the
get operation on the app itself we append it to the router. Because we want to serve a SPA, and one of the defining features of a SPA is that it handles it's own routing, we want to create a rule that matches any sub-route of
/admin and just return the index page of the SPA itself. In scenarios where the SPA is hosted by itself this would happen in your reverse proxy or your web server configuration, but now we can easily do it in from within Express itself. You notice that I am serving an
index.html file from a folder called
admin-client that should be in the current working directory of the application (typically the project root).
You will note that I don't have to prepend my routes with
/admin in the controller because we specified where to attach the router in our
index.js file. If we decide in a week that we prefer for the SPA to live at
/app instead of
/admin we can simply change the one line in our
index.js file and the routes all work again, yet another reason to pick up the Express Router in your projects.
Assuming you have built your SPA and dropped it into the
admin-client folder you should notice that running your application and hitting
http://localhost:3000/admin in your browser nothing renders and you get lots of errors in the browser console. We're still missing one step, now any time we try to request any file at all (remember the
*) we are returning our
index.html page. Want your css stylesheet? Have our
index.html! What about a favicon? You guessed it more
index.html. While we have set up routing to deal with serving our pages we haven't added anything to serve our static content and Express provides that functionality out of the box as well.
Above our router definition we add another middleware definition, on the same route but instead using the
express.static middleware. You might be able to guess what this is doing. When a request for a resource to
/admin is made it first runs through the
express.static middleware and attempts to find a file in the
admin-client folder that matches the request file. If one is found it returns it, if one is not is falls through to our admin controller. Restarting your Express Server and refreshing your browser you should now see your SPA being rendered correctly.
To illustrate exactly how middleware operates if you were to swap the
express.static and router implementations around you would end up with the same issue as when we hadn't specified the
express.static middleware at all. All requests to
/admin/* would get caught up by our router middleware and always returns
index.html which is why we declared it the way we did above.
For an API first solution, or one where you want to save on costs this could be a really great solution. You would have to worry about issues of scalability long term (as opposed to say serving your content via a CDN), and the Express documentation says that production workloads should live behind a reverse proxy. Mostly so that the proxy can do things like handle caching of the
index.html file and generally do what reverse proxies are good at doing (and Express is not).
One thing I have not done yet (as the project isn't quite as polished as I would like) is determine the best way to actually build and deploy this solution. When I started my repositories were separate, because I was treating the client and server separately. In reality because I need to build my SPA, drop it into my Express Server and then publish that perhaps I should be looking at a Monorepo set up or some other way to streamline the process.
I also think given I need to consolidate my blog into this server as well that there may be some containerization coming my way. Stay tuned as the service rolls out and I can offer more learnings from this experience.