For a considerable time, influenced by the scale and technology stacks of projects I’ve been involved in, I’ve approached the client and server sides of applications as fundamentally separate entities. The client, in this model, operates as an independent application, often residing on a distinct host with its own configuration, code repository, and CI/CD pipelines. Within a PaaS environment like Azure, this paradigm is often logical due to the ease of scaling instances up and down, which contrasts with the potentially higher maintenance overhead of IaaS, where container and operating system upkeep falls on the user.
Introducing a Personal Project: Link Management Made Easy
Recently, I embarked on a personal side project: developing a link management service, similar to bit.ly, to enhance my social media link strategy. My primary goals are to retroactively repair broken links and deliver a more consistent user experience. While numerous open-source solutions exist, I saw this as an opportunity to build my own, driven by both enjoyment and a desire to learn.
Cost-effectiveness is a key consideration for any side project deployment. Domain names and hosting fees can accumulate rapidly when your work isn’t generating revenue. This led me to reconsider my existing infrastructure. I already host my blog, and this new link service, also based on Node.js, requires an admin panel for link management. The idea of consolidating these services onto a single server emerged as a way to reduce expenses and explore the intricacies of file hosting within Node.js.
However, cost savings aren’t the only reason to serve a Single Page Application (SPA) from your Express server.
Perhaps you’re deploying to a more traditional server environment that demands regular patching and maintenance. In such cases, minimizing the infrastructure footprint requiring this level of attention becomes crucial. (Confession: this is also a significant motivator for me.)
Alternatively, you might prefer your SPA to be accessible at your-domain.tld/app
rather than app.your-domain.tld
. Achieving the former is straightforward when your API serves the SPA, a process we will explore step-by-step.
Understanding the Basics: Anatomy of an Express Application
One of the primary reasons I chose Node.js and Express for my API development is the simplicity of setting up a functional web server. (And, between us, I’ve developed a real fondness for JavaScript, though don’t tell my colleagues – reputation and all that! 🤫)
Here’s a remarkably simple Express server example. Sending a GET request to the /ping
endpoint will return a pong
message, verifying the server’s operational status.
As you can see in the streamlined folder structure below, the demo’s organization is intentionally flat for clarity.
To keep this guide concise, I’m assuming some prior knowledge of Node.js and Express. In essence, Express applications are constructed using a series of middlewares that process requests sequentially – and this sequence is critical. Endpoint matching follows the same principle: the first route that satisfies the incoming request is executed, regardless of whether a more specific route is defined later in the configuration.
Enhancing Functionality with Middleware
The inherent nature of middleware order might tempt developers to add all routes directly to the main Express server file (typically index.js
) to meticulously control execution sequence. However, this approach can quickly lead to code that is difficult to manage. A more scalable solution is to organize major routes using the Express Router. For instance, suppose we need to incorporate “admin” routes for our new admin portal. We can achieve this by instructing our application to delegate requests starting with /admin
to a separate file dedicated to admin route handling.
This method promotes code separation into logical modules, simplifying the process of adding or removing admin routes without constant refactoring of the main application configuration file. But how do we define a router in a separate file and specify its routes?
The code here is designed to be quite self-explanatory. We instantiate a new Express Router, define a route, and manage the request similarly to how we would within our index.js
file. The distinction is that instead of appending the get
operation to the main app
instance, we append it to the router. Given that we are serving an SPA, and a defining characteristic of SPAs is their self-handling of routing, we establish a rule that matches any sub-route under /admin
and serves the SPA’s index.html
page. In typical SPA deployments, this routing is handled by a reverse proxy or web server configuration. Now, Express enables us to manage this routing directly within our application. Note that we are serving an index.html
file from a directory named admin-client
, expected to reside in the application’s working directory (typically the project root).
You’ll notice that route definitions within the controller don’t require the /admin
prefix. This is because we’ve already specified the router’s mount point in our index.js
file. Should we decide to relocate the SPA to /app
instead of /admin
, a simple one-line change in index.js
is all that’s needed, highlighting another advantage of using the Express Router in your projects.
Assuming your SPA is built and placed into the admin-client
folder, navigating to http://localhost:3000/admin
in your browser might initially result in a blank page and browser console errors. We’re still missing a crucial step. Currently, every request to /admin/*
is met with our index.html
page. Need a CSS stylesheet? Here’s index.html
! Favicon? More index.html
! While we’ve configured routing to serve our pages, we haven’t yet enabled the serving of static content. Thankfully, Express provides built-in functionality for this.
Preceding our router definition, we introduce another middleware configuration. On the same route path, but this time employing the express.static
middleware. As you might deduce, this middleware handles static file serving. When a request is made for a resource under /admin
, it first passes through the express.static
middleware. This middleware attempts to locate a matching file within the admin-client
directory. If found, it serves the file. If not, the request falls through to our admin controller. Restarting your Express server and refreshing your browser should now correctly render your SPA.
To illustrate the middleware execution flow, if you were to reverse the order of express.static
and router implementations, you would encounter the same issue as before adding the express.static
middleware. All requests to /admin/*
would be intercepted by the router middleware, consistently returning index.html
. This is why the order of declaration, with express.static
preceding the router, is essential.
Final Thoughts on Express SPA Serving
For API-centric applications, or scenarios prioritizing cost efficiency, serving your SPA with Express can be an excellent solution. However, it’s important to consider long-term scalability implications compared to, say, CDN-based content delivery. The Express documentation also recommends deploying production workloads behind a reverse proxy. This best practice allows the proxy to handle tasks like caching the index.html
file and leverage its strengths in areas where Express is not optimized.
One aspect I’m still refining, as my project matures, is the optimal build and deployment strategy. Initially, client and server repositories were separate, reflecting their independent treatment. Realistically, the process involves building the SPA, integrating it into the Express server, and then publishing the combined application. This suggests exploring a monorepo setup or another approach to streamline this workflow.
Furthermore, with plans to integrate my blog into this server, containerization might be on the horizon. Stay tuned for updates as this service evolves, and I can share more insights from this ongoing experience.