Htmx allows us to handle async form submissions with ease by using hx-post attribute. In background it will issue a post request to the url specified in hx-post attribute and replace the element with the response.

In this post we will try to handle form submissions in razor pages using htmx.

Initialize a new razor pages project.

dotnet new razor -o htmx-form

In Pages/Index.cshtml we setup the layout.

@page
@using Htmx
@model IndexModel
@{
    ViewData["Title"] = "Home page";
}

<div class="row pb-5">
    <div class="col">
        Form here
    </div>
    
    <div class="col">
        Login here
    </div>

</div>

Create a partial userform which will be used here and in the post request.

@model htmxrazor.Pages.UserModel // User model to be used in the form

<form hx-post="" hx-target="this" hx-swap="outerHTML">
    <div class="mb-3">
        <label for="exampleInputEmail1" class="form-label">Email address</label>
        <input asp-for="Email" type="email" class="form-control" id="exampleInputEmail1" />
        <span asp-validation-for="Email" class="text-danger"></span>
        <div id="emailHelp" class="form-text">We'll never share your email with anyone else.</div>
    </div>
    <div class="mb-3">
        <label for="exampleInputPassword1" class="form-label">Password</label>
        <input asp-for="Password" type="password" class="form-control" id="exampleInputPassword1" />
        <span asp-validation-for="Password" class="text-danger"></span>
    </div>
    @Html.AntiForgeryToken()
    <button type="submit" class="btn btn-primary">Submit</button>
</form>

Note: The AntiForgeryToken is required or else you’ll get 400 unless you ignore it using [IgnoreAntiforgeryToken].

in the partial view, the hx-post attribute will post the form to the current page. the hx-target attribute will replace the current element with the response. the hx-swap attribute will replace the outerHTML of the current element with the response. validation results are rendered using asp-validation-for tag helper.

In Pages/Index.cshtml we render the form.

@page
@using Htmx
@model IndexModel
@{
    ViewData["Title"] = "Home page";
}

<div class="row pb-5">
    <div class="col">
        <h2>Form here</h2>
        <partial name="_UserForm" model="Model.User" />
    </div>
    
    <div class="col">
        Login here
    </div>
</div>

Now, we need to handle the post request. In Pages/Index.cshtml.cs the handlers will look like this.

using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace htmxrazor.Pages;

// User model to be used in the form
    public class UserModel
    {
        [Required]
        public string Email { get; set; }

        [Required]
        public string Password { get; set; }
    }

public class IndexModel : PageModel
{
    private readonly ILogger<IndexModel> _logger;

    public IndexModel(ILogger<IndexModel> logger)
    {
        _logger = logger;
    }

    // Bind property to the page model it will be used when posting data
    [BindProperty]
    public UserModel UserData { get; set; } = new();

    // GET /
    // Get request to render the page Index.cshtml
    public void OnGet()
    {
    }
    
    // POST /
    // Post request to handle form submission
    public IActionResult OnPost()
    {

        if (!ModelState.IsValid)
        {
            // If model state is invalid return the partial view with the model
            _logger.LogInformation("user is invalid");
            return Partial("_UserForm", UserData);
        }
        
        // If model state is valid return success
        _logger.LogInformation("user is valid");
        return Content("<div>success</div>", "text/html");
    }
}

Note: The BindProperty attribute is required to bind the form data to the model.

When the form is submitted, the OnPost method will be called. If the model state is invalid, the partial view will be returned with the model. If the model state is valid, the success message will be returned.

example invalid form post response ( blank ) will be.

<form hx-post="" hx-target="this" hx-swap="outerHTML">
    <div class="mb-3">
        <label for="exampleInputEmail1" class="form-label">Email address</label>
        <input type="email" class="form-control input-validation-error" id="exampleInputEmail1" data-val="true" data-val-required="The Email field is required." name="Email" value=""/>
        <span class="text-danger field-validation-error" data-valmsg-for="Email" data-valmsg-replace="true">The Email field is required.</span>
        <div id="emailHelp" class="form-text">We'll never share your email with anyone else.</div>
    </div>
    <div class="mb-3">
        <label for="exampleInputPassword1" class="form-label">Password</label>
        <input type="password" class="form-control input-validation-error" id="exampleInputPassword1" data-val="true" data-val-required="The Password field is required." name="Password"/>
        <span class="text-danger field-validation-error" data-valmsg-for="Password" data-valmsg-replace="true">The Password field is required.</span>
    </div>
    <input name="__RequestVerificationToken" type="hidden" value="token"/>
    <button type="submit" class="btn btn-primary">Submit</button>
</form>

and htmx is gonna replace the form with the response.

My Thoughts

I really back the idea of HTMX, and I believe it’s such a great library which should be widely adopted in large projects. Instead of making both client and server insanely complex and “solving” the challenges in that local space, HTMX allows us to solve the challenges in the server and use the browser as a dumb renderer.

In my opinion, enterprise applications’ complexity would be the scale of edge cases it needs to handle, not the complexity of duplicate client and server side logic, state and managing their sync. Technologies like HTMX allows us to have more security and control, reduce complexity in ‘server - client’ interfacing and sync, all without sacrificing the user experience / interactivity.

Razor Pages seems like a great choice for a Backend for Frontend (BFF) service. It’s easy to setup and use and it’s easy to integrate with htmx.

The idea of every response being an html page or a partial html and the ability of Razor pages to deliver that feels like a good combination. This integrates well with the htmx way to handle async requests.