In this post I’ll cover the first of two design patterns that is entirely Client-Side Rendered (CSR) and supports all the multi-page behaviours. As far as I can tell this pattern doesn’t have an existing name, so for the purposes of this blog series I’ll refer to it as the Simple CSR-MPA pattern.1
Let’s break that down:
- It’s entirely Client-Side Rendered—that means it runs entirely in the browser, and nothing but static content is served from a web server or CDN.
- It supports all the multi-page behaviours—that means it’s shareable, deep linkable, refreshable, history traversable, link operable, and multi-tabbable.
This design pattern can support an unlimited number of distinct URL paths, yet requires only a small number of static content files hosted on your web server or CDN.
As far as I know, this design pattern implements multi-page behaviours in the simplest possible way for a CSR application. But there’s a price you pay for this simplicity: every time the user clicks a link, it reloads the page. That’s not as bad as it sounds though—web applications implemented this way can be surprisingly fast.
In the next post I’ll describe how to overcome that particular limitation—but first, it’s worth examining this pattern in more detail, since it’s a foundational building block used in many more advanced designs.
How does the Simple CSR-MPA pattern work?
When you open an application using this pattern for the first time, it cycles through four steps, summarized in the diagram above. The steps are:
- Load outer HTML
- Load application
- Render page
At the end of step 4, you are looking at a brand new web page with a new URL.
If you later decide to click a link in that page, it triggers a new page reload and repeats the same four steps, all over again. The same four steps are followed if you primary-click a link, right-click a link and open it in a new browser tab, click Back or otherwise revisit your history, reload the current page, or open a deep link or bookmark from outside the application. They’re all handled in exactly the same way. That’s what makes this design pattern simple!
To show this pattern in use, I’ve taken the demo application from the previous post and modified it to implement the Simple CSR-MPA pattern. Here’s a screenshot which links to the updated demo application:
If you try the new demo application, you’ll notice two major changes:
- All the Not found errors from the previous application have now been fixed: links can be opened in new browser tabs when you right-click them, previously-shared deep links and bookmarks open as expected, locations can be retrieved from the browser’s full History menu, and pages refresh when you click Reload. You can even hack the URL directly in the address bar if you want (as long as you enter a valid URL path).
- Every page-related action now causes a full page reload—even primary-clicking a link or traversing the browser history. You can tell the page is reloading because the three link colours get randomized when you perform the action.
Walkthrough of the pattern in use
Now let’s walk through the four steps to understand how this pattern works in more detail.
Step 1: Load outer HTML
When the user opens any URL belonging to the application or clicks an internal link, the browser sends a request to the web server asking for the URL’s path (as well as its query string and fragment, if present). For example, if you open https://egalo.com/smpl, the browser sends a GET request to the server (‘
egalo.com’) asking for URL path ‘
To support the Simple CSR-MPA pattern, the web server must be configured to rewrite any valid outer URL to a single lightweight piece of markup. This markup is exactly the same for all valid outer URL paths. It’s sometimes referred to as the outer HTML.
What is in the outer HTML? As little as possible. The outer HTML for the demo application is just 336 bytes in size and contains the following markup:
<!DOCTYPE html> <html lang="en"> <head> <title>CSR-MPA demo application</title> <script src="/app/csr-mpa/static/app.js"></script> <script src="/app/csr-mpa/static/ntc.js"></script> <link rel="stylesheet" href="/app/csr-mpa/static/app.css"> </head> <body onload="main()"> </body> </html>
In this particular example the
body element is empty. That’s because we’re going to build the entire DOM structure dynamically using Client-Side Rendering.
Note: In a variation of this approach, statically-generated markup can be placed here instead of an empty
bodyelement. The static markup can vary by URL path. This technique is effectively a hybrid of SSG and CSR, and is often used to improve performance or SEO characteristics of CSR applications, among other reasons.
Using this variation does not change the deployment model, since the web server or CDN only serves static files. The only rendering happening at runtime is CSR, and there is no SSR. Further, the steps needed to support multi-page behaviours are the same. For this reason I consider this to be a variation of the Simple CSR-MPA pattern.
For the rest of this post, I’ll assume that we’re talking about the “pure CSR” variation of this pattern, in which the outer HTML contains an empty ‘body’ element, which is identical for every outer URL. But most of the details apply equally to the SSG/CSR hybrid approach.
With the rewrite rule in place, the web server treats every valid outer URL path as equivalent. For example, the following are all valid outer URLs for the demo application, so they all return the same outer HTML:
- https://egalo.com/smpl/4b813e (“Fern Green”)
- https://egalo.com/smpl/fe993f (“Neon Carrot”)
- https://egalo.com/smpl/055989 (“Venice Blue”)
You can verify this by opening any valid outer URL and choosing View → Developer → View Source. In each case you’ll see an identical piece of markup. That’s the outer HTML.
By convention, the outer HTML is often stored in a file called
index.html, and I followed this convention in the demo application. To show it in context, here’s the complete set of files deployed to the web root directory for the demo application:
In this example you can see
index.html, which contains the outer HTML, along with the three subresources in a subfolder called
static. Not bad, considering the application has 16,777,217 distinct web pages!
Writing a rewrite rule
How do you write a rewrite rule? In a pure CSR approach, the rewrite rule needs to map every valid outer URL path to the physical path on the web server of the file containing the outer HTML, which is a singleton. So for the demo application, the rewrite rule maps the outer URL path for the home page (‘
/smpl’) and every possible colour-specific page (‘
/smpl/<hex>’) to the static path of the outer HTML, ‘
Note that URL rewriting is completely internal to the web server, and is invisible to the browser. This is not a client-side redirect. The browser is still fetching distinct URLs—it’s just that they happen to all return the same content. For example, if the browser requests ‘
/smpl/4b813e’ then the web server immediately maps that to the outer HTML and returns its content in the response.
The steps to configure your web server or CDN to perform URL rewriting are specific to the particular web server or CDN you’re using, so I won’t describe them here. To find out how to do it, search your web server or CDN’s documentation for “URL rewriting”. In most web servers it takes only a few lines of configuration to get the job done.2
Returning outer HTML directly from a CDN
If you’re using a CDN, and your application has a large number of outer URL paths, you might want to consider turbo-charging Step 1 by returning the outer HTML directly from your CDN, without going back to the origin server.
Why would you do this, given that CDNs can automatically cache responses in any case? Bear in mind that a CDN will cache responses separately for each outer URL path. In the case of the demo application, there are 16,777,217 valid outer URL paths. So the chance of any given outer URL path having its response cached by the CDN is low. By returning the outer HTML directly from the CDN, it will be nearly as fast as if it had the response cached for every valid outer URL path all the time. Consult your CDN’s documentation for details on how to do this and on any costs involved.
Why it’s important to optimize this step
Why are we spending effort optimizing for a minuscule piece of static content? The reason is that the outer HTML is the one piece of content we cannot assume will be cached by the browser (again, assuming there are many outer URLs).
All the subresources will be cached by the browser, since they have URL paths that remain fixed while the user navigates—as we shall see in Step 2. But the URL path for the outer HTML keeps changing as the user navigates. The browser has no way of knowing that different outer URLs will return the same content. So every time it sees an outer URL path it’s never seen before, it will make a fresh request to the server.
This means that after the first page has loaded, the only network traffic generated by the application as the user navigates will be requests for the outer HTML (as well as any API calls the application makes in Step 4, described later). Until it’s received the outer HTML, the browser cannot retrieve the subresources from its local cache (Step 2). Hence fetching the outer HTML is on the critical path—nothing else can happen until it’s loaded. Anything you do to optimize Step 1 will therefore help improve overall page load times.
Setting cache control headers for the outer HTML
Should you set cache control headers to tell the browser to cache the outer HTML? You can do—but there are trade-offs. If you do, be aware that:
- The outer HTML will be cached separately for each outer URL path.
- Your web server’s access logs will no longer reliably track users as they navigate between pages.
If the application is protected using old-school server-side redirects to authenticate users, you might want specifically not to cache the outer HTML to ensure that any unauthenticated page requests go to the server and initiate the login sequence.
Step 2: Load application
OK, now step 1 is complete. The browser has the outer HTML. What happens next?
The browser parses the markup, finds references to the subresources that contain your application, and attempts to fetch them. For this to work well, several things need to be in place.
Use absolute paths for subresources
References to subresources in the outer HTML must use absolute paths such as
/app/csr-mpa/static/app.js—or alternatively, absolute URLs if you decide to host them in a different DNS domain.
Don’t use relative paths (such as
static/app.js). If you do, the browser will convert those to absolute paths relative to the current outer URL path—and since the outer URL path keeps changing, requests for subresources will return Not found errors and your application won’t work.
Ensure outer URL rewriting is not applied to subresources
The URL rewriting performed in Step 1 must not apply to the subresources downloaded in Step 2. If you configure URL rewriting to apply to all URL paths, then even requests for subresources will return the outer HTML—and your application will be screwed. To avoid this, requests for subresources must be excluded from URL rewriting, so they return their file contents in the normal way.
In order to segregate subresource URL paths from outer URL paths, you can simply place the subresources in a folder whose path is not the same as any valid outer URL path. For the demo application, I placed the subresources under
/app/csr-mpa/static/. That will never clash with outer URL paths which follow the pattern
It doesn’t matter much what subresource URLs look like, since you won’t see them in the address bar. If it makes things easier, you can place subresources in their own subdomain, such as
static.example.com. In that case, the outer URLs will have an entire DNS domain all to themselves.
Tell the browser to cache subresources
To get good performance, you must tell the browser to cache the subresources. This is important because in a typical CSR application, the subresources are orders of magnitude larger than the outer HTML—they are your application, after all. But they’re nonetheless identical for every page the user visits.
Caching the subresources will mean that when a user opens a new page, even if the browser has never seen that outer URL path before, it will load the subresources almost instantly from its local cache.
To enable browser caching, you need to configure your web server or CDN to include an additional HTTP header in the response for each subresource. For example, the following header tells the browser to cache a response for 86,400 seconds (24 hours):
Again, the configuration steps needed to implement this are specific to web server or CDN you are using, so I won’t describe them here.
Putting it all together: steps 1 and 2
With all the above in place, Steps 1 and 2 should look something like this (this example is taken from Google Chrome.app → View → Developer → Developer Tools → Network):
In this example:
- Step 1—fetching the outer HTML—took 81 ms.
- Step 2—fetching the subresources—took 0 ms.
Let’s walk through exactly what happened to produce that network log. We’ll assume the subresources have already been cached from a previous page visit.
- The user opened a new page https://egalo.com/smpl/feddfe (“Pink Lace”).
- The browser has never seen Pink Lace before, so it sent a GET request to the server asking for URL path ‘
- The web server’s rewrite rule recognized ‘
/smpl/feddfe’ as a valid outer URL path, so the rewrite rule kicked in and generated a response containing the outer HTML.
- The outer HTML was served very fast (81 ms), since it’s a very small piece of static content.
- The browser received the outer HTML, parsed the markup, and found references to the subresources.
- The browser realized that it had all three subresources already in its local cache—so instead of fetching them from the server again, it fetched them from its local cache almost instantly (0 ms).
The end result is that once the user has visited at least one page in the application, then for every subsequent page Steps 1 and 2 complete incredibly fast—even for pages they’ve never visited before!
Step 3: Router
Steps 1 and 2 are now complete, and the browser has all the static content it needs. It’s time for the application to spring to life.
If you’re using a web framework, then your framework will take over at this point. But let’s take a brief look at what needs to happen, framework or not.
The outer HTML is processed first, and it must tell the browser to run the application. In the demo application, the
onload property of the
When the application starts running in the browser, its main job is to render the page. But what page should it render? That depends on the current outer URL, which is displayed in the address bar. The application must therefore ask the browser to tell it what the current URL is.
const url = window.location;
The client-side code now has a Location object, which has properties for all the useful elements of the current URL. For example,
url.pathname returns the URL path.
Using this information, the application can now invoke the appropriate client-side code to render the page. If required, different URL paths (and query strings and fragments, if present) can result in different page renderings based on logic contained entirely within the client-side code.
If you’re used to traditional Server-Side Rendered (SSR) applications, this might seem back-to-front. Normally it’s the web server that parses the URL path and decides what to render. But in this case, it’s the browser!
Web developers use the term router to describe the component that parses the URL and hands off control to the appropriate code to take action. In traditional SSR applications, the router resides on the web server. But in a CSR application that supports multi-page behaviours, the router resides on the client. The application still has distinct URL paths—but the decision about what page to render is deferred until the application is running in the browser.
So Step 3 is basically the application’s router. Furthermore, this is the first time the application has done anything URL-specific. In steps 1 and 2 we basically ignored the outer URL (apart from checking its validity), and treated all outer URLs as interchangeable. Step 3 is the first time we’re saying “do something different, depending on the current URL”—and we’re doing that in the browser.
Step 4: Render page
In Step 4, the client-side code invoked by the router renders the page. It effectively builds what would have gone in the outer HTML’s
body element, if this had been a traditional SSR application. Instead, we’re doing Client-Side Rendering, so we’ll drive the DOM programmatically.
In the demo application, Step 4 is trivially simple—it sets the background colour of the page to match the hex value it found in the URL path, and not much else.
In a real application, this step would obviously do a whole lot more. The application might call server-side APIs to fetch additional data relevant to the current page. For example, if the outer URL path is ‘
/resource/271’, it might call server-side APIs to fetch all kinds of rich information about resource 271 and display it.4
Such API calls are described as asynchronous if they run in the background after the page has loaded. This is what allows the Simple CSR-MPA pattern to display rich information on each page, even though it reloads the page each time the user navigates.
There may be additional things your application needs to do this point—such as updating the page title, scrolling the window to the expected position, changing focus to the right element, ensuring assistive technologies (such as screen readers) respond to the change appropriately, and so on. These are outside the scope of this blog post, but you’ll need to consider them in a real-life application.
A page rendered in Step 4 will probably include links to other pages within the same application. In the Simple CSR-MPA pattern, links are perfectly ordinary, regular hyperlinks—there is nothing special about them at all. No event listeners are attached. The History API is not used.
When the user clicks on a link, the application goes back to Step 1 and repeats everything above, all over again.
Storing security tokens
In authenticated CSR applications, managing sessions and store security tokens is an important consideration. It gets more interesting for CSR applications that support multi-page behaviours, since they need to handle page reloads and opening pages in multiple browser tabs. I won’t cover this topic here, as this post is long enough already, but I’ll try and touch on it briefly in a later post.
When the Simple CSR-MPA pattern is useful
As mentioned earlier, the Simple CSR-MPA pattern is a foundational building block used in more advanced design patterns—including the one described in the next post.
But even on its own, this pattern can work well in certain situations. What types of applications might work well with this design pattern?
Types of application that work well with this design pattern
A web application that’s a good candidate for the Simple CSR-MPA pattern will probably have attributes similar to these:
- Utilitarian. The application exists to serve a purpose. You’re not trying to win awards for aesthetics.
- Lightweight. The application’s pages are relatively simple and they open quickly following a page reload.
- Resource-centric. Different URL paths correspond to different resources, and there’s little shared state between them.
- The users will benefit from multi-page behaviours. The application’s users will be exchanging deep links, working in multiple tabs, and so on—so they’ll appreciate multi-page behaviours for usability and efficiency.
- For architectural reasons, you want the application to run in the browser. Maybe you prefer the simpler deployment model of static site hosting, or you don’t want the operational overhead of maintaining an application server or server-side code. In most cases, either:
If your application fits this profile, the Simple CSR-MPA pattern might just be good enough. If not, the pattern described in the next post might be a better fit.
The Simple CSR-MPA pattern is a powerfully simple—and in my view, underrated design pattern. It’s particularly suited to scenarios where it’s acceptable to have the “feel” of a traditional SSR application—you can sense the page is reloading whenever you click a link—but for architectural reasons, the application needs to run in the browser.
Your users probably won’t notice anything unusual. The page will reload whenever they click a link—but that’s not unusual for web applications. That’s how they’ve worked for years.
Before wrapping up, let’s take a closer look at two main areas of concern: performance and state management.
The baseline performance of the Simple CSR-MPA pattern can be extremely fast. By baseline performance, I mean the time taken to perform only Steps 1, 2 and 3, which have fixed complexity, before doing Step 4, which is application-specific. This performance comes thanks to the caching and other optimizations described earlier.
If the pages are lightweight and CSR is relatively simple, an application using the Simple CSR-MPA pattern can transition between pages at speeds close to this baseline performance. The demo application illustrates this in the extremal case where Step 4 is trivially simple. In a real-life application, page transition times of a few hundred milliseconds can be achieved. Such an application will “feel” like a fast and snappy SSR application to users.5
In practice, web applications built using modern web frameworks can be anything but lightweight and simple. Depending on how heavyweight and complex your application is, the Simple CSR-MPA pattern might not be a good fit.
Some other factors to consider:
- You only get the full benefit of suppressing page reloads using the History API if the DOM structure is substantially similar between pages. If the new page has a completely different DOM structure, everything will need to be re-rendered anyway.
- If the web application spends a significant amount of time waiting for server-side APIs to return data, then this might dominate overall time to page use—regardless whether the page reloaded or not.
Is the Simple CSR-MPA pattern “stateless”? Not really. It can be as stateful as you want it to be for as long as the user stays on the same URL! During that time the application can exhibit all kinds of rich interactive behaviour, asynchronous updates, ability to edit data, and so on.
When the user transitions to a new “page”, the slate is wiped clean. Is the application stateless at that point? Again, not really. A small but crucial piece of state will be transferred to the new page via the URL path itself, such as ‘
/resource/946’. The new page can derive a lot of other data from that URL, especially if it calls server-side APIs.
Again, this works best in utilitarian applications, where URL paths represent distinct resources. If you need to run a media player in the corner of the screen while the user navigates between pages, this design pattern is not for you.
You can of course preserve state by other means, such as Web Storage. But you might find you don’t need to. The URL path in combination with server-side APIs may be enough.
In some cases, the Simple CSR-MPA pattern might actually simplify state management compared to using the History API. When you use the History API, your application needs to figure out the delta to modify the current DOM and ensure that client-side state is consistent with any resources on the server-side. That can get very complex. Rebuilding all client state following every page transition is probably simpler, if you can accept the trade-offs with this design pattern.
As always, caveats apply. Complex applications will have special needs and considerations not covered here.
When the Simple CSR-MPA pattern is not enough
So far, we’ve looked at two design patterns for CSR web applications that run entirely in the browser.
- The HTML5 History API used on its own, without any additional web server configuration, is able to suppress page reloads when the user navigates—but it fails to support some of the multi-page behaviours.
- The Simple CSR-MPA pattern supports all the multi-page behaviours, but it reloads the page every time the user navigates.
What if you want both? What if you want to suppress page reloads when the user navigates and support all the multi-page behaviours? Well, you merge the two design patterns together of course! And that’s what I’ll cover in the next post.
I chose that name because it’s simple, entirely CSR, and supports all the multi-page behaviours you’d expect from a traditional MPA. ↩
You might need to use regular expressions if you have many outer URL paths following the same string patterns. ↩
You will then see the latest information about the resource—even if you arrived at the page by clicking Back. This is normally the behaviour you want from the Back button. You’re travelling back to the resource, not travelling back in time. ↩
In a previous life in 2008, I developed a web application using the Simple CSR-MPA pattern, and it ran successfully in production for many years. Not once did anyone complain about performance—in fact it was very fast. It was implemented using the now unfashionable Google Web Toolkit. The HTML5 History API didn’t exist back then, so wasn’t an option! ↩