Introduction
Single-page applications (SPA) provide better user experience by removing full-page loads, giving smooth transitions between pages and performance improvements depending on the scale of the application, but they come at some costs which may not be worth for some application types. The main cost is the added complexity of a SPA framework like Angular or Vue.
In this post, I'll show how to use ASP.NET and HTMX to build single-page applications without using JavaScript and SPA frameworks.
What is HTMX
HTMX is a library that allows us to access modern browser features by extending HTML, without needing to use Javascript directly. It gives us access to AJAX, CSS Transitions, WebSockets and more, including extensions.
It simplifies making responsive apps that give our users a better experience without the complexity of Javascript.
HTMX is really powerfull, but in this post I'll focus on the hx-boost
attribute.
Base application
I'll use an old Razor Pages Sample Application by Damian Edwards to show how easy it is to make an SPA with ASP.NET and HTMX.
I've made some changes in his sample, including upgrading from .NET Core 2.0 to .NET 7. These changes are in the branch sample-base
of the sample repository: AspNetCoreSPAHtmx.
💡 I'm using Razor pages but what I'll show is also aplicable to ASP.NET MVC.
Including the HTMX lib and menu links configurations
Let's open the _Layout.cshtml
and add the reference to HTMX at the end of the <body>
tag:
...
<script src="https://unpkg.com/htmx.org@1.9.6"></script>
</body>
HTMX has an attribute hx-boost
that enable us to transform our Multi-Page Application in a Single Page Application (SPA) with just some tweaks, making <a>
and <form>
tags use AJAX requests instead of page reloads.
It works on all the children of the element it is applied to, so let's include it in the <nav>
tag so all the menu links use AJAX:
<nav class="navbar navbar-inverse navbar-fixed-top" hx-boost="true" hx-target="#main-content">
By default, hx-boost
applies the AJAX response to the <body>
tag, so we need to specify the id of target (in this sample, #main-content
) in the hx-target
attribute.
Now, let's create a div
with id main-content
to receive the response content inside the body-content
div:
<div class="container body-content">
<div id="main-content">
@RenderBody()
</div>
<hr />
<footer>
<p>Full-page load at @DateTime.Now</p>
<p>© 2017 - Razor Pages</p>
</footer>
</div>
ℹ️ Note that the time of the full-page load is in the footer, so we can check that the requests are done with AJAX.
Removing the layout for HTMX requests
All requests done with HTMX will have a Hx-Request
header set to true
.
We will check for it in the _ViewStart.cshtml
file and remove the page layout for HTMX requests:
@{
@if (!ViewContext.HttpContext.Request.Headers["Hx-Request"].Contains("true"))
{
Layout = "_Layout";
}
else
{
Layout = "";
}
}
Setting up the forms
Now, we also need to include the hx-boost
and hx-target
attributes in the <form>
tags in the Index.cshtml
, New.cshtml
and Edit.cshtml
files:
<form method="post" class="form-horizontal" hx-boost="true" hx-target="#main-content">
Now we can test the application. Note that the time of the page load in the footer doesn't change:
Looking at the browser's console, we can see the request was done:
The form content in the request payload:
The response without the _Layout.cshtml
content:
The response rendered in the page:
💡 ASP.NET Form Validation will also work:
Changing the page title according to the page load
Until now, the page title is not being changed, because it is set in the _Layout.cshtml
file, that is loaded just on the first page load:
<title>@ViewData["Title"] - Razor Pages Sample + HTMX</title>
The hx-boost
attribute will automatically change the page title if the response returns a <title>
tag. To do this, let's create a new layout for the HTMX requests.
Create a _LayoutHtmxBoost.cshtml
with the below:
@if (!string.IsNullOrEmpty(ViewData["Title"]!.ToString()))
{
<head>
<title>@ViewData["Title"] - Razor Pages Sample + HTMX</title>
</head>
}
@RenderBody()
@RenderSection("Scripts", required: false)
Note that we return a <title>
tag if the ViewData["Title"]
has any value.
Now, let's change the _ViewStart.cshtml
file to use the _LayoutHtmxBoost
layout for HTMX requests:
@{
@if (!ViewContext.HttpContext.Request.Headers["Hx-Request"].Contains("true"))
{
Layout = "_Layout";
}
else if (ViewContext.HttpContext.Request.Headers["Hx-Boosted"].Contains("true"))
{
Layout = "_LayoutHtmxBoost";
}
else
{
Layout = "";
}
}
⚠️ We are checking for the
Hx-Boosted
header non-boosted HTMX requests don't use this layout.💡 HTMX automatically pushes the pages to the browser's history:
Indicating the loading status
That's cool, but we don't have an indicator of the request being processed. For this, we can use the hx-indicator
attribute, specifying the id of the element to show while awaiting for the request.
The hx-indicator
works by adding the htmx-request
css class to the element specified, that will set the opacity
to 1
. For this to work, we first need to hide the element with an opacity
of 0
, or use the htmx-indicator
css class that does this with a css transaction.
First, I created a partial page with a loader from Pure CSS Loaders with the name _LoadSpinner
, and set the id
to spinner
and added the htmx-indicator
css class:
<div id="spinner" class="htmx-indicator">
<div class="lds-ring"><div></div><div></div><div></div><div></div></div>
</div>
Then, I added it in the menu:
<div class="collapse navbar-collapse" id="header-navbar-collapse">
<ul class="nav navbar-nav">
<li><a asp-page="/Index">Home</a></li>
<li><a asp-page="/Customers/Index">Customers</a></li>
<li><partial name="_LoadSpinner" /></li>
</ul>
</div>
Lastly, I add the hx-indicator
attribute with the value #spinner
to the <nav>
element:
<nav class="navbar navbar-inverse navbar-fixed-top" hx-boost="true" hx-target="#main-content" hx-indicator="#spinner">
and all <form>
elements:
<form method="post" class="form-horizontal" hx-boost="true" hx-target="#main-content" hx-indicator="#spinner">
💡 To make it easier to see the indicator, I included a delay of 500 milliseconds to all pages GET/POST methods:
public async Task<IActionResult> OnGetAsync(int id) { await Task.Delay(TimeSpan.FromMilliseconds(500)); ... }
Running the app again, we can see the indicator showing in every actions:
Full source code
References and Links
Related
Liked this post?
I post extra content in my personal blog.