htmx 🤝 dotnet
Exploring htmx with Razor Pages: An Old New Web Development Approach?
In the realm of web development, it's crucial to stay updated with the latest tools and methodologies. One such tool creating a buzz is htmx. I am usually not swayed by the 'shiny object syndrome', but I worked a lot with Razor Pages recently, and touched a lot of jQuery, which started to annoy me a bit.
However, after watching a video from @t3dotgg I was convinced that htmx might be helpful here and that it could fit me nicely. So, I decided to go through some use cases for it and do some tests to check if it is any good.
1. Introduction
First, let me briefly introduce what htmx and Razor Pages are:
htmx is a js library that gives access to AJAX, CSS Transitions, WebSockets, and Server Sent Events directly in HTML, using attributes. The library is built around "Hypermedia-Driven Application Architecture," which, in simple terms, could be described as "return HTML instead of JSON."
Razor Pages extends ASP.NET Core MVC, making coding page-focused scenarios easier and more productive than controllers and views. Razor Pages group the action (called a handler) and the viewmodel (called a PageModel) in one class and link it to a view. It uses a routing convention based on location and name in a Pages folder. This tends to keep Razor Pages and its handlers smaller and more focused while at the same time making it easier to find and work with.
I also want to mention pico.css. This is a minimal CSS framework for semantic HTML. It focuses on using simple native HTML tags as much as possible and provides consistent adaptive spacings and typography on all devices.
2. Background
Before I dive into integrating htmx and Razor Pages, let me briefly describe each technology. Both are praised for simplifying web development, but what are they?
2.1. htmx
htmx started as an intercooler.js
- a small helper library to make AJAX calls through HTML tag attributes.
<!-- This anchor tag posts to '/click' when it is clicked -->
<a ic-post-to="/click">
Click Me!
</a>
Asynchronous JavaScript And XML (AJAX)
Around three years ago, intercooler 2.0 (renamed to htmx 1.0) has been released. It was smaller, more expressive, and no longer depended on jQuery.
<!-- have a button POST a click via AJAX -->
<button hx-post="/clicked" hx-swap="outerHTML">
Click Me
</button>
htmx intend to help web developers to create dynamic, interactive web content without the overhead of complex JavaScript frameworks.
Leveraging HTML attributes allows seamless partial page updates, delivering the power of AJAX, CSS Transitions, WebSockets, and Server-Sent Events.
I would not talk about WebSockets and SSE or try them, though, because:
They have experimental support
If I want WebSockets and SSE, I probably would use Blazor and SignalR
2.1.1. AJAX
The core of htmx is a set of attributes that allow you to issue AJAX requests directly from HTML:
Attribute | Description |
hx-get | Issues a GET request to the given URL |
hx-post | Issues a POST request to the given URL |
hx-put | Issues a PUT request to the given URL |
hx-patch | Issues a PATCH request to the given URL |
hx-delete | Issues a DELETE request to the given URL |
Each of these attributes takes a URL to issue an AJAX request to. The element will issue a request of the specified type to the given URL when the element is triggered:
<div hx-put="/messages">
Put To Messages
</div>
This tells the browser:
When a user clicks on this div, issue a PUT request to the URL /messages and load the response into the div
By default, AJAX requests are triggered by the “natural” event of an element:
input
,textarea
&select
are triggered on thechange
eventform
is triggered on thesubmit
eventeverything else is triggered by the
click
event
The hx-trigger
attribute could change this to specify which event will cause the request. Here is a div
that posts to /mouse_entered
when a mouse enters it:
<div hx-post="/mouse_entered" hx-trigger="mouseenter">
[Here Mouse, Mouse!]
</div>
2.1.2. CSS Transitions
htmx makes it easy to use CSS Transitions without javascript. Imagine that we need to replace content by htmx via an AJAX request with this new content:
<div id="div1">Original Content</div>
<!-- replaced -->
<div id="div1" class="red">New Content</div>
Note two things:
The div has the same
id
in the original and the new contentThe
red
class has been added to the new content
Given this situation, we can write a CSS transition from the old state to the new state:
.red {
color: red;
transition: all ease-in 1s ;
}
When htmx swaps in this new content, it will do so in such a way that the CSS transition will apply to the new content, giving you a smooth transition to the new state. It happened because we keep its id
stable across requests.
2.1.3. The philosophy behind "Hypermedia-Driven Applications"
I would not delve into details here, but it is worth mentioning because it's a pretty interesting concept. They wrote a book on it, read it sometime.
In short, it's all about the central role of HTML (or hypermedia) in driving and representing the state of web applications. Instead of relying heavily on intricate client-side scripting or full-page reloads to reflect changes in application state, this principle endorses the use of hypermedia itself.
The application state evolves fluidly by allowing individual page pieces to update in response to user actions or events, reducing the need for heavy JavaScript while promoting a more resilient and accessible web experience.
We did this with early ASP.NET MVC applications, utilizing jQuery to render partials, and now the proposal is to revert to this method for the sake of simplicity.
2.2. Razor Pages
Razor Pages, introduced in ASP.NET Core 2.0, offer a streamlined alternative to the MVC UI pattern, which Microsoft has supported since 2009.
While MVC promotes separation of concerns, it often creates a sprawling structure of controllers, views, and viewmodels across multiple folders. In contrast, Razor Pages encapsulate the action (referred to as a handler) and the viewmodel (now the PageModel) in a single class, directly tied to a corresponding Razor Page.
All these pages reside in a central Pages
folder, following a naming-based routing convention. Handlers, prefixed with HTTP verbs like OnGet
, simplify actions, typically returning the associated page by default. This structure enables concise, focused pages, making an application's navigation and updates more intuitive.
Razor Pages overall a vast topic, but I want to point out two essential for the htmx parts of it: handlers and partials
2.2.1. Razor Pages Handlers
Handler methods are the public methods in Razor Pages PageModel
and they are automatically executed on a request, implicitly returning a result for the current page.
The Razor Pages framework uses a naming convention to select the appropriate handler method to execute. The default convention works by matching the HTTP method used for the request to the name of the method, which is prefixed with "On": OnGet(), OnPost(), OnPut()
or async versions OnPostAsync(), OnGetAsync()
, etc.
Handler methods can return void
, Task
if asynchronous, or an IActionResult
(or Task<IActionResult>
).
IActionResult
ActionResult
return types are possible in an action. The ActionResult
types represent various HTTP status codes. Any non-abstract class deriving from ActionResult
qualifies as a valid return type. Some common return types in this category are BadRequestResult (400), NotFoundResult (404), and OkObjectResult (200).public class ValidationModel : PageModel
{
public void OnGet()
{
}
public IActionResult OnPost()
{
return Page();
}
}
Razor Pages include a feature called "named handler methods". This feature enables you to specify multiple methods that can be executed for a single verb.
public IActionResult OnGetItem()
{
return Partial("_ItemPartial");
}
public IActionResult OnPostItem()
{
return Partial("_ItemPartial");
}
We could use tag helpers to generate links like /validation?handler=Item
, where the handler would be set as a query parameter. There is also an option to generate path links like /validation/Item
by setting a page route @page "{handler?}"
Tag Helpers
<!-- Razor code -->
<article>
<a asp-page="Validation" asp-page-handler="Item">Link</a>
</article>
<!-- HTML code -->
<article>
<a href="/validation?handler=Item">Link</a>
</article>
Unfortunately, these Tag Helpers exist only for <a>
and <form>
tags. One of the questions htmx trying to solve is Why should only <a> and <form> be able to make HTTP requests?
so we could call handlers from any tag. MVC provides a way to generate a handler with a helper function: @Url.Page("BulkUpdate", "Activate")
- would generate /BulkUpdate?handler=Activate
.
But in htmx context, handlers usually belong to the same page, so to make it easier, I wrote a simple URL helper method:
public static class UrlHandlerExtensions
{
public static string Handler(this IUrlHelper urlHelper, string handler, object? values = null)
{
// Convert the values object to a dictionary
var routeValues = new RouteValueDictionary(values)
{
["handler"] = handler // Add the handler to the dictionary
};
var url = urlHelper.RouteUrl(new UrlRouteContext
{
Values = routeValues
});
return url;
}
}
It could be used with any HTML tag to set the handler URL to a htmx attribute:
<button class="outline" hx-delete="@Url.Handler("Item", new { id = Model.Id })" antiforgery="true">
Delete
</button>
2.2.2. Razor Pages Partials
Partial Views in Razor Pages contain reusable HTML and code snippets that simplify complex pages by breaking them into smaller units.
Partial Views in ASP.NET MVC were introduced with the initial release of ASP.NET MVC itself. ASP.NET MVC 1.0 was officially released in March 2009, and from that first version, developers could use partial views to encapsulate and reuse parts of their views.
Partial Views are set up as cshtml files that do not take part in routing, so they do not have @page
directive. Usually, partials are used to reuse some code on razor pages with the use of partial
tag helper.
<partial name="_ItemPartial" model="Model.UserInfo" />
On top of that PageModel
provides a set of methods that implement IActionResult
, one of them: Partial
- would apply the model, render a partial HTML view to the response, and return it as a response body.
Method | Description |
Page() | Creates a PageResult object that renders the page. |
Partial(String) | Creates a PartialViewResult by specifying the name of a partial to render. |
Partial(String, Object) | Creates a PartialViewResult by specifying the name of a partial to render and the model object. |
That gives us the ability to define razor partial view
@model Xakpc.RazorHtmx.Data.UserInfoViewModel
<tr>
<td>@Model.FirstName</td>
<td>@Model.Email</td>
<td>
<button class="outline" hx-get="@Url.Handler("EditItem", new { id = Model.Id })">
Edit
</button>
</td>
</tr>
and return it as generated HTML in a response
public IActionResult OnGetItem(int id)
{
var item = TestData.Users.First(x => x.Id == id);
return Partial("_TableRow", item);
}
<tr>
<td>Krystal</td>
<td>krystal.heaney@bergnaummetz.us</td>
<td>
<button class="outline" hx-get="/edit-row?id=2&handler=EditItem">
Edit
</button>
</td>
</tr>
And we could also re-use the exact partial for the initial page generation
<tbody hx-target="closest tr" hx-swap="outerHTML">
@foreach (var userInfo in Model.Users)
{
<partial name="_TableRow" model="userInfo"/>
}
</tbody>
In my opinion this feature is a single reason why Razor Pages could even work with htmx.
3. Setting the Stage: Prerequisites and Initial Setup
To start, you don't need much - an installed dotnet core SDK and a favorite editor. I use Visual Studio because it's been my default editor since version VS 2013. But VS Code, or VS on Mac, or anything else, is fine. Check official tutorial if you feel stuck.
Run the following command to create a new Razor Pages project:
dotnet new webapp -o RazorHtmx
It would create a default project with Bootstrap and jQuery applied by default. I prefer to use pico.css in my projects, so I have a reduced version (you can check it here), but unfortunately, it's not a template yet.
3.1. Integrating htmx into Razor Pages
To integrate htmx, you should include the js file into _Layout
. Choose whatever option you prefer from docs#installing. I downloaded and included htmx.min.js
to a head tag of _Layout.cshtml
_Layout.cshtml
_Layout.cshtml
.<head>
<!-- rest of the code -->
<script src="~/js/htmx.min.js" asp-append-version="true"></script>
</head>
4. Making First htmx Call in Razor
As I mentioned earlier, my aim was to explore each example from the htmx examples page to understand what is possible and how that could be helpful. The first example is click-to-edit, which is my perfect use case.
The click-to-edit pattern provides a way to offer inline editing of all or part of a record without a page refresh.
The example ended with several files
_Partial.cshtml
to present record_Edit.cshtml
to present form for editingClickToEdit.cshtml
to present the pageClickToEdit.cshtml.cs
for PageModelUserInfoViewModel.cs
for model
Let me describe each of them.
UserInfoViewModel.cs
It's a pretty generic model class. Each property has data annotation to render labels in forms.
public class UserInfoViewModel
{
public int Id { get; set; }
[Display(Name = "First Name")]
public string FirstName { get; set; }
[Display(Name = "Last Name")]
public string LastName { get; set; }
[Display(Name = "Email")]
public string Email { get; set; }
[Display(Name = "Status")]
public string? Status { get; set; }
public Guid RowId { get; set; } = Guid.Empty;
}
ClickToEdit.cshtml.cs
PageModel
for this page consists of all required handler methods, basic OnGet
for initial loading, a couple named GET
handlers to show the edit form and view form, and PUT
method to update form data.
public class ClickToEditModel : PageModel
{
public UserInfoViewModel UserInfoViewModel { get; set; }
public void OnGet()
{
UserInfoViewModel = GetUserInfo(1);
}
public IActionResult OnGetPartial(int id)
{
UserInfoViewModel = GetUserInfo(id);
return Partial("_Partial", UserInfoViewModel);
}
public IActionResult OnGetEdit(int id)
{
UserInfoViewModel = GetUserInfo(id);
return Partial("_Edit", UserInfoViewModel);
}
public IActionResult OnPutEdit(UserInfoViewModel userInfoViewModel)
{
UserInfoViewModel = userInfoViewModel;
return Partial("_Partial", UserInfoViewModel);
}
}
ClickToEdit.cshtml
The page itself is nothing other than a placeholder for partials. Instead of using <partial>
tag helper, I'm using Html.PartialAsync
helper method, but the outcome would be the same.
@page "/click-to-edit"
@model ClickToEditModel
<article>
@await Html.PartialAsync("_Partial", Model.UserInfoViewModel)
</article>
Now, about partials. We have two of them - one renders a form, and another renders a view. Note that none of the HTML tags have classes because I use the semantic HTML CSS library pico.css. This approach significantly reduces the size of the partial. Consider the bulk that would be added if you were to use a class loaded with Tailwind CSS attributes.
First, _Partial.cshtml
@model Xakpc.RazorHtmx.Data.UserInfoViewModel
<div hx-target="this" hx-swap="outerHTML">
<p>
<strong><label asp-for="@Model.FirstName"></label></strong>
@Model.FirstName
</p>
<p>
<strong><label asp-for="@Model.LastName"></label></strong>
@Model.LastName
</p>
<p>
<strong><label asp-for="@Model.Email"></label></strong>
@Model.Email
</p>
<button hx-get="@Url.Page("ClickToEdit", "Edit", new { id = Model.Id })">
Click To Edit
</button>
</div>
hx-target="this"
makes a div that updates itself when changed. It's also inherited, so it works when placed on the parent element.hx-swap="outerHTML"
instructs to replace the entire target element with the response.hx-get="@Url.Page("ClickToEdit", "Edit", new { id = Model.Id })"
would generate a URL toClickToEdit.OnGetEdit
handler method and would passid
query parameter.
<button hx-get="/click-to-edit?id=1&handler=Edit">
Click To Edit
</button>
_Edit.cshtml
@model Xakpc.RazorHtmx.Data.UserInfoViewModel
<form hx-put="@Url.Page("ClickToEdit", "Edit")"
hx-target="this" hx-swap="outerHTML">
@Html.AntiForgeryToken()
<input asp-for="Id" type="hidden"/>
<label asp-for="@Model.FirstName"></label>
<input asp-for="@Model.FirstName" type="text" required/>
<label asp-for="@Model.LastName"></label>
<input asp-for="@Model.LastName" type="text" required/>
<label asp-for="@Model.Email"></label>
<input asp-for="@Model.Email" type="email" required/>
<div class="grid">
<button>Submit</button>
<button hx-get="@Url.Page("ClickToEdit", "Partial", new { id = Model.Id })" class="secondary">Cancel</button>
</div>
</form>
@Html.AntiForgeryToken()
will generate a hidden field with an anti-forgery token to prevent CSRF vulnerabilities. More on this later.hx-put="@Url.Page("ClickToEdit", "Edit")"
will performPUT
a request toOnPutEdit
handler because updates should bePUT
according to RESTful best practices.hx-target="this"
makes a div that updates itself when changed. It's also inherited and works when placed on the parent element.hx-swap="outerHTML"
instructs to replace the entire target element with the response.hx-get="@Url.Page("ClickToEdit", "Partial", new { id = Model.Id })"
will performGET
request toOnGetPartial
handler and set query parameterid
to model id. This query would return rendered HTML of the record view:
<div hx-target="this" hx-swap="outerHTML">
<p>
<strong><label for="FirstName">First Name</label></strong>
Kennedy
</p>
<p>
<strong><label for="LastName">Last Name</label></strong>
Heaney
</p>
<p>
<strong><label for="Email">Email</label></strong>
kennedy_heaney@botsford.uk
</p>
<button hx-get="/click-to-edit?id=1&handler=Edit">
Click To Edit
</button>
</div>
As a result, a fully functional click-to-edit form in Razor Pages and htmx with 100-120 lines of code.
5. Exploring Other htmx Use-Cases
I surely could not even try to describe every feature htmx has. I selected a couple of examples that might be useful references for any web application.
5.1. Progress Bar
The progress bar is a very simple partial, just two lines. Having it as a separate file is almost a crime, but unfortunately, it's how it is.
@model int
<progress id="progress" value="@Model" max="100"></progress>
But I want to show you here is a _ProgresPartial, which hosts a progress bar.
Based on the progress model (is progress done or not), we could alter how this partial is rendered and what hx-trigger
value is set. This is a primary function of Razor markup syntax.
Therefore, we could decide on the application's state and what should be returned as a view on the backend, which is very convenient.
@model Progress
<div hx-trigger="done" hx-get="@Url.Handler("Progress")" hx-swap="outerHTML" hx-target="this">
@if (Model.Done)
{
<h3 role="status" tabindex="-1" autofocus>Complete</h3>
}
else
{
<h3 role="status" tabindex="-1" autofocus>Running</h3>
}
<div
hx-get="@Url.Handler("Progress")"
hx-trigger="@(Model.Done ? "none" : "every 600ms")"
hx-target="this"
hx-swap="innerHTML">
<partial name="_ProgressBarPartial" model="@Model.Value"/>
</div>
@if (Model.Done)
{
<button hx-post="@Url.Handler("Start")" antiforgery="true" classes="add show:600ms">
Restart Job
</button>
}
</div>
5.2. Table Scroll, Edit, Delete
Building interactive tables is a common task of any web application. With the help of htmx, we could easily expand a static table with scroll loading in place of edit and deletion.
I implemented several tests related to tables:
Infinite Scroll
Table Row Edit
Table Row Delete
For infinite scroll, we have a partial in the tbody
<tbody id="tbody">
@await Html.PartialAsync("_TableBody", Model.UsersTable)
</tbody>
The partial would draw table rows, and the last row would add hx-get
attribute with revelaed
trigger.
@foreach (var userInfo in Model.Users)
{
@if (userInfo == Model.Users.Last())
{
<tr hx-get="@Url.Page("InfiniteScroll", "Page", new { page = Model.Page + 1 })"
hx-trigger="revealed"
hx-swap="afterend"
hx-indicator="#ind">
<td>@userInfo.FirstName @userInfo.LastName</td>
<td>@userInfo.Email</td>
<td>@userInfo.RowId.ToString("N").ToUpperInvariant()</td>
</tr>
}
else
{
<tr>
<td>@userInfo.FirstName @userInfo.LastName</td>
<td>@userInfo.Email</td>
<td>@userInfo.RowId.ToString("N").ToUpperInvariant()</td>
</tr>
}
}
The handler method load and render the next page of rows. There is also Task.Delay
to simulate some loading.
public async Task<IActionResult> OnGetPageAsync(int page)
{
await Task.Delay(1000);
var data = TestData.Users.GetRange(page * PageSize, PageSize);
return Partial("_TableBody", data);
}
Here is how it works:
For table editing, I have two partials, for presenting and for editing, which are swapped on the button click.
<tr>
<td>@Model.FirstName</td>
<td>@Model.Email</td>
<td>
<button class="outline" hx-get="@Url.Handler("EditItem", new { id = Model.Id })">
Edit
</button>
</td>
</tr>
<tr>
<td>
<input type="text" asp-for="@Model.FirstName"/>
</td>
<td>
<input type="email" asp-for="@Model.Email"/>
</td>
<td>
<button hx-get="@Url.Handler("Item", new { id = Model.Id })" class="secondary" style="margin-bottom: 6px;">
Cancel
</button>
<button hx-put="@Url.Handler("Item", new { id = Model.Id })" hx-include="closest tr" antiforgery="true">
Save
</button>
</td>
</tr>
Handlers for this page are straightforward: get item, get item form, update (put) item
public IActionResult OnGetItem(int id)
{
var item = TestData.Users.First(x => x.Id == id);
return Partial("_TableRow", item);
}
public IActionResult OnGetEditItem(int id)
{
var item = TestData.Users.First(x => x.Id == id);
return Partial("_TableRowForm", item);
}
public IActionResult OnPutItem(UserInfoViewModel user)
{
var item = TestData.Users.First(x => x.Id == user.Id);
item.FirstName = user.FirstName;
item.Email = user.Email;
return Partial("_TableRow", item);
}
And now we can easily update table rows
Delete Row has a similar table row partial, but with hx-delete
handler, because REST.
<tr>
<td>@Model.FirstName @Model.LastName</td>
<td>@Model.Email</td>
<td>@Model.Status</td>
<td>
<button class="outline" hx-delete="@Url.Handler("Item", new { id = Model.Id })" antiforgery="true">
Delete
</button>
</td>
</tr>
On the delete call, the handler would return 200 OK with an empty body instructing htmx to remove the row. We also use hx-confirm
tag to confirm deletion
<tbody hx-confirm="Are you sure?" hx-target="closest tr" hx-swap="outerHTML swap:1s">
@foreach (var userInfo in Model.Users)
{
@await Html.PartialAsync("_TableRow", userInfo)
}
</tbody>
There is also some transition animation
Let's look at a couple of practical scenarios where htmx and Razor Pages integration shines.
5.3. Dialogs and tabs
A full code listing of how HTML5 dialogs could be created with htmx and Razor Pages. In this example, I used some JavaScript to show and hide the dialog.
At the same time, when dialog is shown, htmx would issue a get request to get data from the page handler and set the result as dialog content.
Actual dialog content would be generated on the backend from Razor partial.
<!-- DialogsHtml.cshtml -->
@page "/dialogs-html"
@model DialogsHtmlModel
<!-- The Dialog -->
<dialog id="myDialog">
<article id="dialogContent">
</article>
</dialog>
<article>
<!-- Trigger buttons -->
<button data-target="myDialog"
hx-get="@Url.Handler("Modal")"
hx-target="#dialogContent"
hx-trigger="click"
onClick="showDialog()">
Show Dialog
</button>
</article>
@section Scripts
{
<script>
function showDialog() {
const dialog = document.getElementById('myDialog');
dialog.showModal(); // Opens the dialog
}
function closeDialog() {
const dialog = document.getElementById('myDialog');
dialog.close(); // Closes the dialog
}
</script>
}
<!-- DialogsHtml.cshtml.cs -->
public class DialogsHtmlModel : PageModel
{
public void OnGet()
{
}
public IActionResult OnGetModal()
{
return Partial("_ModalPartial", ("Model Content Header", """
Nunc nec ligula a tortor sollicitudin dictum in vel enim.
Quisque facilisis turpis vel eros dictum aliquam et nec turpis.
Sed eleifend a dui nec ullamcorper.
Praesent vehicula lacus ac justo accumsan ullamcorper.
"""));
}
}
<!-- _ModalPartial.cshtml -->
@model (string Header, string Text)
<header>
<a href="#close" aria-label="Close" class="close" onclick="closeDialog(); return false;"></a>
@Model.Header
</header>
<div>
<p>
@Model.Text
</p>
</div>
A full code listing of how tabs could be created with htmx and Razor Pages. Here, I have tabs div, which is replaced with tab partial.
The interesting part is a load
trigger, which is fired on page load to get the initial tab. That could be done through a razor like I did previously, as well.
<!-- Tabs.chtmls -->
@page "/tabs-hateoas"
@model TabsModel
<div id="tabs"
hx-get="@Url.Handler("Tab1")"
hx-trigger="load delay:100ms"
hx-target="#tabs"
hx-swap="innerHTML">
</div>
<!-- Tabs.chtmls.cs -->
public class TabsModel : PageModel
{
public void OnGet()
{
}
public IActionResult OnGetTab1()
{
return Partial("_Tab1Partial");
}
public IActionResult OnGetTab2()
{
return Partial("_Tab2Partial");
}
public IActionResult OnGetTab3()
{
return Partial("_Tab3Partial");
}
}
<!-- _Tab1Partial.cshtml -->
<div class="grid">
<button hx-get="@Url.Handler("Tab1")">Tab 1</button>
<button hx-get="@Url.Handler("Tab2")" class="outline">Tab 2</button>
<button hx-get="@Url.Handler("Tab3")" class="outline">Tab 3</button>
</div>
<article>
<div>
some text...
</div>
</article>
<!-- _Tab2Partial.cshtml -->
<div class="grid" role="tablist">
<button hx-get="@Url.Handler("Tab1")" class="outline">Tab 1</button>
<button hx-get="@Url.Handler("Tab2")">Tab 2</button>
<button hx-get="@Url.Handler("Tab3")" class="outline">Tab 3</button>
</div>
<article>
<div>
some text 2...
</div>
</article>
<!-- _Tab3Partial.cshtml -->
<div class="grid" role="tablist">
<button hx-get="@Url.Handler("Tab1")" class="outline">Tab 1</button>
<button hx-get="@Url.Handler("Tab2")" class="outline">Tab 2</button>
<button hx-get="@Url.Handler("Tab3")">Tab 3</button>
</div>
<article>
<div>
some text 3...
</div>
</article>
Tabs, in my case, are buttons, but it still looks good
5.4. Live search feature on a Razor Page
Here we use keyup
trigger to send a search query to OnPostSearch
handler, which would render partial based on search results.
<!-- ActiveSearch.cshtml -->
@page "/active-search"
@model ActiveSearchModel
<article>
<h3>
Search Contacts
<span class="htmx-indicator" aria-busy="true">Searching...</span>
</h3>
<input type="search" name="search"
placeholder="Begin Typing To Search Users..."
hx-post="@Url.Handler("Search")"
hx-trigger="keyup changed delay:500ms, search"
hx-target="#search-results"
hx-indicator=".htmx-indicator"
antiforgery="true"/>
<table class="table">
<thead>
<tr>
<th>First Name</th>
<th>Last Name</th>
<th>Email</th>
</tr>
</thead>
<tbody id="search-results">
</tbody>
</table>
</article>
<!-- ActiveSearch.cshtml.cs -->
public class ActiveSearchModel : PageModel
{
public void OnGet()
{
}
public IActionResult OnPostSearch(string search)
{
if (search == null || string.IsNullOrEmpty(search))
{
return Partial("_TableBody", new List<UserInfoViewModel>());
}
var users = TestData.Users.Where(u => u.Email.Contains(search.ToLowerInvariant())).ToList();
if (users.Any())
{
return Partial("_TableBody", users);
}
return Partial("_TableBody", TestData.Users.Take(7).ToList());
}
}
<!-- _TableBody.cshtml -->
@model List<Xakpc.RazorHtmx.Data.UserInfoViewModel>
@foreach (var userInfo in Model)
{
<tr>
<td>@userInfo.FirstName</td>
<td>@userInfo.LastName</td>
<td>@userInfo.Email</td>
</tr>
}
Here is how it works
5.5 Animations
While my primary focus wasn't on aesthetics during testing, it's worth noting that it could be done. htmx supports CSS transitions
so we could apply some cool animations to our partials.
@page "/animations"
@model AnimationsModel
<article>
<h2>Using the View Transition API</h2>
<div class="slide-it">
<partial name="_SwapContentPartial", model="@("Initial Content", "Swap It")"/>
</div>
</article>
@section Styles
{
<style>
@@keyframes fade-in {
from { opacity: 0; }
}
@@keyframes fade-out {
to { opacity: 0; }
}
@@keyframes slide-from-right {
from { transform: translateX(90px); }
}
@@keyframes slide-to-left {
to { transform: translateX(-90px); }
}
.slide-it {
view-transition-name: slide-it;
}
::view-transition-old(slide-it) {
animation: 180ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
600ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left;
}
::view-transition-new(slide-it) {
animation: 420ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in,
600ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right;
}
</style>
}
In partial, we instruct htmx to use transition.
@model (string Title, string Button)
<h6>@Model.Title</h6>
<button hx-get="@Url.Handler("NewContent")" hx-swap="innerHTML transition:true" hx-target="closest div">
@Model.Button
</button>
This gives us nice animation.
6. Benefits of the Integration
Simplicity, simplicity, simplicity.
I am a huge advocate of simplicity. I work with highly complex systems in my daily job, where dozens of microservices process gigabytes of data 24/7.
Probably, that's why I wouldn't say I like to overcomplicate anything. And like Razor Pages was invented to simplify MVC web development, pico.css was invented to simplify web design, so as far as I see, htmx could be the tool to simplify interactivity.
But these are all personal preferences. Let's evaluate what actual benefits Razor Pages together with htmx might produce.
6.1. Speed and Efficiency: Faster page loads and enhanced user experience.
htmx allows for seamless partial page updates, meaning web pages can reflect real-time data changes without requiring a complete page refresh. This leads to a more interactive and immersive user experience, particularly beneficial for applications where up-to-the-minute data display is crucial, like dashboards or monitoring systems.
Due to the lightweight nature of htmx, only necessary data is transferred between client and server. This minimizes the bandwidth usage and server processing power required for each user interaction, making it possible to serve more users without a proportional resource increase.
6.2. Maintainability: Separation of concerns with Razor's code-behind model and htmx's lightweight nature.
Razor Pages is MPA (Multi-Page Application) framework. It is designed for creating web applications where each user interaction or action corresponds to a different web page loaded from the server.
Historically, dotnet developers relied on JavaScript and jQuery to add interactivity. I believe there was never a "best-way" of how to organize JavaScript: there was a custom colocation option cshtml.js
, section @Scripts
, setup grunt
and npm
aside dotnet project, but most of the time, js was simply dumped into wwwroot
folder.
htmx takes razor page concept of self-contained Page with its associated logic, data model, and view and adds interactivity there but keeps it clean and maintainable.
6.3. Simplified Development Workflow
By merging Razor Pages and htmx, developers can enjoy a more streamlined development process. The inherent structure of Razor Pages, which emphasizes a clear delineation between view and logic, combined with the straightforward integration of htmx, reduces the learning curve for new team members.
This synergy minimizes the need for extensive reliance on JavaScript for dynamic content, which can often introduce complexities. As a result, development teams can bring features to market faster and address bugs or changes with greater agility.
7. Potential Challenges and Solutions
While the benefits of this integration are numerous, like all technologies, it comes with its set of challenges.
7.1. Antiforgery Token
Running the code below to update data will result in the error: Response Status Error Code 400 from /click-to-edit?handler=Edit
<form hx-put="@Url.Page("ClickToEdit", "Edit")"
hx-target="this"
hx-swap="outerHTML">
<!-- form body -->
</form>
This is because any POST or PUT request to the razor page requires AntiforgeryToken to prevent CSRF attacks.
Cross-Site Request Forgery (CSRF) is an attack where a malicious entity tricks a user's browser into performing undesired actions on a trusted site where the user is authenticated. This is possible because browsers automatically include cookies, including session cookies, with requests.
To counteract this, many sites employ CSRF tokens, generated server-side. These tokens, which can be created per session or request, ensure that every action taken on a site is genuinely intended by the authenticated user.
The server validates the token with every request; if they don't match, the request is denied, potentially flagging it as a CSRF attempt.
Antiforgery Token check could be disabled by applying [IgnoreAntiforgeryToken(Order = 1001)]
attribute on the page model, but that is a security risk.
You could manually expand forms and add anti-forgery token with a helper method @Html.AntiForgeryToken()
. This would provide tokens as a hidden field and include it into form data.
Tokens also could be provided as a header. It is considered a more secure option, and we could add a header to our htmx request using hx-headers
attribute.
<form hx-put="@Url.Page("ClickToEdit", "Edit")"
hx-headers='{"RequestVerificationToken": "@Model.AntiforgeryToken"}'
hx-target="this" hx-swap="outerHTML" >
For forms, it is better to use the helper method. But to add tokens to other HTML elements, such as <button>
or <input>
I created a helper tag class. It would generate hx-headers
tag and set token value when I add an attribute antiforgery="true"
to element.
[HtmlTargetElement("input", Attributes = AntiForgeryAttributeName)]
[HtmlTargetElement("button", Attributes = AntiForgeryAttributeName)]
public class AntiForgeryHeaderTagHelper : TagHelper
{
[HtmlAttributeNotBound]
[ViewContext]
public ViewContext ViewContext { get; set; }
[HtmlAttributeName(AntiForgeryAttributeName)]
public bool AntiForgery { get; set; }
private const string AntiForgeryAttributeName = "antiforgery";
private readonly IAntiforgery _antiforgery;
public AntiForgeryHeaderTagHelper(IAntiforgery antiforgery)
{
_antiforgery = antiforgery;
}
public override void Process(TagHelperContext context, TagHelperOutput output)
{
if (AntiForgery)
{
var token = _antiforgery.GetAndStoreTokens(ViewContext.HttpContext).RequestToken;
var currentHeaderValue = output.Attributes["hx-headers"]?.Value.ToString();
var newHeaderValue = $"\"RequestVerificationToken\": \"{token}\"";
if (string.IsNullOrEmpty(currentHeaderValue))
{
output.Attributes.SetAttribute("hx-headers", newHeaderValue);
}
else
{
// Append the anti-forgery token to existing hx-headers
newHeaderValue = $"{currentHeaderValue}, {newHeaderValue}";
output.Attributes.SetAttribute("hx-headers", newHeaderValue);
}
}
}
}
7.2. Validation
MVC and DataAttributes provide a lot of helpers to generate validation. Unfortunately, most of it is designed for jquery.validate.js
and jquery.validate.unobtrusive.js
.
On the other hand, htmx is designed to utilize HTML5 validation.
Of course, we could disable client-side validation and write everything by hand, but that sounds annoying.
services.AddRazorPages()
.AddViewOptions(options =>
{
options.HtmlHelperOptions.ClientValidationEnabled = false;
});
At the moment I didn't found a good solution for that. I could see a several options here:
Link together HTML 5 and MVC-generated validation, by writing custom js code
Link together MVC-generated validation and htmx, but keep using
jquery.validate
If you know a solution to that problem, feel free to ping me.
8. Conclusion
To sum it up, I tried different use cases of htmx and dotnet Razor Pages during my tests. In this article, I presented the history of htmx and Razor Pages, and show you how they can be integrated seamlessly.
I was impressed by how a technology from 2009, partials, integrates so well with htmx. It also fits well with Razor Pages and pico.css, which I often use for side projects. I only have positive impressions here:
It works with razor and MVC partials perfectly
It's a good fit with semantic HTML CSS libraries
It's two times smaller than jQuery
It's straightforward to use
Regarding the negative aspects
There is no IntelliSense support of htmx tags in Visual Studio yet (there is for Visual Studio Code)
Need to figure out validation to reuse all that helpers Razor Pages provides
Some kind of fragments would be nice
htmx might not be accepted by enterprises where dotnet is most used
That would be all. Once again, the source code of my tests is available on github, the demo project deployed on Azure, and if you have any questions, feel free to ping me on Twitter, sorry, on 𝕏.
9. Further Reading/References
In the future, I might explore some more advanced scenarios, like validation, boosting, history, and maybe even touch hyperscript (but I doubt that; it does not bring me joy at first glance).
Some useful resources