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.
Introducing the side project
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 this 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.
The Anatomy of a Basic Express Application
One of the reasons that I chose node (and Express) to run my API was how simple it is to get a web server up and running. (I also think I really like JavaScript now, but don't go telling my colleagues that, I have a reputation to uphold 🤐)
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.
Adding Middlewares
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 /admin
.
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 returns 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.
Some Thoughts
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.