12.2 - Razor Tag Helpers
Recap
In 12.1 - Razor Pages the create-configuration form and the board page were full of asp-for, asp-page, asp-route-* and asp-validation-for attributes with the promise that "lecture 12.2 explains what they generate". This is that lecture. Tag helpers are the reason your form fields bind to Input.BoardWidth without you ever typing a name attribute.
By the end of this lecture you should be able to:
- Explain what a tag helper is, how it is enabled, and read the HTML it generates in the browser.
- Build a complete form with
<form>,<input>,<label>,<select>,<textarea>and the validation helpers. - Generate correct links and form targets with
asp-page,asp-page-handlerandasp-route-*. - Bind a list of inputs to a
List<T>property. - Write a small custom tag helper and register it.
Lecture demos: csharp-2026-fall
What a tag helper is
A tag helper is server-side C# that runs when Razor renders an HTML element, and may change that element's attributes, content, or replace it entirely. From the view's side it looks like plain HTML with a few extra asp-… attributes — which is exactly the point: the markup stays readable, designers can edit it, and the IDE colours the helper attributes so you see where the server is involved.
They are enabled per folder in _ViewImports.cshtml; the template already has the built-in set:
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
The single most useful debugging habit: view source (or the Elements tab) in the browser and compare what you wrote with what arrived. Every example below shows both.
<form>
Attributes: asp-page, asp-page-handler, asp-route-<name>, asp-area, asp-fragment, asp-antiforgery.
<form method="post" asp-page="./Play" asp-page-handler="Move"
asp-route-id="@Model.State.Id" asp-route-row="@r" asp-route-col="@c">
<button type="submit"> </button>
</form>
<form method="post" action="/Games/Play/3f2a5c1e-...?row=1&col=2&handler=Move">
<button type="submit"> </button>
<input name="__RequestVerificationToken" type="hidden" value="CfDJ8..." />
</form>
asp-page— the target page, relative (./Play) or absolute (/Games/Play). Omit it to post back to the current page — the usual case.asp-page-handler— becomes?handler=Move, selectingOnPostMove. Can also sit on a<button>so that one form has several submit buttons with different handlers.asp-route-<name>— a value for the target's route. If the page's@pagetemplate has a matching segment ({id:guid}) it goes into the path; otherwise into the query string.- The hidden
__RequestVerificationTokenis the antiforgery token. Razor Pages validates it on every POST; it stops another site from tricking a logged-in browser into submitting your form. You get it for free whenevermethod="post"and the tag helper is active; a400 Bad Requeston POST means the token was missing — usually a hand-written<form>in a partial that lacks_ViewImports.asp-antiforgery="false"turns it off; do not.
<input>
asp-for="Expression" is the workhorse. From the model expression it derives id, name, value, the HTML type, and the data-val-* attributes that client-side validation reads.
<input asp-for="Input.BoardWidth" class="form-control" />
<input class="form-control" type="number" id="Input_BoardWidth" name="Input.BoardWidth" value="3"
data-val="true" data-val-required="The BoardWidth field is required."
data-val-range="The field BoardWidth must be between 3 and 20."
data-val-range-min="3" data-val-range-max="20" />
name="Input.BoardWidth" is what the model binder matches against [BindProperty] ConfigInput Input — that is the whole link between the form and your C#. The type is inferred from the property's .NET type, or overridden by an annotation:
| .NET type | type= | Annotation | type= | |
|---|---|---|---|---|
bool | checkbox | [EmailAddress] | email | |
string | text | [Url] | url | |
int, double, decimal | number | [Phone] | tel | |
DateTime | datetime-local | [DataType(DataType.Password)] | password | |
DateOnly | date | [DataType(DataType.Date)] | date | |
Guid | text | [HiddenInput] | hidden |
You may still write type="range" or any other attribute by hand — explicit attributes win over generated ones. asp-format="{0:N2}" formats the value; <input asp-for="Id" type="hidden" /> is how a Guid travels with a form without being visible.
<label>
<label asp-for="Input.BoardWidth" class="form-label"></label>
<label class="form-label" for="Input_BoardWidth">BoardWidth</label>
The text is the property name unless you write your own content inside the tag or decorate the property with [Display(Name = "Board width")]. Do the latter — the same text is then used in validation messages.
<textarea>
Same asp-for contract as <input>: id, name, current value as content, validation attributes. Use it for anything longer than a line — a description on a configuration, a note on a saved game:
<textarea asp-for="Input.Description" class="form-control" rows="3"></textarea>
<select> and <option>
asp-for names the bound property; asp-items supplies the options as IEnumerable<SelectListItem>. The classic case for us: pick a configuration when starting a game.
using Microsoft.AspNetCore.Mvc.Rendering;
public class CreateModel(IConfigRepository configs, IGameRepository games) : PageModel
{
[BindProperty, Required]
public string ConfigName { get; set; } = "";
[BindProperty]
public EGamePiece StartingPiece { get; set; } = EGamePiece.X;
public SelectList ConfigOptions { get; private set; } = default!;
public void OnGet()
{
var all = configs.List();
ConfigOptions = new SelectList(all, nameof(GameConfiguration.Name), nameof(GameConfiguration.Name));
}
public IActionResult OnPost()
{
var config = configs.List().FirstOrDefault(c => c.Name == ConfigName);
if (config == null)
{
ModelState.AddModelError(nameof(ConfigName), "Unknown configuration.");
}
if (!ModelState.IsValid || config == null)
{
OnGet(); // rebuild the options — they are not posted back
return Page();
}
var state = new GameState(config) { NextMoveBy = StartingPiece }; // however your engine starts a game
games.Save(state);
return RedirectToPage("./Play", new { id = state.Id });
}
}
<div class="mb-3">
<label asp-for="ConfigName">Configuration</label>
<select asp-for="ConfigName" asp-items="Model.ConfigOptions" class="form-select">
<option value="">-- choose --</option>
</select>
<span asp-validation-for="ConfigName" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="StartingPiece">Who starts</label>
<select asp-for="StartingPiece" asp-items="Html.GetEnumSelectList<EGamePiece>()" class="form-select"></select>
</div>
<select class="form-select" id="ConfigName" name="ConfigName">
<option value="">-- choose --</option>
<option value="Classic">Classic</option>
<option selected="selected" value="Gomoku">Gomoku</option>
</select>
SelectList(items, valueField, textField) builds the options from any collection; the option whose value equals the bound property is marked selected. Static <option> children you write yourself are kept (the placeholder above). Html.GetEnumSelectList<T>() builds options from an enum — it lists every member, Empty included, so filter or build your own SelectList when that matters.
Only the selected value is posted. The options list is not part of the form data, so a PageModel must rebuild ConfigOptions before return Page() after a failed validation — otherwise the re-rendered <select> is empty.
Option groups
Give items a SelectListGroup and the helper emits <optgroup>:
var small = new SelectListGroup { Name = "Small boards" };
var large = new SelectListGroup { Name = "Large boards" };
ConfigItems = configs.List()
.Select(c => new SelectListItem
{
Value = c.Name,
Text = $"{c.Name} ({c.BoardWidth}×{c.BoardHeight}, win {c.WinLength})",
Group = c.BoardWidth * c.BoardHeight <= 25 ? small : large,
})
.ToList();
Multi-select
If the asp-for property is a collection, the helper adds multiple and the binder fills the list from all selected values — for example letting a spectator pick several games to watch:
[BindProperty]
public List<Guid> SelectedGameIds { get; set; } = [];
public SelectList GameOptions { get; private set; } = default!;
<select asp-for="SelectedGameIds" asp-items="Model.GameOptions" class="form-select" size="6"></select>
Validation helpers
Two helpers display what ModelState collected, plus one partial that adds client-side checking.
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
...
<input asp-for="Input.WinLength" class="form-control" />
<span asp-validation-for="Input.WinLength" class="text-danger"></span>
...
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}
asp-validation-for— a<span>showing the error for one property, empty when valid. Put one under every input.asp-validation-summary—All(property and model errors),ModelOnly(only errors added with an empty key, e.g.ModelState.AddModelError("", "Board is full.")), orNone. UseModelOnlywhen every field has its own span, otherwise messages appear twice._ValidationScriptsPartial— the template's partial that loads jQuery Validation + Unobtrusive; it reads thedata-val-*attributes and blocks the submit in the browser until the fields pass. Put it in theScriptssection so it lands after jQuery at the end of_Layout. It never replaces the server check —ModelState.IsValidruns regardless.
<span class="text-danger field-validation-error" data-valmsg-for="Input.WinLength" data-valmsg-replace="true">
Win length cannot exceed the board size.
</span>
<a>
asp-page, asp-page-handler, asp-route-<name>, asp-fragment — the same routing attributes as the form, producing href:
<a asp-page="/Games/Play" asp-route-id="@game.Id">Continue</a>
<a asp-page="./Index" asp-route-filter="finished">Finished games</a>
<a asp-page="/Configs/Edit" asp-route-id="@config.Id" asp-fragment="rules">Rules</a>
<a href="/Games/Play/3f2a5c1e-...">Continue</a>
<a href="/Games?filter=finished">Finished games</a>
<a href="/Configs/Edit/9b1d...#rules">Rules</a>
Prefer this over a hand-written href="/Games/Play/@game.Id": the helper knows the route template, encodes values, and keeps working when you rename or move the page. A plain href="~/css/site.css" is also rewritten — ~/ means the application root.
Collection binding
A form that edits several rows at once — say a "presets" page where you set up a few board sizes and win lengths in one go — binds to a List<ConfigInput>. The helper generates indexed names, and the binder rebuilds the list from them.
public class PresetsModel(IConfigRepository configs) : PageModel
{
[BindProperty]
public List<ConfigInput> Presets { get; set; } = [];
public void OnGet()
{
Presets =
[
new() { Name = "Classic", BoardWidth = 3, BoardHeight = 3, WinLength = 3 },
new() { Name = "Gomoku", BoardWidth = 15, BoardHeight = 15, WinLength = 5 },
];
}
public IActionResult OnPost()
{
if (!ModelState.IsValid) return Page();
foreach (var preset in Presets)
{
configs.Save(preset.ToConfiguration());
}
return RedirectToPage("./Index");
}
}
<form method="post">
<table class="table">
<thead><tr><th>Name</th><th>Width</th><th>Height</th><th>Win length</th></tr></thead>
<tbody>
@for (var i = 0; i < Model.Presets.Count; i++)
{
<tr>
<td><input asp-for="Presets[i].Name" class="form-control" /></td>
<td><input asp-for="Presets[i].BoardWidth" class="form-control" /></td>
<td><input asp-for="Presets[i].BoardHeight" class="form-control" /></td>
<td><input asp-for="Presets[i].WinLength" class="form-control" /></td>
</tr>
}
</tbody>
</table>
<button type="submit" class="btn btn-primary">Save all</button>
</form>
<input type="text" id="Presets_0__Name" name="Presets[0].Name" value="Classic" />
<input type="number" id="Presets_0__BoardWidth" name="Presets[0].BoardWidth" value="3" />
It has to be a @for with an index — @foreach gives the helper no way to number the names. Validation attributes on ConfigInput apply to each row, and asp-validation-for="Presets[i].WinLength" works per row too.
<environment>
Render a block only in some environments (Development, Staging, Production, or your own name from ASPNETCORE_ENVIRONMENT):
<environment include="Development">
<link rel="stylesheet" href="~/css/site.css" />
</environment>
<environment exclude="Development">
<link rel="stylesheet" href="~/css/site.min.css" asp-append-version="true" />
</environment>
<script> and <link>
asp-append-version="true" appends a hash of the file's content as ?v=..., so browsers cache aggressively yet always fetch a changed file — cache busting. The template does it for site.css and site.js; do the same for your board stylesheet.
<link rel="stylesheet" href="~/css/board.css" asp-append-version="true" />
<script src="~/js/site.js" asp-append-version="true"></script>
<link rel="stylesheet" href="/css/board.css?v=Kz3r0Q8mV...">
The asp-fallback-* attributes (load from a CDN, fall back to a local copy) exist too; the current template does not use them and neither should A6.
<partial>
<partial name="_Board" model="Model.State" />
<partial name="_Status" for="State" />
name— the partial to render, searched in the current folder, thenShared/.model— any expression; becomes the partial'sModel.for— a model expression relative to the page'sModel(sofor="State"isModel.State), which additionally keeps the naming prefix correct if the partial containsasp-forinputs.view-data— an extraViewDataDictionarywhen the partial needs a flag the model does not carry.
Always this element. @Html.Partial(...) is obsolete and synchronous; @await Html.PartialAsync(...) works but is longer and reads worse.
A custom tag helper: <game-cell>
The board page from lecture 12.1 repeats the same <td> markup for every cell. A tag helper packages it: a class deriving from TagHelper, public properties for the attributes (PascalCase in C#, kebab-case in the markup), and one Process method.
using GameEngine;
using Microsoft.AspNetCore.Razor.TagHelpers;
namespace WebApp.TagHelpers;
// <game-cell piece="..." row="..." col="..." highlight="...">optional content</game-cell>
public class GameCellTagHelper : TagHelper
{
public EGamePiece Piece { get; set; }
public int Row { get; set; }
public int Col { get; set; }
public bool Highlight { get; set; }
public override void Process(TagHelperContext context, TagHelperOutput output)
{
output.TagName = "td";
output.TagMode = TagMode.StartTagAndEndTag;
output.Attributes.SetAttribute("class", Highlight ? "cell cell-last" : "cell");
output.Attributes.SetAttribute("data-row", Row);
output.Attributes.SetAttribute("data-col", Col);
if (Piece != EGamePiece.Empty)
{
// occupied: replace whatever was inside with the symbol
output.Content.SetContent(Piece == EGamePiece.X ? "X" : "O");
}
// empty: the child content (the move form) is rendered as written
}
}
Register it once in Pages/_ViewImports.cshtml — the second argument is the assembly name:
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, WebApp
Use it in Play.cshtml. For non-string properties the attribute value is a C# expression, so no @ is needed:
@for (var r = 0; r < board.Length; r++)
{
<tr>
@for (var c = 0; c < board[r].Length; c++)
{
<game-cell piece="board[r][c]" row="r" col="c" highlight="isLastMove(r, c)">
<form method="post" asp-page-handler="Move"
asp-route-id="@Model.State.Id" asp-route-row="@r" asp-route-col="@c">
<button type="submit" class="cell-button"> </button>
</form>
</game-cell>
}
</tr>
}
<td class="cell cell-last" data-row="1" data-col="2">O</td>
<td class="cell" data-row="1" data-col="3"><form method="post" action="..."> ... </form></td>
By default the element name is the class name minus TagHelper, in kebab-case; [HtmlTargetElement("td", Attributes = "piece")] would instead attach the helper to any <td> carrying a piece attribute. Keep helpers small and free of business logic — whether a move is legal stays in GameBrain; the helper only decides how a cell looks.
HTML helpers
Before tag helpers there were HTML helpers — C# methods called from the view, such as @Html.DisplayNameFor(m => m.Name), @Html.EditorFor(m => m.Name) or @Html.ActionLink(...). They generate whole elements from C#, which makes the view harder to read, and almost everything they do now has a tag-helper equivalent; the exceptions you will actually meet are Html.GetEnumSelectList<T>() above and the @Html.DisplayNameFor lines that scaffolding still emits in list pages. Treat them as legacy in A6. The next course covers them properly: Web Applications with C# — Tag Helpers.
Self preparation QA
Be prepared to explain topics like these:
- What does
asp-foron an<input>generate, and why does that matter for model binding? —id,name,value,typeanddata-val-*attributes from the model expression; thename(Input.BoardWidth) is what the binder matches to the[BindProperty]property. - Where does an
asp-route-idvalue end up in the URL? — In the path if the target page's@pagetemplate has an{id}segment, otherwise in the query string. - What is the antiforgery token, and what does a
400after a POST usually mean? — A hidden field plus cookie pair that proves the form came from your site; a400means the token was missing or invalid — typically a hand-written form or a partial without tag helpers enabled. - Why must a
PageModelrebuild theSelectListbeforereturn Page()after failed validation? — Only the selected value is posted; the option items are not, so without rebuilding, the re-rendered<select>has no options. - How do you bind a list of inputs, and why does
@foreachnot work? — Loop with@forand useasp-for="Items[i].Prop"; the helper emitsItems[0].Propnames that the binder turns back into a list, and@foreachhas no index to put in the name. - What is the difference between
asp-validation-forandasp-validation-summary="ModelOnly"? — The span shows one property's error; the summary withModelOnlyshows only errors added with an empty key, so field errors are not duplicated. - What does
asp-append-versiondo? — Appends a content hash as?v=to the URL of a static file so browsers can cache it forever and still pick up a new version the moment the file changes. - How do you create and register a custom tag helper? — Derive from
TagHelper, expose attributes as public properties, overrideProcessto setTagName, attributes and content, then@addTagHelper *, WebAppin_ViewImports.cshtml.