ASP.NET forms validation and htmx
Exploring form validation in ASP.NET Razor Pages with and without htmx
Htmx works great with ASP.NET Razor Pages. Let's explore how we can solve the most annoying part of any form post: client-side and server-side validations.
We will explore built-in validation options, how we can adapt ASPNET's built-in validation to work efficiently with htmx, or how we can skip it completely and use HTML5 for validation.
To understand this article, you should already be familiar with Razor Pages and htmx. If not, I recommend reading a more introductory article in the series.
History of validation in ASPNET
In ASP-NET applications, validation has been built right into the framework using Data Annotations and unobtrusive JavaScript since early versions. You can define validation rules in your model classes with attributes like [Required]
, [StringLength]
, or [Range]
. These rules would be used on both the server and client sides.
For client-side validation, the jquery.validate.unobtrusive
library steps in. It applies validation rules using data attributes in the HTML elements. The Razor Pages engine uses tag helpers (like asp-for
) to generate input
tags with all the necessary attributes.
<!-- generated input with validation for use with jquery.validate.unobtrusive -->
<label for="FirstName">First Name</label>
<input type="text" data-val="true"
data-val-length="First Name cannot be longer than 50 characters"
data-val-length-max="50"
data-val-required="First Name is required"
id="FirstName"
maxlength="50"
name="FirstName" value="">
The jquery.validate.unobtrusive
library was introduced with the ASP.NET MVC 3 release on January 13, 2011. In ASP.NET MVC 2, released in March 2010, server-side validation was more common. While client-side validation was possible, it required more manual setup. Developers had to use the jQuery Validation plugin and write custom JavaScript code for the validation logic.
For server-side validation, user-submitted data must follow certain rules before it can be processed. The rules are defined using Data Annotations in model classes. During form submission, the model binding process maps the form data to the model properties and runs the validation framework.
public record UserRegistrationForm
{
// annotatied property
[Required(ErrorMessage = "First Name is required")]
[StringLength(50, ErrorMessage = "First Name cannot be longer than 50 characters")]
[Display(Name = "First Name")]
public string FirstName { get; init; }
}
If any data doesn't meet the defined rules, the framework sets the ModelState
to invalidate and record the validation errors. This information could be used to build a response page and display server-side validation errors on the form.
Project set-up
To test different types of validation, I created a simple Razor Pages project using pico CSS as the default design library. I did not use Bootstrap or any third-party JavaScript libraries except for jquery
, jquery.validation
, and htmx
.
Each case has a separate page with the same partial _FormContent
for the form's internals. We can reuse the same partial and almost the same page models for any type of validation: htmx
, non-htmx
, HTML5
, and jquery.validation
.
@page
@model Xakpc.RazorHtmx.Validation.Pages.RazorPageFormModel
@{
ViewData["Title"] = "User Registration";
}
<hgroup>
<h2>User Registration - Unobtrusive</h2>
<p>Regular POST of the form with <mark>jquery.validate.unobtrusive</mark> validation</p>
</hgroup>
<article>
<form method="post">
<partial name="_FormContent" model="@Model.UserRegistration"/>
</form>
</article>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}
The page model would be also almost the same for each validation: a bound property and a single OnPost
handler.
public class RazorPageFormModel : PageModel
{
[BindProperty]
public UserRegistrationForm UserRegistration { get; set; }
public void OnGet()
{
}
public IActionResult OnPost()
{
if (User.PhoneNumber.Length < 4) {
ModelState.AddModelError("PhoneNumber", "Phone format is wrong");
}
if (!ModelState.IsValid)
{
return Page();
}
// Store the user data in TempData
TempData["User"] = JsonSerializer.Serialize(UserRegistration);
return RedirectToPage("Success");
}
}
Fancy Validation States
Pico CSS supports some fancy validation states using the aria-invalid
attribute. All pages include custom code to set this attribute during server-side validation and also on the client side for HTML5 validation, as it does not do this by default.
Managing validation state on the server side is done using TagHelpers, which are convenient and powerful tools for generating HTML. I use a set of tag helpers in my Razor Pages projects, both with and without htmx.
Specifically, the ValidationStateInputTagHelper
is applied to all input tags where the asp-for
attribute is present. It adds the aria-invalid
attribute based on the value and error state.
[HtmlTargetElement("input", Attributes = "asp-for")]
[HtmlTargetElement("select", Attributes = "asp-for")]
public class ValidationStateInputTagHelper : TagHelper
{
[HtmlAttributeNotBound]
[ViewContext]
public ViewContext ViewContext { get; set; }
[HtmlAttributeName("asp-for")]
public ModelExpression For { get; set; }
public override void Process(TagHelperContext context, TagHelperOutput output)
{
var model = For.Model;
// Check if the field has a value
bool hasValue = model != null && !string.IsNullOrEmpty(model.ToString());
// Check if there are any validation errors for this field
var modelState = ViewContext;
bool hasError = modelState != null &&
modelState.ViewData.ModelState.TryGetValue(For.Name, out var entry) &&
entry.Errors.Count > 0;
// Set the aria-invalid attribute based on the value and error
if (hasValue)
{
output.Attributes.SetAttribute("aria-invalid", hasError ? "true" : "false");
}
}
}
The full code for this article is available on GitHub, feel free to explore it.
Razor Pages "default" Validation
As I mentioned earlier, In ASP.NET web applications, including Razor Pages, default validation is built upon validation attributes and the jquery.validate.unobtrusive
library. This setup ensures seamless integration of client-side and server-side validation.
When data is submitted via a POST request, the validation process kicks in before the data reaches the OnPost
handler. This means that any data entered by the user is checked against the specified validation rules defined by attributes such as [Required]
, [StringLength]
, [Range]
, and custom validation attributes.
Client-side
For example, if we define a typical input field with label and span for validation message:
<label asp-for="FirstName"></label>
<input asp-for="FirstName"/>
<span asp-validation-for="FirstName" class="text-danger"></span>
And we have property with attributes like that
[Required(ErrorMessage = "First Name is required")]
[StringLength(50, ErrorMessage = "First Name cannot be longer than 50 characters")]
[Display(Name = "First Name")]
public string FirstName { get; set; }
It would be translated into the next code:
<label for="FirstName">First Name</label>
<input type="text"
data-val="true"
data-val-length="First Name cannot be longer than 50 characters"
data-val-length-max="50" data-val-required="First Name is required"
id="FirstName"
maxlength="50"
name="FirstName" value="">
<span class="text-danger field-validation-valid" data-valmsg-for="FirstName" data-valmsg-replace="true"></span>
The rendered page transfers all model's data contract validations to HTML and utilizing data-*
attributes to perform validation on change and submission. When something is wrong, an error message is injected into the span and aria-invalid
attribute is set automatically.
Server-side Validation
By default, in Razor Pages, submitting a form triggers a POST request that results in a full page reload. In the case of POST in Razor Pages, we usually add an OnPost
handler that might look like this:
public IActionResult OnPost()
{
if (!ModelState.IsValid)
{
return Page();
}
// Store the user data in TempData
TempData["User"] = JsonSerializer.Serialize(UserRegistration);
return RedirectToPage("Success", UserRegistration);
}
ModelState
is a ModelStateDictionary. After the POST request is accepted but before the OnPost
handler called, the framework would perform validation and fill it with validation results:
If there are no validation errors, ModelState.IsValid
will be true, and we will continue processing with a redirect to the success page.
But if we introduce any validation error
if (UserRegistration.PhoneNumber.Length < 4) {
ModelState.AddModelError("User.PhoneNumber", "Phone format is wrong");
}
We will have this information in the ModelState
for the related property.
ModelState.IsValid
will be false, and Razor Page will render the page with this error information put in place of the related tag helper.
Since we have a [BindProperty]
attribute on the User
model, returning the Page()
will populate this model with data from the request. Therefore, all the data we input (except passwords) will be automatically filled back in.
<!-- template tag helper -->
<label asp-for="PhoneNumber"></label>
<input asp-for="PhoneNumber"/>
<span asp-validation-for="PhoneNumber" class="text-danger"></span>
<!-- rendered tag -->
<span class="text-danger field-validation-error"
data-valmsg-for="PhoneNumber"
data-valmsg-replace="true">Phone format is wrong</span>
This is how default Razor Pages validation works, and ASP.NET validation in general. If you want you could explore it in detail in official Microsoft documentation.
But how could we adapt this validation to use it with htmx?
Unobtrusive Validation with htmx
Htmx integrates with the HTML5 Validation API and will not issue a request for a form if a validatable input is invalid. However, since we do not use HTML5 when we use jquery.validate.unobtrusive
we need to hook into the submit process to prevent submission if any client-side validation error is found.
Server-side we would need to render and return Partial
- our server-side validated form instead of the whole page.
Client-side
First, let's set up the form as a separate partial _Form
. We would require to get the user model and ViewData with some additional properties like handler route or trigger.
@model Xakpc.RazorHtmx.Validation.UserRegistrationForm
@{
var handler = (string)ViewData["Handler"];
var trigger = (ViewData["Trigger"] as string) ?? "submit";
}
<form method="post" hx-post="@handler" hx-swap="outerHtml" hx-trigger="@trigger">
<partial name="_FormContent" model="@Model" />
</form>
We would use _Form
partial like this
@page
@model Xakpc.RazorHtmx.Validation.Pages.UnobtrusiveHtmxModel
@{
ViewData["Title"] = "User Registration";
ViewData["Handler"] = Url.Page("UnobtrusiveHtmx");
ViewData["Trigger"] = "valid-form";
}
...
<article>
<partial name="_Form" model="Model.UserRegistration" view-data="ViewData" />
</article>
@Url.Page("pageName")
is a convenient way to get a URL to the page, but I usually use custom-made @Url.Handler("handlerName")
for it.Note ViewData["Trigger"] = "valid-form"
that would be rendered as hx-trigger="valid-form"
. By default, AJAX requests are triggered by the “natural” event of an element. For a form
, this is the submit
event.
However, this won't work because the submit event is emitted even if there are validation errors. The solution here is to intercept the event and emit our own event if the form is valid.
$(document).ready(function () {
$('form').on('submit', function (e) {
var form = $(this);
if (form.valid()) {
// Form is valid, allow htmx to handle the submission
htmx.trigger("form", "valid-form");
e.preventDefault();
return true;
} else {
e.preventDefault();
return false;
}
});
});
When the form is valid the event valid-form
would trigger hx-post
and submit the form.
Server-side
Server-side validation works the same way in this case, but the way we produce the response changes. Razor Pages are smart enough to handle binding and validation even for AJAX/htmx requests (same thing).
public IActionResult OnPost()
{
if (UserRegistration.PhoneNumber.Length < 4)
{
ModelState.AddModelError("PhoneNumber", "Phone format is wrong");
}
if (!ModelState.IsValid)
{
Response.StatusCode = 422;
return Partial("_FormContent", UserRegistration);
}
// no need to use temp data to pass user object to another page
return Partial("_SuccessUser", UserRegistration);
}
Note the return code 422 Unprocessable Entity
- it is a smart way to tell the client that the form data has an error and cannot be processed. Unfortunately, htmx would not process requests with such a code - we need to set it up.
$(document).on('htmx:beforeSwap', function (event) {
var evt = event.originalEvent; // Get the original event details
if (evt.detail.xhr.status === 422) {
// Allow 422 responses to swap as we are using this as a signal that
// a form was submitted with bad data and want to rerender with the errors
//
// Set isError to false to avoid error logging in console
evt.detail.shouldSwap = true;
evt.detail.isError = false;
}
});
Unobtrusive validation is great and very powerful, but it requires using jQuery. While this is absolutely fine, it does add some weight to our page: jquery-3.3.2.min.js + jquery.validate.min.js + jquery.validate.unobtrusive.min.js = 112 KB.
Could we omit that?
HTML5 Validation
Now let's remove jquery.validation
and jQuery, and disable built-in validation with the setting ViewContext.ClientValidationEnabled = false
. With this option disabled, Razor Pages will not convert Data Annotation attributes to HTML attributes.
ClientValidationEnabled
. If you keep it enabled and just remove the jQuery libraries, you will still get all the attributes generated. You can then use them in your JavaScript validation however you like, including integrating them with HTML5 validation.On the server side, not much will change because it does not rely on client validation.
Client-side
We would lose quite a lot of generated code. Remember the input snippet?
<label asp-for="FirstName"></label>
<input asp-for="FirstName"/>
<span asp-validation-for="FirstName" class="text-danger"></span>
Here is how it would look without client validation enabled - all data attributes and validation messages are gone.
<label for="FirstName">First Name</label>
<input type="text" id="FirstName" maxlength="50" name="FirstName" value="">
<span class="text-danger"></span>
Razor Pages has very limited support for HTML5 validation, and Microsoft has somewhat given up on improving it or replacing jQuery. From what I found, it only supports type
and maxlength
, not even required
, which is a shame because supporting other built-in attributes could easily be done with tag helpers.
Here is an example of a tag helper that adds the required
attribute to inputs, as well as a custom data-err-required
attribute with an error message.
[HtmlTargetElement("input", Attributes = "asp-for")]
[HtmlTargetElement("textarea", Attributes = "asp-for")]
public class RequiredInputTagHelper : TagHelper
{
[HtmlAttributeName("asp-for")]
public ModelExpression For { get; set; }
public override void Process(TagHelperContext context, TagHelperOutput output)
{
var attribute = For.ModelExplorer.Metadata.ValidatorMetadata.FirstOrDefault(
attr => attr is RequiredAttribute) as RequiredAttribute;
if (attribute != null)
{
output.Attributes.SetAttribute("required", null);
if (!string.IsNullOrEmpty(attribute.ErrorMessage))
{
output.Attributes.SetAttribute("data-err-required", attribute.ErrorMessage);
}
}
}
}
I collected some more tag helpers for my personal use.
As you can see, these tag helpers not only set up built-in HTML5 validation but also add a data-err-*
attribute that can be used to customize the validation message.
To achieve this, we need to write some JavaScript and attach event listeners to handle the invalid
event. Then, we can use setCustomValidity
for custom validation messages.
document.addEventListener('DOMContentLoaded', function () {
const inputs = document.querySelectorAll('input');
inputs.forEach(function (input) {
// Check if the input has any data-err-* attributes
if (input.dataset.errRequired ||
input.dataset.errLength ||
input.dataset.errMinmax) {
input.addEventListener('invalid', function (event) {
const target = event.target;
let customMessage = '';
if (target.validity.valueMissing && target.dataset.errRequired) {
customMessage = target.dataset.errRequired;
} else if ((target.validity.tooLong || target.validity.tooShort) && target.dataset.errLength) {
customMessage = target.dataset.errLength;
} else if ((target.validity.rangeUnderflow || target.validity.rangeOverflow) && target.dataset.errMinmax) {
customMessage = target.dataset.errMinmax;
}
target.setCustomValidity(customMessage);
});
input.addEventListener('input', function (event) {
event.target.setCustomValidity(''); // Clear custom message on input
});
}
});
});
Example of HTML5 validation with a custom message
Server-side Validation
Server-side validation would work the same as in the default way in this case, no changes needed.
In the end, we did lose some client-side features, like the Password and Confirmation Password do not match
validation, but this would be handled server-side luckily. The main benefit is that we got rid of heavy jQuery libraries, simplified our HTML, and set a foundation for htmx to take over.
If built-in validation is not enough (many such cases), you can use any validation library you wish. Some are more convenient for use with Razor Pages, while others are less so.
HTML5 Validation with htmx
For me, this is usually a preferred setup for validation. I like to keep it simple, so built-in client validation is enough for me in 90% of cases.
It is not slightly different from the HTML5 and htmx we explored before.
Client-side
We need to make some minor changes on the client side to make it work. Since htmx is integrated with the HTML5 Validation API and will not send a request for a form if a validatable input is invalid, we can remove the custom trigger hx-trigger="valid-form"
.
The default trigger for the form is submit
so no hx-trigger
attribute needed.
@page
@model Xakpc.RazorHtmx.Validation.Pages.Html5HtmxModel
@{
ViewData["Title"] = "User Registration";
ViewData["Handler"] = Url.Page("Html5Htmx");
ViewContext.ClientValidationEnabled = false;
}
<hgroup>
<h2>User Registration - HTML5 with htmx</h2>
<p>htmx form submit with <mark>HTML5</mark> validation</p>
</hgroup>
<article>
<partial name="_Form" model="@Model.UserRegistration" view-data="ViewData"/>
</article>
We also would not need to prevent form submission anymore, but we would need to reattach events on the form after the swap is done (DOM is settled).
document.addEventListener('DOMContentLoaded', function () {
setupValidation();
document.body.addEventListener('htmx:afterSettle', function (evt) {
if (evt.detail.xhr.status === 422) {
setupValidation();
}
});
});
Server-side Validation
Server-side, nothing has changed. The server does not care about how we validate on the client side.
public class Html5HtmxModel : PageModel
{
[BindProperty]
public UserRegistrationForm UserRegistration { get; set; }
public void OnGet()
{
}
public IActionResult OnPost()
{
if (UserRegistration.PhoneNumber.Length < 4)
{
ModelState.AddModelError("PhoneNumber", "Phone format is wrong");
}
if (!ModelState.IsValid)
{
Response.StatusCode = 422;
return Partial("_FormContent", UserRegistration);
}
// no need to use temp data to pass user object to another page
return Partial("_SuccessUser", UserRegistration);
}
}
As expected we have HTML5 validation with a custom message, submitted by htmx
Hypermedia-Friendly way
One of the somewhat controversial, but clever, ideas behind the hypermedia-friendly way to use htmx is inline scripting: writing your scripts directly within hypermedia, rather than placing them in an external file.
This can be done by using the hx-on
attribute and incorporating functions or even inline code snippets to execute our JavaScript. For example, if we need to set the aria-invalid
attribute based on the input's validity, instead of using input.addEventListener('input', function (event) {...}
, we could use something like this:
<form hx-post="@handler" hx-swap="outerHtml"
hx-on:focusout="event.target.setAttribute('aria-invalid',
event.target.validity.valid && event.target.value !== '' ? 'false' : 'true')">
...
</form>
This would set the attribute when we leave focus from any input on the form.
If we need to run a lot of code, we can put it into a function in the partial or, better yet, on the page where the partial is used.
<form hx-post="@handler" hx-swap="outerHtml"/>
<label asp-for="FirstName"></label>
<input asp-for="FirstName" hx-on:invalid="setCustomValidity(event.target)" />
<span asp-validation-for="FirstName" class="text-danger"></span>
<label asp-for="LastName"></label>
<input asp-for="LastName" hx-on:invalid="setCustomValidity(event.target)" />
<span asp-validation-for="LastName" class="text-danger"></span>
<label asp-for="Email"></label>
<input asp-for="Email" hx-on:invalid="setCustomValidity(event.target)" />
<span asp-validation-for="Email" class="text-danger"></span>
...
</form>
<script>
function setCustomValidity(target) {
let customMessage = '';
if (target.validity.valueMissing && target.dataset.errRequired) {
customMessage = target.dataset.errRequired;
} else if ((target.validity.tooLong || target.validity.tooShort) && target.dataset.errLength) {
customMessage = target.dataset.errLength;
} else if ((target.validity.rangeUnderflow || target.validity.rangeOverflow) && target.dataset.errMinmax) {
customMessage = target.dataset.errMinmax;
}
target.setCustomValidity(customMessage);
}
</script>
Finally, when everything is validated on both the client and server, we would see the success screen.
Conclusion
Integrating htmx with .NET Razor Pages for form validation offers a streamlined and efficient approach to both client-side and server-side validation. By leveraging built-in validation mechanisms, customizing them with tag helpers, and utilizing HTML5 validation APIs, we can create robust and user-friendly forms.
It still has some rough edges and missing parts, but nothing that can't be figured out with a few hours of work (or by using AI), and it provides a lot of room for exploring different ways to handle validation:
Built-in Razor Pages validation (is extremely powerful but somewhat outdated),
More modern variations like aspnet-client-validation,
HTML5 validation with vanilla JS,
or any third-party validation library.
You can choose whichever you prefer, and with minor code adjustments in tag helpers, you can achieve both client-side and server-side validation while fully supporting the DRY principle.
Feel free to explore the full code on GitHub to see these concepts in action and improve your form validation workflows, also feel free to subscribe to the series where I explore more htmx + dotnet crap, or ping me on X if you have any questions.