ASP.NET Core Tag Helpers: Built-in Tag Helpers and Writing Your Own
A tag helper is a C# class, bound at compile time, that processes an HTML tag in Razor on the server; a single asp-for produces id, name, label, input type and data-val attributes. We look at the real output of the built-in anchor, form, input, select, environment and cache tag helpers, the difference from HTML Helpers and the build break from a missing @ on an enum attribute, then write our own <status-badge> and a permission-aware <secure-link>.
Version note: The code in this post was built and run with .NET 10 (SDK 10.0.402) and Microsoft.EntityFrameworkCore.Sqlite 10.0.14.
Guess First
We wrote our own tag helper that shows a book's status as a colored badge. Its Status property is the BookStatus enum. In the admin list you try two spellings: <status-badge status="book.Status" /> and <status-badge status="@book.Status" />. Do both compile? And this one: we wrote a <secure-link> tag helper for a menu link visible only to administrators. When a user who is not signed in opens the page, what is left of that link in the HTML (HyperText Markup Language)?
What Does a Tag Helper Do?
A tag helper is a C# class that processes an HTML tag in a Razor view on the server side. You write <a asp-controller="Books" asp-action="Details" asp-route-id="2"> in the view; the browser receives <a href="/Books/Details/2">. Attributes starting with asp- never reach the HTML; AnchorTagHelper consumes them. For that to happen, the view has to see the @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers line in _ViewImports.cshtml; in the 2nd post we saw links left without an href when this line was missing.
Tag helpers are bound at compile time. The Razor compiler resolves which tag helpers each tag matches during the build and generates the C# code that binds attribute values to properties. So a typo such as asp-for="Titel" is caught not at runtime but in dotnet build. In this post we look at what the built-in tag helpers actually produce; then we write two of our own.
Anchor and Form Tag Helpers
The anchor tag helper's attributes speak the language of routing: asp-area, asp-controller, asp-action, asp-route-{name} (each one a route value), asp-fragment, and asp-route for a route name directly. Values you do not specify are taken from the current request; that was the "ambient value" behavior from the 2nd post. An asp-route-* value with no counterpart in the route template goes into the query string.
The form tag helper does two jobs. The opening tag of the new book form in the admin area:
<form asp-action="Create" method="post">
The first and last lines of the real output:
<form method="post" action="/Admin/Books/Create">
...
<input name="__RequestVerificationToken" type="hidden" value="CfDJ8EINq_wm4TFCv7guzy6SHCfzzkOYyIR1Sp4mE3EoNBQjA6BL_QWgAjk2pVGHhe1h8Mj8mVLucy3EHMoeOEqCK4WtHPfzHKpX5f4q1OLbcHOk5kEauCfipliN1vOMCdB9VzD5UKjhXMR6CFDL28eJteU" /></form>
The action URL (Uniform Resource Locator) was generated from routing, and an antiforgery token was added, without our asking, to every form with method="post". What this token protects, how validation is switched on and how to send it with requests made by JavaScript's fetch is the subject of the next post.
Input, Label, Select and Validation Tag Helpers
The real work of the form is done with asp-for. The form is bound to the BookFormViewModel returned by the admin area's Create action:
public class BookFormViewModel
{
public int? Id { get; set; }
[Required]
[StringLength(120)]
public string Title { get; set; } = "";
[Required]
[Display(Name = "ISBN")]
[RegularExpression(@"^\d{13}$", ErrorMessage = "ISBN must be 13 digits.")]
public string Isbn { get; set; } = "";
[Range(1, 10000)]
public decimal Price { get; set; }
[Range(0, 1000)]
public int Stock { get; set; }
[Display(Name = "Published on")]
[DataType(DataType.Date)]
public DateOnly PublishedOn { get; set; } = DateOnly.FromDateTime(DateTime.Today);
public Genre Genre { get; set; }
[Display(Name = "Author")]
public int AuthorId { get; set; }
public IEnumerable<SelectListItem> Authors { get; set; } = [];
}
A few fields from the view:
<div class="mb-2">
<label asp-for="Isbn"></label>
<input asp-for="Isbn" class="form-control" />
<span asp-validation-for="Isbn" class="text-danger"></span>
</div>
<div class="mb-2">
<label asp-for="Stock"></label>
<input asp-for="Stock" class="form-control" />
</div>
<div class="mb-2">
<label asp-for="PublishedOn"></label>
<input asp-for="PublishedOn" class="form-control" />
</div>
<div class="mb-2">
<label asp-for="Genre"></label>
<select asp-for="Genre" asp-items="Html.GetEnumSelectList<Genre>()" class="form-select"></select>
</div>
<div class="mb-2">
<label asp-for="AuthorId"></label>
<select asp-for="AuthorId" asp-items="Model.Authors" class="form-select">
<option value="">-- choose --</option>
</select>
</div>
And the real HTML they produce:
<label for="Isbn">ISBN</label>
<input class="form-control" type="text" data-val="true" data-val-regex="ISBN must be 13 digits." data-val-regex-pattern="^\d{13}$" data-val-required="The ISBN field is required." id="Isbn" name="Isbn" value="" />
<span class="text-danger field-validation-valid" data-valmsg-for="Isbn" data-valmsg-replace="true"></span>
<label for="Stock">Stock</label>
<input class="form-control" type="number" data-val="true" data-val-range="The field Stock must be between 0 and 1000." data-val-range-max="1000" data-val-range-min="0" data-val-required="The Stock field is required." id="Stock" name="Stock" value="0" /><input name="__Invariant" type="hidden" value="Stock" />
<label for="PublishedOn">Published on</label>
<input class="form-control" type="date" data-val="true" data-val-required="The Published on field is required." id="PublishedOn" name="PublishedOn" value="2026-11-08" /><input name="__Invariant" type="hidden" value="PublishedOn" />
<label for="Genre">Genre</label>
<select class="form-select" data-val="true" data-val-required="The Genre field is required." id="Genre" name="Genre"><option selected="selected" value="0">Fiction</option>
<option value="1">Science</option>
<option value="2">History</option>
<option value="3">Programming</option>
</select>
<label for="AuthorId">Author</label>
<select class="form-select" data-val="true" data-val-required="The Author field is required." id="AuthorId" name="AuthorId">
<option value="">-- choose --</option>
<option value="1">Ada Lane</option>
<option value="2">Alan Brooks</option>
<option value="3">Grace Hill</option>
</select>
A single asp-for produced a lot here. id and name match the name model binding will read back on POST. The label took its text from [Display(Name = "ISBN")]. The DataAnnotations attributes were turned into data-val-* attributes for the client-side validation library to read; even the messages are ready. The type was inferred from the property's type: text for string, number for int, date for a DateOnly marked with [DataType(DataType.Date)]. Without that attribute, the DateOnly field rendered as type="text" with value="11/8/2026"; no date picker, and a value that depends on the server's culture. decimal Price comes as type="text" with data-val-number; we discuss the "1,5" vs "1.5" problem this creates under a Turkish culture in the 7th post.
A detail that is easy to miss: <input name="__Invariant" type="hidden" value="Stock" /> was added next to the number and date inputs. Browsers always send the value of these input types in a culture-independent format (1234.5, 2026-11-08), and this hidden field tells model binding "parse this field with the invariant culture". Even if the server runs under a Turkish culture, 2026-11-08 is read correctly.
On the select side, asp-items expects an IEnumerable<SelectListItem>. We brought the authors from the controller through the ViewModel; for the enum, Html.GetEnumSelectList<Genre>() turns the enum values (with their [Display] names, if any) into a list. The <option value=""> you write by hand is kept and stays at the top of the list. Of the two validation tag helpers, asp-validation-for prints the message for a single field and asp-validation-summary the messages for several errors; we will see both at work with a failing POST in the 7th post.
Environment, Script, Image and Cache Tag Helpers
The template's layout has the line <link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />. Its real output is <link rel="stylesheet" href="/css/site.css?v=j6fhJSuuyLpOSLuPJU0TsDV0iNjor5S3rDnvxJrt4bg" />. The v value is a hash of the file content; when the file changes the URL changes too and the browser cache is invalidated on its own. The same works for <img> and <script>:
<img src="~/favicon.ico" asp-append-version="true" alt="" />
<environment include="Development"><p class="text-warning">Development build</p></environment>
<img src="/favicon.ico?v=Jtxf9L-5ITKRc1gIRl4VbUpGkRNfOBXjYTdhJD4facM" alt="" />
<p class="text-warning">Development build</p>
~/ resolves to the application root. The <environment> tag itself is not written to the output; its content is rendered only in the specified environment. When I started the application with ASPNETCORE_ENVIRONMENT=Production, the text "Development build" appeared 0 times on the same page. Since .NET 9, static assets are served fingerprinted through MapStaticAssets; how that relates to asp-append-version is in the 9th post of this series.
The <cache> tag helper keeps the rendered HTML inside it in server memory. In the previous post we saw that the cart summary in the layout adds one query to every page. I wrapped it:
<cache vary-by-cookie="cart" expires-after="@TimeSpan.FromSeconds(30)">
<vc:cart-summary />
</cache>
I sent three requests with the same cart=1-4-4 cookie, then one with a different cookie, and read the query counts from the Entity Framework Core (EF Core) command log:
req 1 queries: 3
req 2 queries: 2
req 3 queries: 2
other cookie queries: 3
On the second and third requests the view component did not run at all; the HTML came from memory. vary-by-cookie added the cookie's value to the cache key, so a different cart became a different entry. There is an important limit: the cache tag helper only skips what is inside it. The queries the action runs in the controller still run; so it pays off most when it wraps a view component that fetches its own data. <distributed-cache> does the same job through IDistributedCache (a shared cache such as Redis) and is shared across several servers; its name attribute is required.
How They Differ from HTML Helpers, and Switching Tag Helpers Off
In code bases coming from ASP.NET MVC (Model-View-Controller) 5, HTML Helpers do the same job. I wrote both side by side in the same view:
@Html.TextBoxFor(m => m.Title, new { @class = "form-control" })
@Html.ActionLink("Back to list", "Index")
<a asp-action="Index">Back to list</a>
<!a asp-action="Index">Back to list</!a>
<input class="form-control" data-val="true" data-val-length="The field Title must be a string with a maximum length of 120." data-val-length-max="120" data-val-required="The Title field is required." id="Title" maxlength="120" name="Title" type="text" value="" />
<a href="/Admin/Books">Back to list</a>
<a href="/Admin/Books">Back to list</a>
<a asp-action="Index">Back to list</a>
The output is the same; the difference is in how you write it. An HTML Helper is a C# method call: you need anonymous objects like new { @class = ... } for a CSS (Cascading Style Sheets) class and underscore tricks for data-* attributes, and a designer cannot read the line as HTML. A tag helper is HTML itself; the editor highlights it as HTML, attributes stay as they are, and only the asp-* part is processed on the server. Use tag helpers in new code; in views migrated from MVC 5 you do not have to replace HTML Helpers right away, the two live happily in the same view.
The last line is an escape hatch: a ! before the tag name switches tag helpers off for that tag, and asp-action goes into the HTML as is. To switch off a specific tag helper in a folder or a single view, use @removeTagHelper; if you want tag helpers to work only with a specific prefix (like <th:a>), use @tagHelperPrefix.
Writing Your Own Tag Helper
The first one is simple: <status-badge>, which turns a book's status into a Bootstrap badge.
using Bookstore.Web.Models;
using Microsoft.AspNetCore.Razor.TagHelpers;
namespace Bookstore.Web.TagHelpers;
[HtmlTargetElement("status-badge", TagStructure = TagStructure.WithoutEndTag)]
public class StatusBadgeTagHelper : TagHelper
{
public BookStatus Status { get; set; }
public override void Process(TagHelperContext context, TagHelperOutput output)
{
(string css, string text) = Status switch
{
BookStatus.Published => ("bg-success", "Published"),
BookStatus.OutOfPrint => ("bg-warning text-dark", "Out of print"),
_ => ("bg-secondary", "Draft"),
};
output.TagName = "span";
output.TagMode = TagMode.StartTagAndEndTag;
output.Attributes.SetAttribute("class", $"badge {css}");
output.Content.SetContent(text);
}
}
[HtmlTargetElement] says which tag to match; without it, the tag is derived from the class name (StatusBadge → status-badge). Public properties bind to kebab-case attributes. Inside Process we modify TagHelperOutput: we set the tag name to span, add a class and write the content. SetContent HTML-encodes the text; there is SetHtmlContent if you want to emit HTML, but the Html.Raw warning from the 3rd post applies here as well.
The first part of the guess. When I wrote <status-badge status="book.Status" /> in the admin list, the build broke:
Areas/Admin/Views/Books/Index.cshtml(12,39): error CS0117: 'BookStatus' does not contain a definition for 'book'
Razor reads an attribute value given to an enum-typed property as a member of the enum: write status="Published" and it becomes BookStatus.Published. That is why book.Status turned into BookStatus.book.Status. To pass a variable you need the @: <status-badge status="@book.Status" />. For types other than string (int, bool, your own classes) the value is already read as a C# expression, while for string properties it is plain text. Three lines from the real output of the admin list after the fix:
<td><span class="badge bg-success">Published</span></td>
<td><span class="badge bg-warning text-dark">Out of print</span></td>
<td><span class="badge bg-secondary">Draft</span></td>
The second one is more realistic: a link visible only to users who satisfy the CanManageBooks policy. Tag helpers can take services from the DI (Dependency Injection) container through the constructor and can run asynchronously:
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;
namespace Bookstore.Web.TagHelpers;
[HtmlTargetElement("secure-link")]
public class SecureLinkTagHelper(IAuthorizationService authorization, IUrlHelperFactory urlHelperFactory) : TagHelper
{
public required string Policy { get; set; }
public string? Area { get; set; }
public required string Controller { get; set; }
public string Action { get; set; } = "Index";
[ViewContext]
[HtmlAttributeNotBound]
public ViewContext ViewContext { get; set; } = null!;
public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{
AuthorizationResult result =
await authorization.AuthorizeAsync(ViewContext.HttpContext.User, Policy);
if (!result.Succeeded)
{
// A user without permission never sees the link
output.SuppressOutput();
return;
}
IUrlHelper url = urlHelperFactory.GetUrlHelper(ViewContext);
output.TagName = "a";
output.Attributes.SetAttribute("href", url.Action(Action, Controller, new { area = Area ?? "" }));
}
}
The [ViewContext] attribute tells the framework "put the current view's context into this property"; [HtmlAttributeNotBound] keeps it from looking like an HTML attribute. SuppressOutput() removes the tag and its content entirely. The usage in the layout and the policy in Program.cs:
<li class="nav-item">
<secure-link policy="CanManageBooks" area="Admin" controller="Books" class="nav-link text-dark">Admin</secure-link>
</li>
builder.Services.AddAuthorization(options =>
options.AddPolicy("CanManageBooks", policy => policy.RequireRole("Admin")));
Authentication is not the topic of this series; to try it out I wrote a small middleware, running only in Development, that turns a header into a user. Do not put this in a real application:
// Demo only: turns the X-Demo-Role header into a user. Real authentication comes from the Authentication series.
if (app.Environment.IsDevelopment())
{
app.Use(async (context, next) =>
{
if (context.Request.Headers.TryGetValue("X-Demo-Role", out StringValues role))
{
context.User = new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.Role, role.ToString())], "Demo"));
}
await next();
});
}
The real output with curl -H "X-Demo-Role: Admin"; the class attribute we do not know about was kept as is:
<li b-v51vsz6my3 class="nav-item">
<a class="nav-link text-dark" href="/Admin/Books">Admin</a>
</li>
With X-Demo-Role: Editor and with no header, there was no link. The answer to the second part of the guess is in the real output of the request without the header:
<li b-v51vsz6my3 class="nav-item">
</li>
The link is gone, but the <li> around it stayed; an empty item and an extra gap in the menu. A tag helper is only responsible for its own tag. You will find the way to fix this in the experiments below.
In Production
Hiding is not authorization. <secure-link> is only for user experience; someone who knows the /Admin/Books address can type it directly. The real check belongs on the controller or area with [Authorize(Policy = "CanManageBooks")]. The details are in the Web API (Application Programming Interface) series post on Authorization and in the upcoming Authentication and Authorization series.
Tag helpers run on every render. SecureLinkTagHelper evaluates a policy on every page view; for an in-memory policy such as a role check that is negligible, but a requirement handler that goes to the database adds a query to every page. Likewise, the memory used by <cache> is not unlimited; if you produce a large number of different keys with vary-by-* (such as a separate entry per user), memory grows. The default size limit and priority settings are in CacheTagHelperOptions.
Common Pitfalls
Forgetting the @ when passing a variable to an enum or another property. With an enum you get a compile error; but with a string property, title="book.Title" silently prints the text "book.Title".
Using your own tag helper without adding the @addTagHelper *, Bookstore.Web line. <status-badge> goes into the HTML as is, without any error.
Business logic or data access in a tag helper. A tag helper is a presentation tool; it changes a badge, a link, a tag's attributes. A fragment that has to fetch data is a view component.
Putting <cache> around user-specific content without vary-by-user or vary-by-cookie. The first user's cart, name or permission-hidden links are shown to everyone for 30 seconds.
A Small Experiment
★☆☆ Give the members of the BookStatus enum names such as [Display(Name = "Out of print")] and build a select with Html.GetEnumSelectList<BookStatus>(). Do the option texts change?
★★☆ Fix the empty <li> problem: write a tag helper that targets the li tag and carries a secure-policy attribute ([HtmlTargetElement("li", Attributes = "secure-policy")]), suppressing the whole item when the policy is not satisfied.
★★★ Add a show-icon bool attribute to <status-badge>; when true, put a small icon <i> at the start of the content with SetHtmlContent. Try the difference between TagHelperOutput.PreContent and Content, and change the Order property to observe the order in which two tag helpers on the same tag run.
The tag helpers built our form: the names, the data-vals and an antiforgery token are ready. Now the form needs to be submitted. In the next post we use real requests to see how model binding binds the values coming from a form to collections, nested objects and checkboxes, along with Post-Redirect-Get, antiforgery validation and the overposting hole that binding an entity directly opens.