What's a Single-Page App?


The web development community talks a lot about single-page apps, but are we all on a single page?

Heydon Pickering tackled this question in his similarly-named article The TL;DR — spoiler alert! — is that it’s a website that uses a ton of JavaScript to improve user experience by showing you a loading spinner.

That’s obviously tongue-in-cheek, but it’s a reaction to the working definition that most people use. For better or worse, “single-page app” is usually a euphemism for “JavaScript framework app”.

I recently wrote about using service workers to render everything client-side — no loading spinners in sight! In response, Thomas Broyer objected to the premise that htmx and single-page apps were opposites. He showed me an article that he wrote called (which you should also go read!) that breaks down rendering into a spectrum.

Schema of SSR, ESR, SWSR and CSR, with grouping representing SSR-in-the-broader-sense (SSR and ESR) vs. BSR (SWSR and CSR), and which generate HTML (SSR, ESR and SWSR) or manipulate the DOM (CSR)

The rendering spectrum, by Thomas Broyer.

In a bid to cement my burgeoning reputation as a , I feel compelled to add even more nuance to the situation:

A graph with two axes that intersect in the exact center, labeled SSR/CSR horizontally and SPA/MPA vertically.

I’m sorry. Kinda.

Okay, let’s define the extrema of each axis:

  • Server-side rendering (SSR) is when HTML is produced on a server and sent to the browser.
  • Client-side rendering (CSR) is when HTML (or some other representation, such as the result of a JSX transform) is produced on the client and applied to the DOM.
  • A multi-page app (MPA) is when a hyperlink click or form submission results in the browser replacing the current page with an entirely new document.
  • A single-page app (SPA) is when the browser never replaces the page with a new document, and instead makes all changes through client-side DOM manipulation.

If you just came here for an answer to the title, that’s it; I guess you can go home now. But I think it’s interesting to look at the various tools people use and how they fit in.

Most tools for building websites don’t lock you into just one quadrant. After all, any tool lets you drop in a plain un-enhanced <a> tag and at the very least get MPA behavior, and most JavaScript usage outside of Google Tag Manager relies on client-side rendering (even if done manually).

So: without casting any aspersions, here’s my ontology of web app architectures organized by rendering and navigation.

Traditional Web Frameworks and Static Site Generators

This is a pretty large tent, encompassing WordPress, Django, Rails (pre-Turbolinks) Jekyll, Hugo, Eleventy and myriad others. It also includes hand-authored HTML, though I wouldn’t describe that as a “tool” so much as a “way of life”.

Tools in this category are on the bottom left of the chart: server-side rendered multi-page apps.

A graph with two axes that intersect in the exact center, labeled SSR/CSR horizontally and SPA/MPA vertically. A shaded region labeled “Traditional Frameworks & Static Site Generators” covers the quadrant where SSR and SPA intersect.

The tradeoffs of this quadrant are well known:

  • The browser takes care of a lot of important accesibility features, such as letting screen readers know when the user navigates to a new page.
  • Delivering HTML first allows the content to be visible even if CSS or JavaScript fail to load.
  • Pages can load even faster if HTML is streamed in, rather than delivered all at once.
  • The full page must be downloaded and replaced on each navigation.
  • In fact, every interaction requires a network round trip.

This experience has remained mostly unchanged for 30 years. And it’s great! With only a little bit of HTML and CSS, you can make a pretty good website; the show just how far a few tags and properties get you. The low barrier to entry is one of the main reasons the web flourished.

Three decades on, improvements in HTML and CSS are starting to mitigate some of the downsides. Preloading resources, for example, allows the browser to preemptively download associated files, which can make navigation almost instantaneous. And cross-document view transitions — not yet well supported, but hopefully soon! — promise to allow multi-page apps to navigate with fancy animations.

That said: requiring a network request and a whole new page for every interaction is a pretty strong constraint! As developers’ ambitions grew, they leaned more and more heavily on JavaScript, which led to…

JavaScript Frameworks

Although JavaScript was invented way back in 1995, I don’t think a schism truly happened until 2010 or so. That’s when the stereotypical single-page apps began to emerge: rather than using small snippets of JavaScript to add client-side functionality to server-side rendered HTML, people started building apps with a JavaScript framework and rendering them on the client.

Note that I’m not talking about Next.js or similar tools (I’ll get to them in the next section). I’m talking about Backbone, Angular 1, React with a custom Webpack setup… basically, JavaScript apps before circa 2018, when people would ship an HTML file with an empty <body> except for one lonely <script> tag.

Used thusly, JavaScript frameworks are the diametric opposite of traditional web frameworks: both navigation and rendering happens on the client. As such, they fit neatly into the top right quadrant: client-side rendered single-page apps.

A graph with two axes that intersect in the exact center, labeled SSR/CSR horizontally and SPA/MPA vertically. A shaded region labeled “JavaScript Frameworks” covers the quadrant where CSR and SPA intersect.

What are the benefits of this quadrant?

  • The initial page load can be much faster once the JavaScript bundle is cached.
  • Page navigations can be instantaneous, since all the relevant state is already on the client.
  • Elements can persist across navigations, enabling e.g. uninterrupted media playback and fancy transition animations.
  • Modifying the UI without first going through the network enables much richer client-side interactions.

In practice, I think many of the purported benefits of client-side rendered SPAs turned out to be wishful thinking:

  • When bundles are cached with a hash of the full app code, every deploy busts the cache and forces the user to download the whole bundle again.
  • Page navigations tend to wait for API responses from a server and database in the same datacenter that would have served the HTML anyway.
  • Aspirations of being richly interactive are often fantasy; most websites are really just gussied up forms.

There are also more general drawbacks:

  • The client needs to download 100% of the UI code.
  • The initial page load (before the JavScript bundle is cached) will always be slower.
  • Page navigations are not accessible by default.
  • It’s way more difficult for apps here to be indexed by search engines.

If I sound critical of this category, it’s only because the industry has largely recognized these drawbacks and moved on to other architectures. While JavaScript frameworks are more popular than ever, they tend to exist as components of larger systems rather than than as app frameworks in and of themselves.

Client-side rendered SPAs still have their uses, though. When I made , I built it as a client-side rendered SPA. There was really no other way to build it — since the client has the canonical copy of the data, there’s not even a server to do any rendering! As local-first picks up steam, I hope and expect to see this architecture make a resurgence in a way that does capture the upside of the quadrant’s tradeoffs.

JavaScript Metaframeworks

JavaScript frameworks had about half a decade of client-side rendering glory before people realized that delivering entire applications that way was bad for performance. To address that, developers starting building metaframeworks1 — Next.js, Remix, SvelteKit, Nuxt and Solid Start, among others — that rendered on the server as well.

In metaframeworks, rendering happens in two different ways:

  1. When the user requests a page, the app runs on the server, rendering the appropriate HTML and serving it to the browser. This step is server-side rendered.
  2. Next, the browser requests the JavaScript bundle. That same app then runs in the browser, “hydrating” the already-rendered HTML and taking over any further interactions. This step is client-side rendered.

These steps slot neatly into the top left and top right quadrants, respectively:

A graph with two axes that intersect in the exact center, labeled SSR/CSR horizontally and SPA/MPA vertically. A shaded region labeled “JavaScript Metaframeworks” covers the SPA half, covering both SSR and CSR.

JavaScript metaframeworks are an attempt to get the “best of both worlds” between server-side rendered multi-page apps and client-side rendered single-page apps. In particular, they fix the cold cache initial page load and SEO drawbacks of the latter. With React Server Components, React-based metaframeworks can omit UI code from the JavaScript bundle as well.2

Depending on whom you ask, this is either good because it really is a “best of both worlds” situation, or bad because your UI is probably useless before it hydrates with the JavaScript (that your users still need to download). But “probably” in that sentence is doing at least some amount of lifting; many metaframeworks like SvelteKit and Remix embrace progressive enhancement and work without JavaScript by default.

A couple years ago, Nolan Lawson :

At the risk of grossly oversimplifying things, I propose that the core of the debate can be summed up by these truisms:

  1. The best SPA is better than the best MPA.
  2. The average SPA is worse than the average MPA.

I think that’s a fair take, but there are a couple other architectures still remaining that make things a little blurrier.

Islands Frameworks

Recently we’ve seen the emergence of a new category: server-side rendered multi-page frameworks that embrace for rich client-side behavior. While the idea itself isn’t new, the current crop of frameworks built around it are — Astro, Deno Fresh and Enhance, among others.

In case you’re unfamiliar: an island of interactivity is a region of an otherwise static HTML page that is controlled by JavaScript. It’s an acknowledgment that while richly interactive applications do exist, the richly interactive part is often surrounded by a more traditional website. The classic example is a carousel, but the pattern is broadly useful; the are built as islands within static HTML.

What that means in practice is that these websites will fit mostly into the bottom left quadrant — except for the namesake islands of interactivity, which fit into the bottom right.

A graph with two axes that intersect in the exact center, labeled SSR/CSR horizontally and SPA/MPA vertically. A shaded region labeled “Islands Frameworks” covers the MPA half, covering both SSR and CSR.

Similar to JavaScript metaframeworks, islands frameworks also try to get the “best of both worlds” between client-side and server-side rendering — albeit as MPAs rather than SPAs. The bet is that reducing complexity around the static parts of a page is a better tradeoff than giving developers more control. As with traditional web frameworks, the gap between them should narrow as support for view transitions gets better.

Partial Swapping

This pattern is less all-encompassing than some of the others, but it’s worth mentioning because the past few years have seen it explode in popularity. By “partial swapping”, I mean making an HTTP request for the server to render an HTML fragment that gets inserted directly into the page.

To wit, websites using partial swapping generally fall on the server-side rendered side of the chart, spanning both the single-page and multi-page quadrants:

A graph with two axes that intersect in the exact center, labeled SSR/CSR horizontally and SPA/MPA vertically. A shaded region labeled “Partial Swapping” covers the SSR half, covering both SPA and MPA.

The most famous partial swapping tool is htmx, which people tend to use in conjunction with “traditional” server-side rendered frameworks. Other libraries like Unpoly and Turbo work similarly. Some frameworks in other categories, such as Rails (with Turbo) and Deno Fresh, have adopted partial swapping as well.

As I’ve written before, people act as though this pattern is saving the web from SPAs. Once we widen our view like this, though, we can see that’s a false dichotomy. In fact, by making it easier for developers to replace finer-grained regions of the page, partial swapping is actually a tool for creating SPAs3 — albeit server-side rendered ones.

It’s not all or nothing! The htmx documentation outlines . I won’t make a chart with three of the four quadrants filled in, but you get the idea: these boundaries are fluid, and good tools don’t lock developers into a specific region.

Partial swapping can also be used as a polyfill for cross-document view transitions. Frameworks like Astro allow authors to load full pages asynchronously, progressively enhancing MPAs into server-side rendered SPAs.

Did We Learn Anything?

None of this is particularly groundbreaking. But I agree with Thomas that imprecise terminology doesn’t help whatever discourse plays out on the hot-take-fueled Internet argument fora. Hopefully, this can serve as a reference point when we talk about when and where these architectures are appropriate.