While ASP.NET Core ships with a rich set of built-in Tag Helpers for forms, links, and scripts, you can build your own to encapsulate reusable HTML generation logic. A custom Tag Helper targets a specific HTML element or attribute, runs server-side C# code during view rendering, and produces modified or entirely new HTML output. The result looks like natural HTML in your views — no messy @Html.SomeHelper() calls scattered through templates. Custom Tag Helpers are the recommended modern approach for reusable Razor UI components that need server-side data but do not require full controller logic (use View Components for that).
Building a Pagination Tag Helper
// ── PaginationTagHelper.cs ─────────────────────────────────────────────────
// Targets: <pagination> element
// Usage: <pagination page="@Model.Page" total-pages="@Model.TotalPages"
// asp-action="Index" asp-controller="Posts" />
[HtmlTargetElement("pagination")]
public class PaginationTagHelper : TagHelper
{
private readonly IUrlHelperFactory _urlHelperFactory;
public PaginationTagHelper(IUrlHelperFactory urlHelperFactory)
=> _urlHelperFactory = urlHelperFactory;
// Typed properties from HTML attributes (kebab-case → PascalCase)
[HtmlAttributeName("page")]
public int Page { get; set; }
[HtmlAttributeName("total-pages")]
public int TotalPages { get; set; }
[HtmlAttributeName("asp-action")]
public string Action { get; set; } = string.Empty;
[HtmlAttributeName("asp-controller")]
public string Controller { get; set; } = string.Empty;
// ViewContext is injected automatically
[ViewContext]
[HtmlAttributeNotBound]
public ViewContext ViewContext { get; set; } = null!;
public override void Process(TagHelperContext context, TagHelperOutput output)
{
output.TagName = "nav"; // render as <nav> instead of <pagination>
output.Attributes.SetAttribute("aria-label", "Pagination");
var urlHelper = _urlHelperFactory.GetUrlHelper(ViewContext);
var sb = new System.Text.StringBuilder();
sb.Append("<ul class=\"pagination\">");
// Previous button
if (Page > 1)
{
var prevUrl = urlHelper.Action(Action, Controller, new { page = Page - 1 });
sb.Append($"<li class=\"page-item\"><a class=\"page-link\" href=\"{prevUrl}\">← Prev</a></li>");
}
// Page numbers
for (int i = Math.Max(1, Page - 2); i <= Math.Min(TotalPages, Page + 2); i++)
{
var pageUrl = urlHelper.Action(Action, Controller, new { page = i });
var active = i == Page ? " active" : "";
sb.Append($"<li class=\"page-item{active}\"><a class=\"page-link\" href=\"{pageUrl}\">{i}</a></li>");
}
// Next button
if (Page < TotalPages)
{
var nextUrl = urlHelper.Action(Action, Controller, new { page = Page + 1 });
sb.Append($"<li class=\"page-item\"><a class=\"page-link\" href=\"{nextUrl}\">Next →</a></li>");
}
sb.Append("</ul>");
output.Content.SetHtmlContent(sb.ToString());
}
}
_ViewImports.cshtml with @addTagHelper *, BlogApp.Web. The * means “register all Tag Helpers in this assembly.” When you create a new custom Tag Helper class, it is automatically available in all views that have this directive — no additional registration needed. If a Tag Helper is not working, first check that the assembly name in @addTagHelper matches the actual assembly name (case-sensitive on Linux).output.Content.SetHtmlContent() for HTML content (not HTML-encoded) and output.Content.SetContent() for plain text content (HTML-encoded automatically). For performance with large HTML outputs, use TagBuilder or HtmlContentBuilder rather than string concatenation. If your Tag Helper wraps existing content (like a card component around any child content), use await output.GetChildContentAsync() to read and include the child content inside your generated HTML.total-pages) must be annotated with [HtmlAttributeName("total-pages")] to bind to a C# property name (TotalPages). Without the attribute, the kebab-case HTML attribute is not mapped to the PascalCase C# property — the property receives its default value and no error is thrown, making the bug silent and confusing. Always use [HtmlAttributeName] explicitly for attributes with dashes or when the HTML attribute name differs from the C# property name.Alert Message Tag Helper
// ── Usage: <alert type="success">Post created!</alert>
// ── Usage: <alert type="danger" dismissible="true">Error occurred!</alert>
[HtmlTargetElement("alert")]
public class AlertTagHelper : TagHelper
{
[HtmlAttributeName("type")]
public string Type { get; set; } = "info"; // info, success, warning, danger
[HtmlAttributeName("dismissible")]
public bool Dismissible { get; set; }
public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{
output.TagName = "div";
output.TagMode = TagMode.StartTagAndEndTag;
var dismissClass = Dismissible ? " alert-dismissible fade show" : "";
output.Attributes.SetAttribute("class", $"alert alert-{Type}{dismissClass}");
output.Attributes.SetAttribute("role", "alert");
// Include the content between the opening and closing <alert> tags
var childContent = await output.GetChildContentAsync();
if (Dismissible)
{
output.Content.SetHtmlContent(
childContent.GetContent() +
"<button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"alert\"></button>");
}
}
}
Common Mistakes
Mistake 1 — Missing @addTagHelper in _ViewImports (custom Tag Helper silently ignored)
❌ Wrong — custom Tag Helper element rendered as plain unknown HTML element.
✅ Correct — ensure @addTagHelper *, YourAssemblyName is in _ViewImports.cshtml.
Mistake 2 — Using SetContent() for HTML output (HTML gets encoded)
❌ Wrong — HTML tags appear as visible text in the browser:
output.Content.SetContent("<strong>Bold</strong>"); // renders as literal text!
✅ Correct — use SetHtmlContent() for HTML: output.Content.SetHtmlContent("<strong>Bold</strong>").