I was recently working on an HTMX and ASP.NET Core demo and wanted to limit client-to-server requests to valid requests. This is a built-in feature for HTMX, as long as you utilize HTML5 validators on your forms and inputs. As many ASP.NET Core practitioners know, the default client-side validation in ASP.NET Core is not HTML5 but a mix of custom data-val attributes and JQuery. The current approach is acceptable, but it makes adopting new frameworks, supporting native HTML capabilities, and dropping extraneous dependencies more difficult.

Could we drop the JQuery validation dependency in favor of HTML5 validation? Luckily, we can thank OSS author Andrew White for his latest NuGet package, FinBuckle.Html5Validation. This package allows us to disable the default behavior of ASP.NET Core MVC and Razor Pages for a more modern approach.

What is HTML5 Validation?

Before HTML5, developers wrote all client-side validation using JavaScript code. While JavaScript code allowed for complex scenarios, many folks realized two things: Client-side validation has many recurring patterns, and, ultimately, it’s not a bullet-proof validation approach. Server-side validation is essential.

This realization helped HTML narrow down the scope of client-side validation, which limits requests sent to the server to client-validated ones. This validation focused on a few user input characteristics:

  • Required: This field must have some value.
  • Type: This field conforms to the expected integer, date, phone number, etc.
  • Constraints: This field has a minimum, maximum, or fits within a specified range.
  • Pattern: This field conforms to a regular expression pattern

These agreed-upon validations reduced the required JavaScript code while allowing clients to create native experiences based on the input types.

Let’s take a look at an example of an HTML5-validated form. The following code can be seen on the MDN site.

<form>
  <fieldset>
    <legend>
      Do you have a driver's license?<span aria-label="required">*</span>
    </legend>
    <input type="radio" required name="driver" id="r1" value="yes" /><label
      for="r1"
      >Yes</label
    >
    <input type="radio" required name="driver" id="r2" value="no" /><label
      for="r2"
      >No</label
    >
  </fieldset>
  <p>
    <label for="n1">How old are you?</label>    
    <input
      type="number"
      min="12"
      max="120"
      step="1"
      id="n1"
      name="age"
      pattern="\d+" />
  </p>
  <p>
    <label for="t1"
      >What's your favorite fruit?<span aria-label="required">*</span></label
    >
    <input
      type="text"
      id="t1"
      name="fruit"
      list="l1"
      required
      pattern="[Bb]anana|[Cc]herry|[Aa]pple|[Ss]trawberry|[Ll]emon|[Oo]range" />
    <datalist id="l1">
      <option>Banana</option>
      <option>Cherry</option>
      <option>Apple</option>
      <option>Strawberry</option>
      <option>Lemon</option>
      <option>Orange</option>
    </datalist>
  </p>
  <p>
    <label for="t2">What's your email address?</label>
    <input type="email" id="t2" name="email" />
  </p>
  <p>
    <label for="t3">Leave a short message</label>
    <textarea id="t3" name="msg" maxlength="140" rows="5"></textarea>
  </p>
  <p>
    <button>Submit</button>
  </p>
</form>

As you’ll notice, the attributes required, pattern, min, max, and type are all used to create a validation experience with no lines of JavaScript. When attempting to submit the form, you’ll notice the client displays a message. You can also highlight inputs based on their validation status.

HTML5 validation on HTML Form

Awesome, right?! With a bit of CSS, we can get some UI indicators. Depending on the client, any input with an appropriate type will have automatic access to the native type value picker, whether it’s a date, a phone number, or a number.

OK, now that we understand what HTML5 validation gets us, how do we get that in ASP.NET Core?

Disable Client-side Validation in ASP.NET Core

This section is for folks who’d prefer to keep their models and views completely separate and write the HTML mostly by hand.

To disable the default client-side validation in ASP.NET Core MVC and Razor Pages, you can use the following lines in your Program file.

builder.Services.AddRazorPages()
    .AddViewOptions(options => {
        options.HtmlHelperOptions.ClientValidationEnabled = false;
    });

This will prevent Razor Pages and MVC from generating the data-val attributes commonly found on inputs in the HTML output.

This approach is fine, but I think the next section is a better option for most folks as it still relies on the model metadata that ASP.NET Core provides.

FinBuckle.HTML5Validation NuGet Package

To move towards HTML5 validation, you must first install the FinBuckle.HTML5Validation NuGet package into your ASP.NET Core application.

Important Note: this package only works with MVC and Razor Pages.

<PackageReference Include="Finbuckle.Html5Validation" Version="1.0.1" />

Once added to your dependencies, you must register the package with your application’s services collection.

using Finbuckle.Html5Validation;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHtml5Validation();

Internally, this method provides the ASP.NET Core pipeline with an IValidationAttributeAdapterProvider.

namespace Microsoft.AspNetCore.Mvc.DataAnnotations
{
  public interface IValidationAttributeAdapterProvider
  {
    IAttributeAdapter? GetAttributeAdapter(
      ValidationAttribute attribute,
      IStringLocalizer? stringLocalizer);
  }
}

This adapter gets passed an attribute and a localizer, at which point you can decide what attributes should be added to the HTML input. Let’s see an implementation for the pattern attribute.

// Copyright Finbuckle LLC, Andrew White, and Contributors.
// Refer to the solution LICENSE file for more information.

// Portions of this file are derived from ASP.NET Core and are subject to the following:
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc.DataAnnotations;
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
using Microsoft.Extensions.Localization;

namespace Finbuckle.Html5Validation.Internal;

public class RegularExpressionAttributeAdapter : AttributeAdapterBase<RegularExpressionAttribute>
{
    public RegularExpressionAttributeAdapter(RegularExpressionAttribute attribute, IStringLocalizer? stringLocalizer)
        : base(attribute, stringLocalizer)
    {
    }

    public override void AddValidation(ClientModelValidationContext context)
    {
        if (context == null)
        {
            throw new ArgumentNullException(nameof(context));
        }
        
        MergeAttribute(context.Attributes, "pattern", Attribute.Pattern);
    }

    /// <inheritdoc />
    public override string GetErrorMessage(ModelValidationContextBase validationContext)
    {
        if (validationContext == null)
        {
            throw new ArgumentNullException(nameof(validationContext));
        }

        return GetErrorMessage(
            validationContext.ModelMetadata,
            validationContext.ModelMetadata.GetDisplayName(),
            Attribute.Pattern);
    }
}

This is a neat part of the ASP.NET Core MVC and Razor Pages pipeline. It’s extensible to the point where folks can replace validation without changing Razor views. Speaking of Razor views, let’s see how we can use this in one.

First, let’s create a Razor PageModel.

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

namespace HtmxFormValidation.Pages;

public class IndexModel(ILogger<IndexModel> logger) : PageModel
{
    [BindProperty, Required]
    public string? Name { get; set; }
    
    [BindProperty, Required]
    public string? Media { get; set; }
    
    [BindProperty, Required]
    public DateTime? Date { get; set; }
    
    public void OnGet()
    {
    }

    public IActionResult OnPost()
    {
        if (ModelState.IsValid)
        {
            logger.LogInformation("{Name} picked {Media}", Name, Media);
        }

        return Partial("_Form", this);
    }
}

I’m using DataAnnotations here; now, what does the view look like? (I’m using HTMX, hence the hx- attributes).

@model IndexModel
@{ string[] options = ["Television", "Radio", "Social Media"]; }

<form method="post" asp-page="Index"
      hx-post="@Url.Page("Index")"
      hx-swap="outerHtml">

    @* alert box *@
    @if (Model.Name is not null)
    {
        <div class="alert alert-info mb-3">
            @Model.Name picked @Model.Media!
        </div>
    }

    <div class="form-group mb-2">
        <label asp-for="Name"></label>
        <input asp-for="Name" type="text"
               class="form-control" 
               title="Must be Khalid or RoboCop">
    </div>

    <div class="form-group mb-2">
        <label asp-for="Date"></label>
        <input asp-for="Date" class="form-control">
    </div>
    
    <!-- radio button list -->
    <div class="mb-3">
        <label>Media Types</label>
        @foreach (var option in options)
        {
            <div class="form-check">
                <input class="form-check-input" type="radio" asp-for="Media" value="@option">
                <label class="form-check-label" asp-for="Media">
                    @option
                </label>
            </div>
        }
    </div>
    <button type="submit" class="btn btn-primary ">
        Submit
    </button>
</form>

Cool! Now we need some CSS to highlight invalid fields with some styling.

input:user-invalid {
  border-color: red;
  background-color: pink;
  box-shadow: 0 0 5px 1px red;
}

You can target several states, such as :valid, :invalid, and :user-invalid. The :user-invalid state only triggers after a user has interacted with an element, whereas you can use the :invalid state to signify a field is invalid immediately.

Let’s run our application and submit the form.

ASP.NET Core HTML form with HTML5 validation

Sweet! All looks like it’s working as expected.

Conclusion

While validation libraries can offer a lot in terms of user experience, the native validation is really good. In fact, it’s good enough to handle most client-side validation and then additionally lean on server-side validation to handle the rest.

I recommend trying this approach and seeing how far you get. You’ll likely be surprised how many dependencies you can drop in favor of native controls and validation.

As always, thanks for reading and sharing my posts. Cheers.