Custom Tag Helpers — Building Reusable HTML Components

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());
    }
}
Note: Tag Helpers are discovered through the assembly registered in _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).
Tip: Use 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.
Warning: Tag Helper property names with dashes in 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>").

🧠 Test Yourself

A custom Tag Helper has a C# property TotalPages. In the view it is used as <my-pager total-pages="5">. The property always reads as 0. What is the likely cause?