6.7 KiB
Form Submission & Model Binding
Handle validated form submissions with strongly-typed models — no manual dictionary access required.
Basic Submit (No Model Binding)
The simplest approach uses an onSuccess callback with direct HttpContext access:
app.MapFormValidation<ContactFormValidator>("/api/forms/contact",
onSuccess: async ctx =>
{
var form = ctx.Request.Form;
var name = form["name"].ToString();
var email = form["email"].ToString();
// Save to database, send email, etc.
await SaveContactAsync(name, email);
});
The onSuccess callback fires only after all validation rules pass. If validation fails, the callback is never invoked.
Strongly-Typed Model Binding
Define a POCO model and let FormModelBinder handle the mapping automatically:
Step 1: Create a Model
public class ContactFormModel
{
public string Name { get; set; } = "";
public string Email { get; set; } = "";
public string Password { get; set; } = "";
public int? Age { get; set; }
public DateOnly? Birthdate { get; set; }
public TimeOnly? Preferredtime { get; set; }
public DateTime? Appointment { get; set; }
public string Confirmation { get; set; } = "";
}
Step 2: Use the Typed Overload
app.MapFormValidation<ContactFormValidator, ContactFormModel>("/api/forms/contact",
onSuccess: async model =>
{
Console.WriteLine($"Name: {model.Name}");
Console.WriteLine($"Email: {model.Email}");
Console.WriteLine($"Age: {model.Age}");
Console.WriteLine($"Birth Date: {model.Birthdate}");
Console.WriteLine($"Preferred Time: {model.Preferredtime}");
Console.WriteLine($"Appointment: {model.Appointment}");
await SaveToDbAsync(model);
});
Step 3: Customize the Success Message (Optional)
app.MapFormValidation<ContactFormValidator, ContactFormModel>("/api/forms/contact",
onSuccess: async model => { /* ... */ },
successMessage: "Thank you! Your form has been submitted.");
FormModelBinder
FormModelBinder.Bind<T>() maps form fields to model properties using reflection with these rules:
- Case-insensitive matching — form field
namematches propertyName - Automatic type conversion for all common types
- Nullable support — empty values become
nullfor nullable types
Supported Types
| Type | Format Expected |
|---|---|
string |
Any text |
int, long |
Integer text |
float, double, decimal |
Numeric text (invariant culture) |
bool |
true/false, on, 1 |
DateTime |
Parseable datetime (e.g. 2025-12-25T10:30) |
DateOnly |
Parseable date (e.g. 2025-12-25) |
TimeOnly |
Parseable time (e.g. 14:30) |
Guid |
Standard GUID format |
Enum |
Case-insensitive enum member name |
All types support their Nullable<T> equivalents (int?, DateTime?, etc.).
API Reference
MapFormValidation (without model binding)
public static RouteGroupBuilder MapFormValidation<TValidator>(
this IEndpointRouteBuilder endpoints,
string basePath,
string successMessage = "✓ Form submitted successfully!",
Func<HttpContext, Task>? onSuccess = null)
where TValidator : FormValidator, new();
MapFormValidation (with model binding)
public static RouteGroupBuilder MapFormValidation<TValidator, TModel>(
this IEndpointRouteBuilder endpoints,
string basePath,
Func<TModel, Task> onSuccess,
string successMessage = "✓ Form submitted successfully!")
where TValidator : FormValidator, new()
where TModel : new();
Parameters
| Parameter | Type | Description |
|---|---|---|
basePath |
string |
URL prefix (e.g. /api/forms/contact) |
successMessage |
string |
HTML text shown on successful submission |
onSuccess |
Func<HttpContext, Task>? or Func<TModel, Task> |
Callback invoked after validation passes |
Return Value
Returns a RouteGroupBuilder for further endpoint configuration if needed.
Registered Endpoints
Both overloads register the same endpoint structure:
| Method | Path | Purpose |
|---|---|---|
POST |
{basePath}/validate |
Per-field validation (called on input blur) |
POST |
{basePath}/submit |
Full form validation and submission |
Both endpoints have .DisableAntiforgery() applied since htmx sends raw form data.
Response Format
Validation Error Response
When validation fails, the /submit endpoint returns HTML with OOB (out-of-band) swap fragments:
<p data-field-error="email" hx-swap-oob="outerHTML:[data-field-error='email']"
class="text-[0.8rem] font-medium text-destructive">
Please enter a valid email address.
</p>
<p data-field-error="name" hx-swap-oob="outerHTML:[data-field-error='name']"
class="text-[0.8rem] font-medium text-destructive hidden"></p>
<!-- ... one fragment per field ... -->
<div id="form-result" class="hidden"></div>
htmx processes each OOB fragment, updating every field's error element in a single response.
Success Response
<p data-field-error="email" hx-swap-oob="outerHTML:[data-field-error='email']"
class="text-[0.8rem] font-medium text-destructive hidden"></p>
<!-- ... clears all error elements ... -->
<div id="form-result">
<div data-testid="success-message"
class="rounded-md border border-green-500/30 bg-green-500/10 p-3 text-sm text-green-600 dark:text-green-400">
✓ Form submitted successfully!
</div>
</div>
Complete Example
// ContactFormValidator.cs
using Enciphered.Blazor.UIComponents.Validation;
public class ContactFormValidator : FormValidator
{
public ContactFormValidator()
{
RuleFor("name", required: true, minLength: 2);
RuleFor("email", required: true,
pattern: @".+@.+\..+",
message: "Please enter a valid email address.");
}
}
// ContactFormModel.cs
public class ContactFormModel
{
public string Name { get; set; } = "";
public string Email { get; set; } = "";
}
// Program.cs
app.MapFormValidation<ContactFormValidator, ContactFormModel>("/api/forms/contact",
onSuccess: async model =>
{
await db.Contacts.AddAsync(new Contact
{
Name = model.Name,
Email = model.Email
});
await db.SaveChangesAsync();
});
@* ContactForm.razor *@
@page "/contact"
<HtmxForm Endpoint="/api/forms/contact">
<FormField Label="Name" For="name">
<TextInput Id="name" Name="name" />
</FormField>
<FormField Label="Email" For="email">
<TextInput Id="email" Name="email" Type="email" />
</FormField>
<Button Type="submit">Send</Button>
</HtmxForm>