htmx 🤝 dotnet

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.

💡
The results of these tests are available in the repo. The demo application is hosted on Azure free plan and is available here. To get maximum from this article you need to be familiar with ASP.NET MVC or RazorPages

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)
To retrieve data from a web server, AJAX utilizes a blend of a browser's built-in XMLHttpRequest object and JavaScript and HTML DOM to display or utilize the data. However, newer browsers can use the Fetch API instead of the XMLHttpRequest Object. XML in the name is obsolete and misleading, in fact, any data could be transported: from JSON to HTML.

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:

AttributeDescription
hx-getIssues a GET request to the given URL
hx-postIssues a POST request to the given URL
hx-putIssues a PUT request to the given URL
hx-patchIssues a PATCH request to the given URL
hx-deleteIssues 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 the change event

  • form is triggered on the submit event

  • everything 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 content

  • The 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.

meme from htmx.org

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
The IActionResult return type is appropriate when multiple 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
Tag Helpers in Razor files allow server-side code to create and render HTML elements. They include built-in helpers for common tasks like creating forms, links, and loading assets. Custom Tag Helpers can also be created in C# to target elements by name, attribute, or parent tag.
<!-- 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.

MethodDescription
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&amp;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
Web apps use a common layout for consistency. This includes elements like headers, navigation, and footers. Layout files reduce duplicate code and the default layout for ASP.NET Core is named _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 editing

  • ClickToEdit.cshtml to present the page

  • ClickToEdit.cshtml.cs for PageModel

  • UserInfoViewModel.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 to ClickToEdit.OnGetEdit handler method and would pass id 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 perform PUT a request to OnPutEdit handler because updates should be PUT 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 perform GET request to OnGetPartial handler and set query parameter id 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&amp;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