ASP.NET Core + Razor + HTMX + Chart.js
TLDR; You can build simple, easy to maintain, modern interactive web experiences using ASP.NET Core, Razor, and HTMX. Adding interactive (client side) charts is possible by leveraging HTMX's extensibility mechanisms to bridge the world of HTMX Hypermedia and Chart.js requirements for RESTful JSON APIs. You can leverage HATEOAS to provide stateful links to self, previous, and next pages of data, which can also be used to enable/disable pagination buttons. The full source code is available on GitHub
Technical Entropy
We have a number of internal Line of Business applications which we've built since we founded endjin in 2010. Like any software portfolio, they use a wide variety of technologies, architectural approaches, and hosting options, and are all suffering from technical entropy.
We've recently been modernising several of them; some we've converted into Data Apps either using Microsoft Fabric Pipelines to manage orchestration and Power BI to handle visualisation, or using Streamlit hosted in Azure Container Apps, if a more bespoke and interactive experience is needed.
Some have been converted into .NET CLI Apps (using Spectre.Console, Spectre.Console.Cli and Spectre.IO), wrapped in a container, along with any dependencies, and hosted in GitHub Actions to provide scheduling & orchestration. I love this approach, especially if you need to incorporate Linux toolchains such as Pandoc to do sophisticated document generation.
The above have been the low-hanging-fruit; the apps that have been relatively trivial to modernise. The bigger challenge has been the complex web applications, with rich interactions and visualisations which are powered by a deep software supply chain of (open source) components which have become increasingly difficult to maintain. The added complexity is that some of the applications target .NET Framework 4.x and have the OWIN architecture which predates the current ASP.NET Core middleware approach, and doesn't have a clear migration path. These web applications also have SPA experiences powered by ASP.NET HTTP endpoints. Those SPA frameworks are also vastly out of date, and have their own migration pathway challenges (I'm looking at you Angular!).
The approach we've tried to take in this modernisation effort can be summarised as:
- Make Future You Thank Past You
- Simplicity pays dividends
- Don't reinvent the wheel
- Maintain a shallow software supply chain
ASP.NET Core + HTMX + Azure Container Apps = ❤️
We've carried out a number of proof of concepts to understand how we can build a simpler, more maintainable, and more modern web application, and the architecture we've iterated towards is ASP.NET Core + Razor + HTMX, wrapped in a container, hosted in Azure Container Apps, and it seems to work well across the variety of scenarios we need to support. We've seen significant simplification of the codebase, and big performance improvements too. Win-Win.
This post could easily become a love letter to HTMX. But others have already written a few of those. All I'll add is that I started doing web development in the mid 1990s, and I thoroughly enjoyed it up until the era of the SPA, at which point all of the client side frameworks sucked the joy out of the simplicity, elegance and productivity I had experienced until that point. Rest, HAL, and OpenAPI then became a focus as I still wanted to contribute to those applications, but found myself avoiding client-side development as much as possible. HTMX has removed much of the complexity of using client-side frameworks, while still providing rich user experiences that everyone expects. It's made web development fun again.
Interactive Charts & Graphs
One particular challenge modernising our web apps has been how to handle pages with interactive charts and graphs. Many of the charting libraries either depend upon embedding a (JSON based) data island within the page, or providing a set of HTTP APIs to fetch the data. This works well with most client-side frameworks as they are all based on the notion that data and state are provided by such APIs. The patterns are so intertwined that many of the charting libraries have plugins to integrate into the most popular client-side frameworks, to make the experience seamless.
Before diving into the example, it's worth understanding the approach at a high level:
- We're using ASP.NET Core and Razor to generate the "initial state" of the page; the Chart.js graph and buttons. This is generated from the first
GET
request toIndex.cshtml
(served by the root path/
), which executes theOnGet()
handler. - When the user clicks the "Next" button, HTMX intercepts the event and sends an
XMLHttpRequest
to the server, which is handled by theOnGetUpdate
handler. Thehx-ext="update_chartjs"
extension which defined byhtmx.defineExtension
below, intercepts the response and updates the chart, and the "Next" and "Previous" buttons. - Now we're not at the start of the dataset, the "Previous" button is now enabled. Step 2 is repeated for every "Next" and "Previous" button click. When we get to the end of the dataset, the "Next" button is disabled.
Now in more detail; on the back-end we define some record
types as DTOs
// Use a record type to represent the data for a chart
public record ChartData(string ChartId,
string NextButtonId,
string PreviousButtonId,
string Title,
Uri Self,
Uri Previous,
Uri Next,
int[] Data);
public record DataPoint(DateOnly Date, int[] Data);
In the Razor Page we have a method that represents our data source:
private List<DataPoint> GetData()
{
List<DataPoint> data =
[
new DataPoint(new DateOnly(2023, 1, 31), [18, 12, 15, 1, 2, 4]),
new DataPoint(new DateOnly(2023, 2, 28), [0, 15, 9, 18, 1, 12]),
new DataPoint(new DateOnly(2023, 3, 31), [4, 12, 8, 10, 16, 1]),
new DataPoint(new DateOnly(2023, 4, 30), [6, 4, 2, 7, 0, 5]),
new DataPoint(new DateOnly(2023, 5, 31), [18, 3, 19, 10, 2, 7]),
new DataPoint(new DateOnly(2023, 6, 30), [0, 12, 14, 7, 6, 13]),
new DataPoint(new DateOnly(2023, 7, 31), [6, 15, 12, 8, 17, 13]),
new DataPoint(new DateOnly(2023, 8, 31), [13, 6, 1, 9, 17, 14]),
new DataPoint(new DateOnly(2023, 9, 30), [6, 15, 14, 4, 12, 5]),
new DataPoint(new DateOnly(2023, 10, 31), [6, 11, 13, 9, 16, 8]),
new DataPoint(new DateOnly(2023, 11, 30), [16, 19, 10, 9, 5, 17]),
new DataPoint(new DateOnly(2023, 12, 31), [10, 0, 11, 16, 18, 9])
];
return data;
}
And properties of the page mode which we reference in the Razor template, and we have defined an OnGet
handler which loads the default state of the page model:
public DateOnly CurrentPeriod { get; set; }
public string CurrentPeriodData { get; set; } = string.Empty;
public DateOnly PreviousPeriod { get; set; }
public DateOnly NextPeriod { get; set; }
public void OnGet()
{
DataPoint result = this.GetData().First();
this.PreviousPeriod = result.Date;
this.NextPeriod = result.Date.AddMonths(1);
this.CurrentPeriod = result.Date;
this.CurrentPeriodData = string.Join(",", result.Data);
}
The page model is then used to render the default state of the buttons in the Razor template:
<button hx-get="@Url.Page("", "Update", new { chartId = "myChart", nextButtonId="next", previousButtonId="previous", period = this.Model.PreviousPeriod.ToString("o") })"
hx-swap="none"
hx-ext="update_chartjs"
id="previous"
class="btn btn-primary"
disabled="disabled">
Previous
</button>
<button hx-get="@Url.Page("", "Update", new { chartId = "myChart", nextButtonId="next", previousButtonId="previous", period = this.Model.NextPeriod.ToString("o") })"
hx-swap="none"
hx-ext="update_chartjs"
id="next"
class="btn btn-primary">
Next
</button>
Note the use of HTMX attributes:
- hx-get - will cause an element to issue a GET to the specified URL and swap the HTML into the DOM using a swap strategy.
- hx-swap - allows you to specify how the response will be swapped in relative to the target of an AJAX request.
- hx-ext - enables an htmx extension for an element and all its children.
We use Razor to generate the buttons with the HTMX attributes. The URL generated contains the HATEOAS state which we can round trip between the back-end and front-end:
?chartId=myChart&nextButtonId=next&previousButtonId=previous&period=2023-02-28&handler=Update
This invokes OnGetUpdate
handler below, which returns a HATEOAS payload which will be intercepted and processed by the by htmx.defineExtension
(defined below):
public IActionResult OnGetUpdate(string chartId, string nextButtonId, string previousButtonId, DateOnly period)
{
DataPoint? result = this.GetData().FirstOrDefault(x => x.Date.Equals(period));
if (result == null)
{
return new NotFoundResult();
}
DateOnly previousDate = LastDayOfMonth(period.AddMonths(-1));
DateOnly nextDate = LastDayOfMonth(period.AddMonths(1));
if (this.GetData().FirstOrDefault(x => x.Date.Equals(previousDate)) == null)
{
previousDate = period;
}
if (this.GetData().FirstOrDefault(x => x.Date.Equals(nextDate)) == null)
{
nextDate = period;
}
Uri self = new($"/?chartId={chartId}&nextButtonId={nextButtonId}&previousButtonId={previousButtonId}&period={WebUtility.UrlEncode(period.ToString("o"))}&handler=Update", UriKind.Relative);
Uri previous = new($"/?chartId={chartId}&nextButtonId={nextButtonId}&previousButtonId={previousButtonId}&period={WebUtility.UrlEncode(previousDate.ToString("o"))}&handler=Update", UriKind.Relative);
Uri next = new($"/?chartId={chartId}&nextButtonId={nextButtonId}&previousButtonId={previousButtonId}&period={WebUtility.UrlEncode(nextDate.ToString("o"))}&handler=Update", UriKind.Relative);
// We pass the chart HTML element ID back to the client so it knows which chart to update
return new JsonResult(new ChartData(chartId, nextButtonId, previousButtonId, period.ToLongDateString(), self, previous, next, result.Data));
DateOnly LastDayOfMonth(DateOnly date)
{
return new DateOnly(date.Year, date.Month, 1).AddMonths(1).AddDays(-1);
}
}
This is the payload that is returned from the back-end when the buttons are clicked and is processed by htmx.defineExtension
transformResponse
method (defined below):
{
"chartId": "myChart",
"nextButtonId": "next",
"previousButtonId": "previous",
"title": "28 February 2023",
"self": "/?chartId=myChart\u0026nextButtonId=next\u0026previousButtonId=previous\u0026period=2023-02-28\u0026handler=Update",
"previous": "/?chartId=myChart\u0026nextButtonId=next\u0026previousButtonId=previous\u0026period=2023-01-31\u0026handler=Update",
"next": "/?chartId=myChart\u0026nextButtonId=next\u0026previousButtonId=previous\u0026period=2023-03-31\u0026handler=Update",
"data": [
0,
15,
9,
18,
1,
12
]
}
and the chart:
<canvas id="myChart"></canvas>
<script>
/*
We use Razor to inject the data from the Model into the JavaScript to set the initial state.
All future updates are handled by HTMX via the hx-get attributes on the buttons, which call
the OnGetUpdate page handler (manifested as &handler=Update in the query string) to get
the next page of data. Client side state (HTML elements) are passed to the server mimicking HATEOAS.
*/
document.addEventListener('DOMContentLoaded', (event) => {
const ctx = document.getElementById('myChart');
const myChart = new Chart(ctx, {
type: 'bar',
data: {
labels: ['Red', 'Blue', 'Yellow', 'Green', 'Purple', 'Orange'],
datasets: [{
label: '# of Votes',
data: [@this.Model.CurrentPeriodData],
borderWidth: 1
}]
},
options: {
scales: {
y: {
beginAtZero: true,
max: 20
}
},
plugins: {
title: {
display: true,
text: '@this.Model.CurrentPeriod.ToLongDateString()'
}
}
}
});
});
</script>
HTMX Extensibility FTW
Unfortunately, there is an impedance mismatch between HTMX, which has a Hypermedia driven approach (Richardson Maturity Model Level 3), and Chart.js which requires a more common HTTP JSON API (Richardson Maturity Model Level 0).
Fortunately, HTMX does have an extensibility mechanism which can be leveraged to bridge the two worlds. defineExtension
allows you to create an extension you can reference from the hx-ext
attribute on an element. This extension can then be used to intercept a HTTP response and transform it before it is inserted into the DOM.
We use this mechanism to locate the named chart in the DOM, extract the new chart data from the response, and update the chart object. Next we locate the previous and next buttons, and update their hx-get
attributes to point to the new "previous" and "next" pages of data. We also update the disabled
attribute on the buttons if we are on the first or last page of data.
Note that when we update an element, we must notify HTMX that the DOM has changed by calling htmx.process
.
Finally, we return an empty string to prevent HTMX from performing chained operations on a null - we don't actually need HTMX to process the result and mutate the DOM, we've handled all those changes ourselves (we don't have a mechanism to say "don't do anything", so we return an empty string instead, if we return a null we get an unhandled exception deep within the library).
htmx.defineExtension('update_chartjs', {
transformResponse: function (text, xhr, elt) {
var response = JSON.parse(text);
var chart = Chart.getChart(response.chartId);
chart.config.data.datasets[0].data = response.data;
chart.options.plugins.title.text = response.title;
chart.update();
// Find the previous button and update its hx-get attributes
var previous = htmx.find("#" + response.previousButtonId)
previous.setAttribute("hx-get", response.previous);
// We have to notify HTMX about the updates to the DOM
htmx.process(previous);
// Find the next button and update its hx-get attributes
var next = htmx.find("#" + response.nextButtonId)
next.setAttribute("hx-get", response.next);
// We have to notify HTMX about the updates to the DOM
htmx.process(next);
// Disable the previous and next buttons if we are on the first or last page
if (response.self == response.previous) {
previous.setAttribute("disabled", "disabled");
} else {
previous.removeAttribute("disabled");
}
if (response.self == response.next) {
next.setAttribute("disabled", "disabled");
} else {
next.removeAttribute("disabled");
}
// Return an empty string to prevent HTMX from performing string operations on a null.
return "";
}
});
You can see a recording of the architecture in action below:
The full source code of this proof of concept is available on GitHub.