Zero Magic

by Patrik Duditš

Unpoly: Achieving a Single-Page Experience with Server-Side Rendering

26 Feb 2026
Unpoly: Achieving a Single-Page Experience with Server-Side Rendering
photo by Noah Wilde

Unpoly Series

In attempt to adapt conference talk into text I realized it is too big for a single article, so expect more Unpoly content shortly

If you prefer watching over reading, catch the live version of this content from my talk at Devoxx Belgium 2024

I started with web development a long time ago. I remember AJAX arriving and how we suddenly didn't need to wait for the full page redraw. And that redraw used to take a long time, because computers were slow as well as the network.

Since then we've got heaps of frameworks and libraries to give us ultimate user experience with JavaScript frontends. We have seamless navigation, transitions, components with complex state, caches for offline operation and much more.

But the browsers have improved in the meantime, plain HTML and CSS are now simple and powerful enough to write without heaps of helper elements and libraries.

And almost all of that client-side interaction is also possible with hardly any JavaScript. Unpoly -- the progressive enhancement library for HTML -- enables these with few extra attributes on elements and some HTTP headers. This article will only speak about the elements, we save the communication with server for later.

Let's start by defining our core concepts. Originally a Single Page Application (SPA) didn't mean a blank HTML page with single <app> element requiring 4MB of JavaScript to get loaded. Instead, we define a SPA any web interface where user interactions, like clicking a link or a button, update the page dynamically via JavaScript rather than triggering a full page reload.

On the other hand, Server-Side Rendering (SSR) is an approach where the server makes all the decisions about the HTML that gets sent to the browser. This means business logic and rendering happen on the back end. A great example of this architecture is Ruby on Rails.

So, how can we get the fast, fluid user experience of an SPA without abandoning the simplicity and power of an SSR architecture? This is where Unpoly comes in. It's a JavaScript library that lets you progressively enhance your server-rendered HTML to create an SPA-like feel for your users. If you're currently building API endpoints for JavaScript frontend, Unpoly lets you skip that and return HTML directly.

What is Unpoly and how it differs from similar tools?

Unpoly comes from the Ruby on Rails community and is designed to progressively enhance the HTML you send from the server. Its core purpose is to give you those partial view updates without forcing you to build a separate front-end application. You achieve the functionality mostly with extension attributes on elements prefixed with up-.

If you've heard of HTMX, the concept might sound familiar, it uses hx- attributes. The key difference is that Unpoly builds on different contract between browser and backend. Dwhich is more optimized for building applications and maintaining state on the server. It's designed around the idea that the server will always send back a full HTML document, from which Unpoly then intelligently extracts the necessary fragments to update the page. And that really is the fundamental difference from HTMX or DataStar : The endpoints these two libraries use for fragment updates expect server to deliver just the single element that should be updated. Endpoints built for them cannot be reused as standalone page, while Unpoly endpoints deliver full pages and can be used for both purposes. This naturally gives you URLs that you can just reload and land at page with similar functionality, with HTMX and Datastar you need to handle browser navigation such as back buttons separately.

Unpoly builds on regular links and form submissions and the fallback behavior is to just have browser handle the interaction as prescribed by HTML standard.

While doing the replacement Unpoly automatically handles several additional key features crucial for an application, such has browser history management and caching. It is also possible to go beyond just links and forms, because every action Unpoly makes can be observed as custom event on client side. Also every replacement gives an opportunity for browser to either modify the elements or add event listeners on them.

The Demo Application

To demonstrate Unpoly's features, I've built a demo application. This application is very similar to what we built at Payara for management interface of our Cloud runtime.

The full source code is available on GitHub at pdudits/jakarta-mvc-unpoly if you want to follow along.

The demo is a Payara Micro application built with a few key technologies:

  • Jakarta MVC : A specification that allows you to return views from your Jakarta REST endpoints.

  • jte : A small and efficient template engine that compiles templates into Java code.

  • Pico.css : A CSS library that styles semantic HTML elements with minimal need for custom classes.

The Core Mechanism

Let's look at how Unpoly works on the most simple example possible: Imagine, that user wants to navigate from the first page to the second.

In a standard world of HTML, you would have a simple link: <a href="/other-page">Go</a>. The browser would make the network request, parse HTML, and render new page.

With Unpoly, you enhance this link with an up- prefixed attribute. The attribute up-follow tells Unpoly to take over the navigation and replace a fragment of page:

<a href="/other-page" up-follow>Go</a>

But which part of the page should be updated? You specify this with the attribute up-target, which takes a CSS selector. In this case, we want to replace the contents of the <main> element:

<a href="/other-page" up-follow up-target="main">Go</a>

When a user clicks this link, Unpoly intercepts the action and does the following:

  1. It makes a fetch request to the link's href.

  2. The server responds with the full HTML for the new page.

  3. Unpoly parses the returned HTML.

  4. It finds the element matching the target selector (main).

  5. It replaces the content of the current page's main element with the new content.

  6. Updates location history to match location.

This entire process happens without a full page reload, making the transition feel instant.

Forms behave in similar manner but use attribute up-submit instead of up-follow. The up-target attribute works the same for both.

It is not necessary to specify up-target every time. By adding the up-main attribute to our primary content area (e.g., <main up-main>), any link with up-follow will automatically target this element for replacement. The link just becomes <a href="..." up-follow>. Even more implicit target can be declared by setting Unpoly's configuration object, but that wouldn't be instructive enough for our purpose.

Designing for Unpoly

When you start building an application with Unpoly, it's a good exercise to think about how it would work with JavaScript disabled. Once my colleague asked me: "I'm about to start doing UI for this feature and I don't know how to start and make it work with Unpoly, give me some wizard knowledge!"

To which I said:

The best way to build Unpoly UI is not to build Unpoly UI

Really the best way to start is to design the interaction with just forms and links. This will force you to think what the state of your user interface is and how to preserve it. Your state will live primarily in the URL, query and form parameters. Long running state, such as preferences may end up in cookies. Then it's easy to add dynamic elements later in a way that aligns well with how Unpoly is designed.

Fragment replacement in motion

When we turn JavaScript off in our demo navigating between pages results in classic full-page reloads. The "Comments" and "Add Comment" sections are entirely separate pages.

Navigation with Javascript disabled

When enabled, we now observe fragments and forms changing in-page and even transitioning thanks to up-follow and up-submit:

Navigation with unpoly's up-follow and up-submit

Notice how pages are seamlessly updated, while the address bar still shows the expected URL.

Lazy Loading with up-defer

Sometimes you don't need to load all content immediately. Unpoly enables that with the up-defer attribute.

The mechanism works like this: a placeholder element with up-defer triggers a request to its href (or up-href if it's not a link) either immediately after loading the page, or when the element becomes visible.

<div id="chart-container" up-defer up-href="/app/$app/charts" up-revalidate="true">
  <div class="chart-box" >
    <div aria-busy="true"></div>
    <div aria-busy="true"></div>
    <div aria-busy="true"></div>
  </div>
  </div>

These charts are an example of UI element that is not crucial for usability, especially when retrieving the data for it takes time – our endpoint artificially delays the response by 5 seconds. The initial response from server therefore serves code for a spinning placeholder and up-defer fires up the request as soon as elements are loaded on the page.

<details>
  <summary>Deployed Revision 4</summary>
  <section>
    <a href="/comment/appevent/2/" up-defer="reveal" up-target="section">Comments</a>
  </section>
</details>

Setting up-defer="reveal" triggers the second behavior, waiting for the element to become visible before requesting data from server. Observe the requests in developer console in video below:

On-demand loading with up-defer

Advanced Targeting

Observe how in the last code example we target section, rather than specific ID or the entire hierarchy from body or main. That's because Unpoly has a convenient default. It starts search for target element for replacement at the element that triggered the request and traverses up the ancestor hierarchy until it finds matching element. This makes it easy to replace an element without needing unique IDs for everything. So in that snippet we start searching from <a> that triggered the replacement. Element <section> is its parent that matches the selector and that will become the target element for replacement. We'll replace the contents of the single <section> element from response. While the source document may have multiple matching elements for up-target, the selector must identify single element in the response.

A common challenge is when a single user action needs to update multiple, independent areas of the page. For example, submitting a comment should both add the new comment to a list and reset the submission form.

We are not limited to single replacement per server roundtrip in up-target. Instead we can provide a comma-separated list of targets. This allows us to define replacement for multiple elements or by using special modifiers to also prepend or append to existing elements:

<form id="add-comment" up-target=".comment-list:after, #add-comment">
  <!-- ... -->
</form>

This single instruction tells Unpoly to perform two distinct updates from one server response:

  1. Append the new comment to the .comment-list. :after is what tells Unpoly to append instead of replace.

  2. Replace the element with ID add-comment, the form itself (perhaps with a fresh, empty form).

This declarative approach allows for complex page updates without writing any custom JavaScript to orchestrate them.

Always fresh elements

Your application might include common messages area. It will be very annoying if you had specify the target for your notification area on every request. Thankfully there's is straight forward way, we call those element hungry.

<div id="notifications" up-hungry>
<!-- I get updated on every request regardless of up-target value --->
</div>

These elements need to have an ID or at least unique class to function properly.

Stay Tuned

In the next part of the series we'll discuss more about the protocol between Unpoly and server, how it can be used to support complex forms and other interactions and how Unpoly fares with client-side scripting.

Share on Mastodon

e.g., mastodon.social or https://mastodon.social