You have a Blazor SSR form with a handful of [Required] and [EmailAddress] attributes. A user leaves every field blank and clicks submit. Instead of an instant red flash telling them what's wrong, the form does a full round-trip to the server, waits for the response, and only then shows the errors. Meanwhile, MVC apps with unobtrusive validation handled this client-side years ago.

This has been the single most common complaint about Blazor's static server-side rendering mode since it shipped in .NET 8. You either accepted the degraded experience or bolted on an interactive render mode purely for validation — defeating much of the point of choosing SSR in the first place.

.NET 11 Preview 5 fixes this. Drop a DataAnnotationsValidator into an SSR form and client-side validation just works. No render mode changes, no JavaScript interop, no workarounds. The same preview also introduces an async validation pipeline for scenarios like checking username availability against an API, complete with pending and faulted state tracking.

How SSR client-side validation works

The mechanism is straightforward. When Blazor renders a static SSR form containing a DataAnnotationsValidator, it inspects the model's data annotation attributes and emits HTML constraint metadata alongside the rendered inputs. The Blazor JavaScript runtime then enforces those constraints client-side using the browser's Constraint Validation API.

There is nothing to opt into. If your component uses static SSR (no @rendermode directive) and contains a DataAnnotationsValidator, client-side validation is active automatically.

Components/Pages/Register.razor
@page "/register"

<EditForm Model="Model" OnValidSubmit="HandleSubmit">
    <DataAnnotationsValidator />
    <ValidationSummary />

    <div>
        <label>
            Email:
            <InputText @bind-Value="Model!.Email" />
        </label>
        <ValidationMessage For="() => Model!.Email" />
    </div>

    <div>
        <label>
            Display Name:
            <InputText @bind-Value="Model!.DisplayName" />
        </label>
        <ValidationMessage For="() => Model!.DisplayName" />
    </div>

    <button type="submit">Register</button>
</EditForm>

@code {
    private RegistrationModel? Model { get; set; }

    protected override void OnInitialized() => Model ??= new();

    private void HandleSubmit() { /* persist registration */ }
}
Models/RegistrationModel.cs
public class RegistrationModel
{
    [Required]
    [EmailAddress]
    public string? Email { get; set; }

    [Required]
    [StringLength(50, MinimumLength = 3)]
    public string? DisplayName { get; set; }
}

That is the entire setup. No @rendermode directive, no interactive circuit, no WebAssembly download. The user gets instant feedback on blur and on submit.

Supported validation attributes

Not every data annotation attribute can be meaningfully enforced in the browser. The following attributes are supported client-side:

Custom validation attributes — anything inheriting from ValidationAttribute with your own IsValid logic — are not enforced client-side. They continue to run server-side during the form POST. This is the correct trade-off: custom validation often depends on application state, database access, or logic that cannot safely or meaningfully execute in the browser.

Validation timing

The validation timing mirrors what developers expect from modern form UX:

  1. On blur (first touch): A field validates when it loses focus for the first time.
  2. On change (after error): Once a field has shown a validation error — or after the form has been submitted — it re-validates on every keystroke.
  3. On submit: Submitting the form validates every field simultaneously.

This progressive disclosure avoids the hostile pattern of screaming errors at a user before they've finished typing. Standard CSS pseudo-classes reflect the validation state, so you can style inputs without any Blazor-specific CSS:

style.css
/* File: wwwroot/css/app.css */
input:valid {
    border-color: #22c55e;
}

input:invalid {
    border-color: #ef4444;
}

Opting out

There are two escape hatches when you need to bypass client-side validation.

To disable it for an entire form, set the EnableClientValidation parameter to false:

Example.cs
<DataAnnotationsValidator EnableClientValidation="false" />

To allow a specific button to submit without triggering validation — a "Save Draft" button, for example — use the standard HTML formnovalidate attribute:

Example.cs
<button type="submit" formnovalidate>Save Draft</button>

Server-side validation still runs in both cases. Client-side validation is a UX improvement, not a security boundary.

Async validation: checking things the browser cannot

Client-side validation handles format checks, but many real validation rules require server interaction. Is this email already registered? Does the organisation slug conflict with an existing one? Is the promo code valid?

.NET 11 introduces a first-class async validation pipeline built into EditContext. Three new APIs make this work:

There is also a new OnValidationRequestedAsync event on EditContext for running async validation when the form is submitted.

Per-field async validation

The most common pattern is validating a single field as the user types or tabs away. Here is a validator component that checks username availability:

Components/Validators/UsernameValidator.razor
@implements IDisposable
@inject HttpClient Http

@code {
    [CascadingParameter]
    private EditContext? EditContext { get; set; }

    [Parameter, EditorRequired]
    public RegistrationModel Model { get; set; } = default!;

    private ValidationMessageStore? _messages;
    private CancellationTokenSource? _cts;

    protected override void OnInitialized()
    {
        ArgumentNullException.ThrowIfNull(EditContext);
        _messages = new ValidationMessageStore(EditContext);
        EditContext.OnFieldChanged += OnFieldChanged;
    }

    private void OnFieldChanged(object? sender, FieldChangedEventArgs e)
    {
        if (e.FieldIdentifier.FieldName != nameof(RegistrationModel.Username))
            return;

        _cts?.Cancel();
        _cts?.Dispose();
        _cts = new CancellationTokenSource();

        var task = ValidateAsync(e.FieldIdentifier, _cts.Token);
        EditContext!.AddValidationTask(e.FieldIdentifier, task);
    }

    private async Task ValidateAsync(FieldIdentifier field, CancellationToken token)
    {
        _messages!.Clear(field);

        var available = await Http.GetFromJsonAsync<bool>(
            $"api/usernames/available?value={Uri.EscapeDataString(Model.Username)}",
            token);

        if (!available)
            _messages.Add(field, "That username is already taken.");

        EditContext!.NotifyValidationStateChanged();
    }

    public void Dispose()
    {
        _cts?.Cancel();
        _cts?.Dispose();
        if (EditContext is not null)
            EditContext.OnFieldChanged -= OnFieldChanged;
    }
}

The critical detail is the cancellation pattern. When the user types another character, the previous in-flight check is cancelled and a new one starts. AddValidationTask tells the framework to track this task, and a cancelled task is silently discarded without marking the field as faulted.

Showing pending and faulted states

With async validation registered, the UI can respond to in-flight and failed states:

Components/Pages/Register.razor
<InputText @bind-Value="Model!.Username" />
<ValidationMessage For="() => Model!.Username" />

@if (editContext.IsValidationPending(() => Model!.Username))
{
    <span class="text-blue-500" aria-live="polite">Checking availability...</span>
}
else if (editContext.IsValidationFaulted(() => Model!.Username))
{
    <span class="text-amber-500" aria-live="polite">Could not verify availability. Try again.</span>
}

InputBase<TValue> automatically applies pending and faulted CSS classes to the rendered element, so you can also drive styling through CSS alone:

style.css
/* File: wwwroot/css/app.css */
input.pending {
    border-color: #3b82f6;
    background-image: url('spinner.svg');
    background-repeat: no-repeat;
    background-position: right 0.5rem center;
}

input.faulted {
    border-color: #f59e0b;
}

A common pattern is disabling the submit button while any field has outstanding validation:

Example.cs
<button type="submit" disabled="@editContext.IsValidationPending()">
    Register
</button>

Form-level async validation on submit

For validation that should only run at submission time — checking a promo code, for instance — subscribe to OnValidationRequestedAsync:

Components/Validators/PromoCodeValidator.razor
@implements IDisposable
@inject HttpClient Http

@code {
    [CascadingParameter]
    private EditContext? EditContext { get; set; }

    [Parameter, EditorRequired]
    public CheckoutModel Model { get; set; } = default!;

    private ValidationMessageStore? _messages;

    protected override void OnInitialized()
    {
        ArgumentNullException.ThrowIfNull(EditContext);
        _messages = new ValidationMessageStore(EditContext);
        EditContext.OnValidationRequestedAsync += ValidatePromoCodeAsync;
    }

    private async Task ValidatePromoCodeAsync(
        object? sender, ValidationRequestedEventArgs e)
    {
        if (string.IsNullOrWhiteSpace(Model.PromoCode))
            return;

        var field = EditContext!.Field(nameof(Model.PromoCode));
        _messages!.Clear(field);

        var valid = await Http.GetFromJsonAsync<bool>(
            $"api/promo-codes/validate?code={Uri.EscapeDataString(Model.PromoCode)}",
            e.CancellationToken);

        if (!valid)
            _messages.Add(field, "That promo code is not valid or has expired.");

        EditContext!.NotifyValidationStateChanged();
    }

    public void Dispose()
    {
        if (EditContext is not null)
            EditContext.OnValidationRequestedAsync -= ValidatePromoCodeAsync;
    }
}

The CancellationToken on ValidationRequestedEventArgs is important — pass it through to all I/O operations so that navigating away or resubmitting doesn't leave orphaned requests.

ValidateAsync: the async counterpart to Validate

If you build your own submit logic using EditContext directly rather than relying on OnValidSubmit, there is now a ValidateAsync method:

Example.cs
private async Task HandleSubmitAsync()
{
    if (await editContext.ValidateAsync(CancellationToken.None))
    {
        await PersistOrderAsync();
    }
}

ValidateAsync fires both synchronous and async validators, awaits all registered tasks, and returns the overall result. Use this instead of Validate() whenever your form has async validation components.

Cross-render-mode compatibility

The async validation API works identically across all render modes:

This means you can write a single validator component and use it regardless of how the page is rendered.

Common pitfalls

Assuming client-side validation replaces server-side validation. It does not. Client-side validation is trivially bypassed — a user can disable JavaScript or craft a raw POST request. Server-side validation always runs. Treat client-side validation as a UX optimisation, not a security mechanism.

Using custom ValidationAttribute subclasses and expecting client-side enforcement. Only the built-in data annotation attributes listed above are enforced in the browser. If you have a [UniqueEmail] attribute with custom IsValid logic, it will not run client-side. Use the async validation pipeline instead.

Forgetting to call NotifyValidationStateChanged after async validation. After adding or clearing messages in a ValidationMessageStore, the UI will not update until you call EditContext.NotifyValidationStateChanged(). Omitting this call is the most common reason async validation "doesn't work."

Not cancelling superseded async tasks. If you validate on every keystroke without cancelling previous in-flight tasks, you can end up with race conditions where an older response overwrites a newer one. Always cancel the previous CancellationTokenSource before starting a new validation task.

Mixing OnValidSubmit with ValidateAsync. EditForm.OnValidSubmit already awaits async validators internally. If you also call ValidateAsync inside your submit handler, you are validating twice. Use OnValidSubmit for the common case, and only drop down to OnSubmit + ValidateAsync when you need custom control over the validation flow.

Summary