Umbracians, as far as I can tell, are mostly shielded from the online noise surrounding server-side and client-side rendering/environments. Many of us prefer to render our pages server-side, passing nicely typed view model objects into Razor templates, rarely considering much else. While we seem to put more energy into helping each other out and building our own special Umbraco ecosystem, the greater web development landscape has been convulsing through an identity crisis. This drama and sometimes cognitive dissonance-inducing chaos are partially fueled by a mix of a new class of YouTube-promoted software developer influencers, as well as promises of easy six-figure salaries from coding bootcamps.
At first glance, it appears that ReactJS and NextJS are the cause of this strife, but if you zoom out, this is really a battle between client-side and server-side rendering. And if you really zoom out, you’ll notice that it’s more of a question of whether the web platform should be treated as a distributed application platform, something that competes with the native environments, or if the web should be preserved as the hypermedia system it was conceived as.
Many have lamented the popularity of the web application platform direction, but perhaps the more comprehensive, well-thought, and contrarian writing comes from Carson Gross in his book, Hypermedia Systems.
If you’re unfamiliar with his short and easily digestible ode to hypermedia, it’s part reintroduction to hypermedia, and part tour of how to use Carson’s HTMX library. Hypermedia Systems is thorough and well-written, and the reintroduction section does a great job at rewiring your brain from thinking “this is the old (jQuery) way to do this” to embracing a refreshed approach to creating dynamic and interactive websites.
For the first time, I had seen vocabulary to describe what I was observing elsewhere, and I became completely captivated and started figuring out how I could layer HTMX on top of Umbraco.
You should absolutely read his book (it’s free), but in this article I’ll cover a few highlights to pique your interest, and then demonstrate how we too, Umbracians, can enhance our websites using a hypermedia approach.
Defining a Hypermedia System
What is a hypermedia system? It’s a networked architecture that contains digital media (text, images, videos), which are wrapped in structure (HTML tags), and the content contains hyperlinks to other content (in our case, these are simple a=href’s). All three elements are returned together in a single response from the remote host.
This soup of content, markup and navigation is returned to a user agent (for example, but not limited to!) a web browser, which is then presented to the end user. The user agent is responsible for a single thing - presenting the content in its interface, however it pleases. In a hypermedia system, the user agent does not store or maintain state; instead, that responsibility is largely ejected out to the remote host, and no two responses are guaranteed to return the same content or outward navigation.
It all seemed so exciting - hyperlinks! - and it was in the early 1990s. By the mid-1990s, forms were added to the HTML spec with version 2.0, and additional verbs were added to the HTTP protocol, resulting in HTTP 1.1. This prompted Marc Andreessen to boldly proclaim Windows as a “collection of badly debugged device drivers.”
“Netscape will soon reduce Windows to a poorly debugged set of device drivers.” - Marc Andreessen, 1995
Later, Roy Fielding brilliantly distilled his understanding of this architecture, describing what we know today as a REST API. That is, receiving partial responses of HTML as valid behavior.
Yes, you read that right - a single REST API response should return fragments of content, structure, and navigation. And herein lies one of Carson’s often repeated gripes - a JSON API is not a REST API, it’s a data API. Carson argues that the field of web development has been corrupted by the latter definition, a complete rewrite (or misunderstanding) of Roy Fielding’s definition! Outrageous!
The tragic result of this misunderstanding? We’ve shoehorned the older (yes, older) thick-client application architecture (you’ll recognize these in a contemporary fashion as a SPA or PWA) onto the most successful hypermedia system of all human existence.
Google deserves a good portion of the blame for this sacrilege. While succeeding with Android as an alternative to iOS, they failed to create a culturally relevant alternative to iOS. They struck out on a mission to create a modern competing platform — the progressive web application, or PWA. PWAs seek to essentially recreate the functionality of a native mobile app environment inside of the browser, including features to make web applications available offline and access hardware (camera, sensors).
Idealist developers who thought that Apple's "walled garden" approach to software distribution was misguided (if not harmful), and web developers who had little interest in learning a native SDK, embraced PWAs.
Today, we observe the web heading toward a far cry from what it was originally envisioned as. In the process, we’re erasing the best attributes of that very hypermedia system, and it would appear that many developers don’t know the difference or care to understand.
To put it another way, the most successful hypermedia system of all time has been reduced to a facsimile of Microsoft’s “Click-Once” deployment technique. How the tables turn.
It Doesn’t Have to be this Way
In my professional experience, the explosion of ReactJS and later, NextJS, has resulted in bloated web applications, an introduction of hard problems into what was once a very simple and accessible platform, duplicated logic between client and server, and often requiring reimplementing facilities of what established back-end frameworks (Django, Rails) have long shipped with (pagination, form validation).
We’re writing all sorts of front-end code that we simply do not need to write, except to appease budding junior developers who teethed on NextJS.
Web projects take longer than they should, and have classes of issues that are tedious to address when using things like React, issues that are often handled in the most bog-standard of ways by established server-side frameworks.
And hey, ReactJS is an easy, winning career technology stack to put yourself in. Even as a self-proclaimed “hater,” I begrudgingly shipped React/NextJS code for years at Vimeo for a nice paycheck.
The Tidelas Project
I created my premier website, Tidelas, first using Django and VueJS, then later Django and HTMX, before lifting the entire site and porting it to live on top of Umbraco with HTMX.
The Django and HTMX combination was glorious. I was able to implement so many wonderful features very quickly and easily, and felt like I was running circles around myself and my team at Vimeo.
I later realized that I did not like the editing experience of Django admin for such a content-heavy website, so I ported the entire project to Umbraco, and I brought HTMX along for the ride.
Umbraco + HTMX
So what exactly makes Umbraco jive so well with HTMX?
In short - Render Controllers and Surface Controllers.
Umbraco is one of the most flexible and extensible CMSes I have ever had the privilege of developing with. It’s obvious from the first time you open the back office how well-constructed this platform is. It’s only gotten more so over the years (I teethed on the legendary version 7).
Render Controllers aren’t exactly an obvious choice to include. They’re typically used as a way to provide richer (or custom) data models to templates; they act as an intermediary between your DocType and your Razor view file. Render Controller subclasses are an insertion point where you can enhance the default IPublishedContent model with an amalgamation of rich data from other sources. This can allow developers to reduce code duplication, in our case, particularly by making use of partial views.
Surface Controllers are an extremely obvious choice to include and are very well suited to rendering and returning partial views. Additionally, they’re easily routed, support all HTTP verbs, and come with easy access to Umbraco’s services layer.
So how do we combine these two controller types together to create some dynamic interactivity?
Let's look at a few examples!
Filterable Grid
Suppose we'd like to make a grid of data that's filterable. We'll need: a list of data, a list of filters, and, because we're not storing state anywhere on the client, a way to track the filter selection on the server.

As a data model, this might look something like:
// /Models/FilterableDestinationViewModel.cs
public class FilterableDestinationViewModel
{
public string SelectedState { get; init; } = "All";
public IEnumerable<IPublishedContent>? Destinations { get; init; }
public IEnumerable<string?>? StateList { get; init; }
}
This data model will be tightly-coupled to a razor view file. All of the data.
And for our partial view:
// /Views/Partials/DestinationGrid.cshtml
@model Project.Models.FilterableDestinationViewModel
<div id="mainGrid">
<div id="selectBar">
<ul>
@if (Model.SelectedState.Equals("All"))
{
<li class="cursor-pointer inline-block rounded-2xl border border-solid border-orangeMid text-white bg-orangeMid px-2 py-1 mb-2">
<a href="/xhr/location-guide/filter-destinations?state=All" hx-target="#mainGrid" hx-boost="true" hx-push-url="false">All</a>
</li>
}
else
{
<li class="cursor-pointer inline-block rounded-2xl border border-solid border-orangeMid text-white px-2 py-1 mb-2">
<a href="/xhr/location-guide/filter-destinations?state=All" hx-target="#mainGrid" hx-boost="true" hx-push-url="false">All</a>
</li>
}
@foreach (var state in Model.StateList)
{
if (state.Equals(Model.SelectedState))
{
<li class="cursor-pointer inline-block rounded-2xl border border-solid border-orangeMid text-white bg-orangeMid px-2 py-1 mb-2">
<a href="/xhr/location-guide/filter-destinations?state=@state" hx-target="#mainGrid" hx-boost="true" hx-push-url="false">@state</a>
</li>
}
else
{
<li class="cursor-pointer inline-block rounded-2xl border border-solid border-orangeMid text-white px-2 py-1 mb-2">
<a href="/xhr/location-guide/filter-destinations?state=@state" hx-target="#mainGrid" hx-boost="true" hx-push-url="false">@state</a>
</li>
}
}
</ul>
</div>
</div>
<div id="gridListing">
<div class="grid grid-cols-4 w-full gap-2">
@foreach (var destination in Model.Destinations)
{
if (destination.Children != null)
{
<div class="shadow-md rounded-md py-3 px-4">
<div>
<a href="@destination.Url()" class="hover:underline">
<h3 class="font-semibold cursor-pointer">@destination.Value("city")</h3>
<p class="text-xs font-medium text-blueDark mb-2">@destination.Value("state")</p>
</a>
</div>
</div>
}
}
</div>
</div>
Let's step through a few interesting points in this razor view:
First, we've got a wrapper div with the id #mainGrid.
Next, we've got an unordered list of states. Each of the list items contains an anchor tag, and the anchor tags have some funny-looking, non-standard attributes specified on them.
hx-target="#mainGrid"
The hx-target attribute tells HTMX to target the DOM node with id mainGrid as its insertion point. When the HTMX request completes, it'll shove the markup coming back from the server into this div.
hx-boost="true"
hx-boost is a very simple (and lazy) way to apply a break to the default navigation function of these anchor tags and fire off a request to the server, without doing a full page reload.
In this case, we'll be creating an HTTP GET request to the URL specified in the href attribute, with an additional query string parameter, state.
/xhr/location-guide/filter-destinations?state=Florida
This could also be achieved using hx-get on any other element - a div, a button, an image, you name it! In HTMX, any DOM node can fire off an HTTP request, not just anchor tags and forms.
hx-push-url="false"
hx-push-url controls whether the browser's address field will update its location. It defaults to true, but here I've set it to false.
Let's talk about controllers. We've set this filterable data grid up to be a reusable partial view. This means that we can initialize FilterableDestinationViewModel in a Render Controller, and then initialize it again and again in a Surface Controller. Let's have a look at how to do both.
Render Controller
// /Controllers/LocationGuideController.cs
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ViewEngines;
using Project.Models;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Web;
using Umbraco.Cms.Web.Common.Controllers;
namespace Project.Controllers;
public class LocationGuideController(
ILogger<RenderController> logger,
ICompositeViewEngine compositeViewEngine,
IUmbracoContextAccessor umbracoContextAccessor,
IPublishedValueFallback publishedValueFallback,
IMemberManager memberManager,
IConfiguration configuration)
: RenderController(
logger,
compositeViewEngine,
umbracoContextAccessor
)
{
private var states = new List<string> { "Florida", "Ohio", "Connecticut" };
[NonAction]
public sealed override IActionResult Index() => throw new NotImplementedException();
public async Task<IActionResult> Index(CancellationToken cancellationToken)
{
var destinations = umbracoHelper
.ContentAtRoot()
.First()
.Children()
.First(x => x.ContentType.Alias == "locationGuidePage")
.Children();
var partialViewModel = new FilterableDestinationViewModel
{
StateList = states,
Destinations = destinations
};
LocationGuidePageViewModel viewModel = new(CurrentPage!, publishedValueFallback)
{
FilterableGrid = partialViewModel
};
return CurrentTemplate(viewModel);
}
}
The RenderController implementation is pretty standard and easy to follow. You'll also need a PublishContentWrapped view model to pair with.
// /Models/LocationGuidePageViewModel.cs
using Umbraco.Cms.Core.Models.PublishedContent;
namespace Project.Models;
public class LocationGuidePageViewModel: PublishedContentWrapped
{
public LocationGuidePageViewModel(IPublishedContent content, IPublishedValueFallback publishedValueFallback)
: base(content, publishedValueFallback)
{
}
public FilterableDestinationViewModel? FilterableGrid { get; set; }
}
Surface Controller
// /Controllers/HypermediaControllers/LocationGuideHypermediaController.cs
using Microsoft.AspNetCore.Mvc;
using Project.Models;
using Project.Models;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Web.Common.Filters;
using Umbraco.Cms.Web.Common.Security;
namespace Project.Controllers.HypermediaApis;
[Route("xhr/location-guide")]
public class LocationGuideHypermediaController(
IPublishedContentQuery publishedContentQuery,
MemberManager memberManager,
ILocationGuideDataServices locationGuideDataServices
): Controller
{
[HttpGet("filter-destinations")]
public PartialViewResult FilterDestinationsByState([FromQuery] string state)
{
var homepage = publishedContentQuery.ContentAtRoot().FirstOrDefault(x => x.ContentType.Alias.Equals("homepage"));
var locationGuide = homepage.Children.Where(c => c.Name.Equals("Location Guide"));
var destinations = locationGuide.FirstOrDefault().Children;
var stateList = new List { "Florida", "Ohio", "Connecticut" };
if (string.IsNullOrEmpty(state) || !state.Equals("All"))
{
destinations = destinations.Where(d => d.Value("state").Equals(state));
}
var viewModel = new CityListViewModel
{
Destinations = destinations,
StateList = stateList,
SelectedState = state
};
var content = PartialView("~/Views/Partials/DestinationGrid.cshtml", viewModel);
content.ContentType = "text/html";
return content;
}
}
Let's step through this Surface Controller -
- We're specifying a base route for the controller of /xhr/location-guide
- We're specifying that this endpoint will respond only to HTTP GET requests and be accessible at /xhr/location-guide/filter-destinations
- We'll accept a single query parameter, state
- We'll get ahold of all of the destinations in our location guide, and if there's a state parameter being passed in, we'll use a LINQ query to reduce that dataset down.
- We'll stuff everything in our reusable view model class, and render that into our reusable template
When the response returns from FilterDestinationsByState(), HTMX will refresh the contents of the #mainGrid div.
On SEO
You'll notice in the surface controller's route, I prefixed it with /xhr. My projects often contain more than one surface controller acting as a hypermedia API, and I'll tend to add that same prefix to all of them.
- xhr/location-guide
- xhr/riding-events
- xhr/user-profile
- ...
This is very intentional. It makes it cleaner to exclude these endpoints in your robots.txt file with a single rule.
Disallow: /xhr/
What I've found is that search engines try to index links pointing at these hypermedia controllers. This can have implications on how it parses and ranks our websites - particularly with search crawl budget.
Google may only want to spend a very small slice of compute time crawling and processing our website, so you'll want to restrict it to crawling full pages of content, instead of partial pages of content.
In the case of our location guide grid, it doesn't do us any good to have the content of each state indexed individually anyway, since all destinations are discoverable under the "All" mode.
Form Submissions
You can also attach the hx-post attribute to form elements, which will POST their contents into, say... an endpoint on a surface controller!
// /Controllers/HypermediaControllers/LocationGuideHypermediaController.cs
using Microsoft.AspNetCore.Mvc;
using Project.Models;
using Project.Models;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Web.Common.Filters;
using Umbraco.Cms.Web.Common.Security;
namespace Project.Controllers.HypermediaApis;
[Route("xhr/location-guide")]
public class LocationGuideHypermediaController(
IPublishedContentQuery publishedContentQuery,
MemberManager memberManager,
ILocationGuideDataServices locationGuideDataServices
): Controller
{
[HttpPost("submit-destination")]
public PartialViewResult SubmitDestination([FromForm] SubmitDestinationFormModel formData)
{
// process the form data
var formSuccess = form.was.saved;
var content = PartialView("~/Views/Partials/SubmitDestinationForm.cshtml", formSuccess);
content.ContentType = "text/html";
return content;
}
}
First, we'll decorate this with [HttpPost()] and give it a unique URL path.
Next, we'll use the [FromForm] decorator to tell the MVC framework to capture the post values and parse them into the SubmitDestinationFormModel object.
I'll leave it up to your imagination on how to process the form. This could write data into the Umbraco content tree, create a record in a form hosted by Umbraco Forms, and write data into a custom database table.
Finally, we'll return a partial view - a new blank form with a message letting the user know that their submission succeeded (or didn't!).
// /Views/Partials/DestinationGrid.cshtml
@model Project.Models.SubmitDestinationFormViewModel
<div id="submitDestinationFormWrapper">
@if (Model.Saved)
{
<p>Your destination was submitted!</p>
}
<form hx-post="/xhr/location-guide/submit-destination" hx-target="#submitDestinationFormWrapper">
@Html.TextBoxFor(m => m.DestinationName)
@Html.TextBoxFor(m => m.City)
@Html.TextBoxFor(m => m.State)
<button type="submit">Save</button>
</form>
</div>
A Note on Handling CSRF Requests
ASP.NET is going to require a valid CSRF token for form requests. What's the easiest way to provide that to HTMX?
With an HTML extension method, of course!
// master.cshtml
@using MyProject.Utils
...
@{
var antiForgeryToken = Html.AntiForgeryTokenValue().ToString();
}
...
<body hx-headers='{"RequestVerificationToken": "@antiForgeryToken"}'>
In your base/master template file, you'll fetch a new CSRF token, then provide that in an object passed to the hx-headers directive. I usually place this on the body, but it could be placed on any element that wraps the form.
You can extract out the token creation code into a HtmlHelper method, which makes this feel even more effortless and will keep your razor view file a bit cleaner.
If you've never done this before, it's really simple!
Create a class called HtmlHelpers in a directory called "Utils," and copy-paste in the AntiForgeryTokenValue() method from the code sample below. This class and method will be auto-discovered when the application starts, and available for usage in your view files.
using HtmlAgilityPack;
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Mvc.Rendering;
namespace MyProject.Utils;
public static class HtmlHelpers
{
public static IHtmlContent AntiForgeryTokenValue(this IHtmlHelper helper)
{
var antiforgery = helper.ViewContext.HttpContext.RequestServices.GetService<IAntiforgery>();
var tokens = antiforgery.GetAndStoreTokens(helper.ViewContext.HttpContext);
var tokenValue = tokens.RequestToken;
return new HtmlString(tokenValue);
}
...
}
Other HTMX Attributes
HTMX goes far beyond the GET/POST methods you're used to. You can also attach:
- hx-delete
- hx-patch
- hx-put
to any DOM element.
For example:
<div hx-delete="/johnny-drop-tables/" hx-trigger=”click[shiftKey]”></div>
Cleaning Up
So... what's the takeaway?
On the downside, we've added a bunch of partials into the equation - things we wouldn't particularly need to do if we weren't using HTMX. (For completeness, you can actually use HTMX to fetch an entire page instead of a partial, and it'll smartly update the changed areas of the DOM, without page flicker.)
On the upside, we've made our website a tad bit more slick and dynamic, the data transfer between browser and server is about as efficient as it can be, and we have the flexibility to change the partial response of the hypermedia controller without needing to adjust any data marshaling code (server-side or client-side). The lack of a full-page navigation means that we can create richer website experiences, including animations, just like the JavaScript frameworks afford. And we've done all of this by including a single library, no build steps required.
I personally love every bit of the upside. I love being able to avoid Node and TypeScript, and I have really grown to appreciate how HTMX has allowed me to save client budget for higher impact tasks.
This is just a starting point - the possibilities with HTMX, especially when mixed into an Umbraco project are endless.