For a few years now I’ve wanted to write about the various architectural approaches for web frontends. I’ve hesitated, however, because of a lack of experience with one particular approach: streaming HTML from the server (e.g. Phoenix LiveView). I looked for an opportunity to try out that approach to get more experience with it before I wrote, but that opportunity never came: it didn’t seem to be a good fit for the professional and side projects I had.

But there’s been a shift in the frontend ecosystem that’s motivated me to finally write. The new React docs now recommend using a third-party framework for React web apps created from scratch. Previously it felt like single-page apps (SPAs) were the default React approach with the first-party Create React App tool, and frameworks were for if you needed enhanced behavior such as server rendering. But now that frameworks are the official recommendation, I don’t feel like I have a default anymore. I don’t want frameworks to be my default because I strongly feel the downsides, but SPAs’ downsides are now more pronounced too.

Without a default to lean on, each time I start a project I need to think though the tradeoffs of the different architectural approaches. Which isn’t necessarily a bad thing! Having processed through the tradeoffs more extensively, I wanted to share my current thoughts on them.

Take this as a snapshot of what I’ve had the opportunity to learn so far—both “here’s what I know” and “here’s what I know could use more research.” Cunningham’s Law may help me out here. If any readers have information or experiences to fill in gaps in my knowledge I’d welcome that—feel free to contact me via Mastodon or email. I tend to give more consideration to openhanded opinions, so “here’s a way to mitigate a downside” will carry more weight with me than “here’s why my preferred approach actually has no downsides.”

This article is focused on rich web applications, not static web pages/documents, so I don’t go into the topic of static site generation. The boundary between web pages and web apps is fuzzy, hence frameworks like Next.js that mix static, server, and client rendering. But this post focuses on rich web applications, as those are what I spend most of my time building.

With that said, let’s look at the frontend architectures as I understand them.

JavaScript Sprinkles

At the beginning of the web, JavaScript didn’t exist. All web pages were rendered by generating an HTML document on a server and returning it to the client. When something on the page needed to change, the user would click a link or button, a request was made to the server, and the server returned a new HTML page.

When JavaScript was later added to web browsers, its capabilities were limited—as were ideas for what to do with it. The use of JavaScript was generally limited to adding a little animation or interactivity to small portions of the page. DHH, the creator of the Ruby on Rails framework, refers to this approach as “JavaScript sprinkles,” in contrast to some of the other approaches we’ll discuss next that use significantly more JavaScript.

Pros of this approach include that almost all of the UI is defined in one place: the server templates. There is also a strong separation between backend and frontend: you can use any JavaScript library with any backend, even using different JavaScript libraries on different pages as needed. This approach also still relies on the web browser for navigation, so the URL, back button, and history work as you would expect.

A key downside of the JavaScript sprinkles approach was that there was limited ability for rich interactions. The DOM wasn’t changed much because almost all of the initial HTML came from the server.

It’s still possible to use JS sprinkles today, but now that so much JavaScript development has shifted to other approaches, it can be difficult to find third-party libraries that work with this approach.

Frontend Embedded in Backend

To allow for richer, more app-like interactions on the web, more substantial JavaScript UI frameworks emerged that allowed developers to implement larger widgets. Each page would still be rendered by the server, but a significant chunk of that page might be created by JavaScript. Embedding individual components in a server-rendered page isn’t the way we usually think about React development, but the React docs still do describe how to use React for part of your existing page. And Vue.js is called “the progressive JavaScript framework” because it’s incrementally adoptable, and its docs have been organized to emphasize how it can easily be dropped into an existing server-rendered application.

The simplest way to embed frontend widgets within backend-rendered pages is by manually writing script tags. However, if you’re using the frontend framework extensively throughout your application then this can get boilerplate-heavy, especially when you want to pass data from backend to frontend. Some libraries exist to handle this boilerplate for you, including the recently-released Superglue for React and Rails, and Inertia which is from the Laravel ecosystem but says it can be used with any backend.

Like the sprinkles approach, frontend embedded in backend has a strong separation between backend and frontend: you can mix-and-match different frameworks on different pages, and migrating from one UI framework to another can be done incrementally. This approach also relies on native browser navigation functionality for an intuitive experience.

A downside of this approach is that it splits your UI logic across two different technologies: the server templating engine, and the frontend framework. When you begin to create a bit of UI you need to decide which of the two to implement it in, and if you later need to change that approach it requires significant reimplementation. And developers need to be familiar with and think through two different UI technologies to understand what will be rendered for a given page.

CDN-Hosted Single Page Application

A single-page application is a frontend web application that runs on a single HTML page returned from the server. Generally the JavaScript is responsible for rendering everything visible on the screen, so the initial HTML page might not include any visible HTML tags at all, only tags that load assets like JavaScript, CSS, and fonts. Because the frontend consists only of static assets, it does not need to be hosted on a high-cost application server: it can be served from a Content Delivery Network (CDN).

For a long time SPAs were the de facto “default” way to use frontend frameworks. First party tools like Create React App and Vue CLI provided a low-effort way to create an SPA in the given framework. They handled needs like transpiling custom syntax and newer JavaScript features to a form the target browsers could run, as well as bundling many source files into a form optimized for fast loading. For more custom needs, webpack was the widest-adopted JavaScript build tool for a long time, then later Vite got a lot of adoption. Recently, browser importmap functionality provides a way to potentially skip the bundling step. Build tools are not highly coupled to the code you write, so it should be fairly easy to migrate from one tool to another as long as the latter supports all the features you need.

One of the main benefits of SPAs is having your whole UI in a single technology, while getting the rich interactions that modern JavaScript frameworks provide. SPAs also fully decouple the frontend and backend so that you don’t even need to run the backend for local development: your local frontend can connect to a backend on a different server. This means developers only need JavaScript experience and tooling to work on the frontend code, which has fueled the rise of the frontend developer role as separate from backend development. Because it is hosted on a CDN, traffic cost is minimal and transfer rates tend to be better. For applications that need additional clients such as native mobile apps, an SPA accesses the backend in the same way as other clients: through an API. This can prevent inconsistency and extra work, because once you have an API endpoint for one client it can likely work for others as well. Finally, since an SPA is effectively a complete application running in the browser, you have the option to provide offline support for either connection drops or extended usage offline.

The most visible downside to SPAs is performance: rather than the browser receiving a web page and rendering it immediately, the browser has to go through several steps: receive an HTML page, load all needed JavaScript files, parse them, and execute them—and only then does the application appear for users. This may be fine for web applications that users spend a significant amount of time in on fast connections on fast devices. But for high-visibility public web sites and on slower connections and devices, this speed issue can significantly hinder a user’s experience, and SEO scores reflect that.

SPAs tend to have the concept of pages, but since it’s not actually separate HTML pages the browser doesn’t know what to do with these conceptual pages. So, to get the address bar to update to a new path so that the back button and saved links work, custom JavaScript code is needed. And it’s possible to get that navigation logic wrong. Also, significantly, there are more limited options for secure credential storage. Most commonly, local storage is used, but it is vulnerable to cross-site scripting (XSS) attacks and some authorities recommend never storing access tokens in local storage. Apparently, web workers or service workers can also be used, but I’ve never seen an example of how to implement them yourself—only offered by third-party authentication providers.

Server-Hosted SPA

Instead of hosting an SPA on a CDN, it can instead be hosted from within a server-based web application. In this approach the frontend application code is very similar to CDN-hosted SPAs, but the differences in tradeoffs are significant enough that I’m describing this option separately.

In this approach, a server-rendered web application renders the mostly-empty HTML page that boots the SPA. This can be done easily with web library or framework that accepts HTTP connections and returns HTML, whether something lightweight like Express.js or heavyweight like Ruby on Rails. The key advantage that a server-rendered application gets you is the ability to use cookies for credential storage. Cookies can be configured to be secure against XSS attacks, and although they’re vulnerable to cross-site request forgery attacks, that effect can be mitigated.

The coupling between frontend and backend is still fairly light because the frontend is rendered without knowledge of the backend it’s running within, and it receives its data via backend-agnostic API requests. A downside is that these requests will likely need to be proxied through the backend so that the cookie can be translated into an access token sent via some other mechanism—and this means more traffic on more expensive application servers.

Another downside is that you need to run both frontend and backend technologies locally for development. And the performance is no better than a CDN-hosted SPA; possibly worse, as you don’t have a CDN helping with initial load times.

Server-Rendered Frontend Framework (SRFF)

Within Frontend circles this approach is simply called “server-side rendering” (SSR). This can elicit the pushback that “server-side rendering isn’t new, it’s the original approach of the web” (as in, JavaScript sprinkes in a server-rendered HTML page). But dunks aside, SSR is genuinely different, and to clarify that I’m referring to it with the more precise name “Server-Rendered Frontend Framework” (SRFF).

In this approach, the same frontend framework code that runs in the browser for rich interactivity also runs on the server to render the initial HTML for the page. It may not be immediately obvious why this is faster than an SPA—after all, the same work is happening, whether on the server or in the browser. The speed benefit comes from fast servers with cached assets sending a visually complete web page down to the client so that as soon as it receives the first HTTP response it can render with no additional requests, parsing, or execution needed. And this speed benefit comes while still having the benefit of all of the UI implemented in a single technology.

SRFF has been developed over the years via frameworks like Next.js, Nuxt, and Remix. The the technologies it’s implemented with have advanced over time: for example, React Server Components allow for more granular control over what happens on the server and more minimization of what code is sent to the client, reducing JavaScript bundle sizes. Server-side rendering is one of the reasons the React docs recommend using a framework, although not the only reason: they point out that many such frameworks allow exporting static assets while providing other benefits.

Running frontend code on the server provides another option that is sometimes helpful: skipping the API layer entirely. You can make direct database calls from that server code, so that if the web is your only client you can skip developing and maintaining an API server and API client code. If you have other clients such as native mobile apps, though, you will likely need an API regardless. In those cases, your SRFF application can make calls to that API from its server-side code.

Another benefit of SRFF is that it handles integration between frontend and backend for you, letting you skip writing boilerplate-heavy integration code yourself. But this benefit leads right into the major downside: coupling. Your application is no longer a general React or Vue application; it is now specific to the SRFF framework you’re using, and it cannot easily be ported to another framework. If there is a bug in the SRFF framework or a breaking change that will take you a lot of effort to address, you’re out of luck. And although none of the frameworks require hosting on a single server platform, they are very complex applications, so deviating from the recommended hosting platform may require a lot of setup work. For example, Next.js is created by Vercel so their priority is on making it run easily on their own hosting platform—and you may have challenges running it on other server platforms.

Incremental Server Rendering

Parallel to the rise of JavaScript UI frameworks there have been efforts to get a richer web user experience while keeping UI rendering logic on the server.

As early as 2013 Ruby on Rails apps had a common pattern to allow Ajax requests to return JavaScript to execute, which was commonly used to replace parts of the page with updated HTML rendered on the server.

An alternative to returning HTML over HTTP responses is to use WebSockets. This has the advantage of avoiding overhead for repeatedly establishing server connections, as well as the option to proactively push updates in response to server events. There are a number of backend-framework-specific libraries for streaming HTML updates over WebSockets. Microsoft’s Blazor may have been the first (2018), but outside the Microsoft ecosystem Phoenix LiveView got more attention (announced in 2019). Following suit were LiveWire in Laravel and Hotwire in Rails. And, uniquely, HTMX provides this architecture in a backend-agnostic way, so you can use it regardless of your server-side framework.

This approach is often referred to as “streaming HTML,” but the term “streaming” specifically refers to WebSocket connections. I also want to include HTML updates in response to HTTP requests because the tradeoffs are similar, so I’m using “incremental server rendering” as a broader term.

Incremental server rendering libraries give you the advantage of keeping your UI in a single technology—the backend templating engine. They mostly remove the need for a frontend framework. They also provide the advantage of initial quick rendering, because the initial render is just a normal HTML page.

A downside is that most of these libraries are coupled to the backend framework they run within. You’ll hope that the maintainers of that framework continue to maintain this library in addition to their overall framework, because if they stop maintaining it your code can’t easily be migrated to another approach. HTMX mitigates this problem because it isn’t coupled to a specific backend. This may help it gain more momentum, as teams using any server-side technology have the option of adopting it and contributing to it.

Since all interactions require a trip to the server, this necessarily introduces a delay, even in the smallest interactions. Even if the server responded instantaneously, the request and response take time to travel. By contrast, in SPA code running in the browser, small changes like toggling a button can be handled as quickly as the JavaScript can execute. Now, I haven’t investigated interaction times in incremental server-rendering libraries myself, so I would appreciate any data on it that a reader might want to share. But the speed of light does exist, so there is going to be a limit of how quickly data transmission over a distance can happen, barring some kind of major radical rethinking.

A more significant downside of incremental server rendering is that it generally isn’t robust for offline use—whether protracted offline work or just handling connection hiccups. There is no persistent application running in the browser, only on the server, and even the smallest interactions require server connectivity. If your application is online-only then this may not be too much of a problem, but it can still magnify disruptions to a user’s experience from connection drops.

A broader limitation is that incremental server rendering has not received a lot of adoption yet. It may be trending upward so we’ll see where it goes in the next few years. But without a high degree of adoption you have fewer well-supported third-party libraries (not to mention that libraries are fragmented across the different backend technologies) so you’ll need to write more low-level code from scratch. This isn’t to say this approach is bad because it hasn’t received adoption (that would be circular reasoning), but it is a pragmatic consideration.

Decisions

To conclude, I don’t have a neat set of criteria for how to pick the frontend architecture for a given project. Really, the whole article is the criteria: you’ll want to take all the tradeoffs I discussed into consideration. Instead, I’ll share some of the decisions I’ve made on projects recently.

One of my side projects is a server-side web application with only one sprinkle of JavaScript. Slapdash is an app for creating public topical notes; I use it for reference information for programming languages and frameworks. It’s more of a web site than a web app: it displays pages of content and allows the logged-in users to edit it in a text box. The only JavaScript it uses is a library for applying syntax highlighting to code snippets. It’s such a perfect fit for the server-side web that I didn’t see any advantage to considering a more JavaScript-heavy approach, especially with all the niceties I got by using Ruby on Rails.

The client project I worked on in 2019 had a frontend embedded in the backend. It was a custom CMS for an organization’s public web site. Most of the public-facing pages only needed to be static HTML, and most of the admin pages were simple HTML forms. However, there was one key place that needed a richer user interface: a block-based page editor allowing admins to lay out pages. We implemented that page editor as a React application embedded within that admin page.

For most of my side projects—such as the latest, Riverbed—I’ve gone with a CDN-hosted SPA. Access tokens are stored in local storage, and to mitigate the risk of XSS attacks I use only widely-trusted dependencies and don’t allow any rendering of user-supplied HTML. Because I’m not looking to scale these side projects to hundreds of thousands of users, and because they’re personal utility apps that don’t inherently track super-secure information, this seems to me to be secure enough that the effort of setting up a server-hosted SPA was not warranted. Initial load time isn’t very important either for SEO or for user experience; users will generally be running the applications in a browser tab for an extended period of time.

I’ve thought about trying a SRFF on one of these side projects just to get more experience with it, but what makes me hesitate is the coupling it introduces to that framework. I want to maintain these projects indefinitely, so it’s not appealing to take on the burden of having to keep the app updated to that framework’s updates when (as I just mentioned) the apps work perfectly fine as vanilla React SPAs.

If I had a client that was deeply invested in one of the ecosystems that has a streaming HTML approach (.NET, Rails, Phoenix, Laravel) then I would recommend that they look into that option. But, because I’ve been professionally focused on React, the opportunities I tend to get are projects using React or looking to do so.