Forms and Validation End-to-End
This lesson assembles the last three modules into the single flow you'll build hundreds of times:
a form that validates on the client, re-validates on the server, submits via AJAX, and displays
errors properly. One rule governs the whole design: never trust the client. Client-side
validation is UX; server-side validation is security. Anyone with dev tools open can delete your
JavaScript, edit your data-val-* attributes, or POST straight to the endpoint with curl.
The model defines the rules β once
Start with the ViewModel; the annotations are the single source of truth for both sides:
public class PermitApplicationViewModel
{
[Required, StringLength(100)]
public string ApplicantName { get; set; }
[Required, Range(1, 5000), Display(Name = "Site area (mΒ²)")]
public int SiteArea { get; set; }
}
The view renders with tag helpers (<label asp-for>, <input asp-for>,
<span asp-validation-for>, plus <div asp-validation-summary="ModelOnly"> for model-level
errors) inside a <form asp-action="Apply"> β and the form tag helper emits the hidden
anti-forgery CSRF field automatically. Because the helpers read the
annotations, the generated inputs carry data-val-required, data-val-length-max, and friends β
the client rules are derived from the server rules, never written twice.
Server side: the only validation that counts
The POST action is Module 2's canonical shape: [HttpPost, ValidateAntiForgeryToken], check
ModelState.IsValid, redisplay on failure, PRG on success. Rules annotations can't express β
"permit number must be unique", "end date after start date" β run in the action or service and
are reported the same way: ModelState.AddModelError("PermitNumber", "Already in use."), keyed to
the field name so the message lands next to the right input (empty key β validation summary).
This is why redisplaying View(model) "just works": helpers read ModelState and re-render the
user's values plus every error.
Client side: the same rules, for free β then AJAX
With jquery.validate + the unobtrusive adapter loaded (Lesson 2), the browser enforces those
data-val-* rules before any request is sent β instant feedback, and Bootstrap error styling
from Lesson 3 lights up the failing fields. That's the whole client story for a plain submit.
For an AJAX submit, intercept and check validity first:
$('#permit-form').on('submit', function (e) {
e.preventDefault();
if (!$(this).valid()) return;
$.post(this.action, $(this).serialize(), handleResponse).fail(showError);
});
serialize() includes the anti-forgery hidden field, so [ValidateAntiForgeryToken] stays
satisfied. The controller returns JSON { success: true, redirectUrl: ... } on success; on
validation failure the cleanest pattern is returning the form as a partial view with
ModelState errors rendered, replacing the form's HTML, and re-running
$.validator.unobtrusive.parse() on it.
The checklist
Every form you ship: annotations on the ViewModel β’ asp-for tag helpers so names and data-val-* line
up β’ anti-forgery token + attribute β’ ModelState.IsValid checked server-side, always β’
errors keyed to fields β’ PRG on success, redisplay on failure. When validation "doesn't work,"
diff your form against this list β the culprit is on it.