frontend-razor

SKILL.md

Razor View Development Patterns

View Structure

Project Layout

Views/
├── Shared/
│   ├── _Layout.cshtml          # Main layout template
│   ├── _LayoutEmpty.cshtml     # Minimal layout (no header/footer)
│   ├── _Header.cshtml          # Header partial
│   ├── _Footer.cshtml          # Footer partial
│   ├── _Navigation.cshtml      # Navigation partial
│   ├── _Pagination.cshtml      # Reusable pagination
│   └── Components/             # View components
│       └── SearchBox/
│           └── Default.cshtml
├── Home/
│   ├── Index.cshtml
│   └── About.cshtml
├── Products/
│   ├── Index.cshtml
│   ├── Details.cshtml
│   └── _ProductCard.cshtml     # Page-specific partial
├── _ViewImports.cshtml         # Shared imports and tag helpers
└── _ViewStart.cshtml           # Default layout assignment

_ViewImports.cshtml

@using MyApp.Web
@using MyApp.Web.Models
@using MyApp.Core.Models
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, MyApp.Web

_ViewStart.cshtml

@{
    Layout = "_Layout";
}

Layout Templates

Main Layout

@* Views/Shared/_Layout.cshtml *@
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>@ViewData["Title"] - My Application</title>
    
    @* Head section for page-specific styles *@
    @await RenderSectionAsync("Styles", required: false)
    
    <link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
</head>
<body class="@ViewData["BodyClass"]">
    <header>
        @await Html.PartialAsync("_Header")
        @await Html.PartialAsync("_Navigation")
    </header>
    
    <main class="container">
        @RenderBody()
    </main>
    
    <footer>
        @await Html.PartialAsync("_Footer")
    </footer>
    
    <script src="~/lib/jquery/dist/jquery.min.js"></script>
    <script src="~/js/site.js" asp-append-version="true"></script>
    
    @* Scripts section for page-specific JavaScript *@
    @await RenderSectionAsync("Scripts", required: false)
</body>
</html>

Page Using Layout

@model ProductListViewModel
@{
    ViewData["Title"] = "Products";
    ViewData["BodyClass"] = "products-page";
}

@section Styles {
    <link rel="stylesheet" href="~/css/products.css" asp-append-version="true" />
}

<h1>@ViewData["Title"]</h1>

<div class="product-grid">
    @foreach (var product in Model.Products)
    {
        @await Html.PartialAsync("_ProductCard", product)
    }
</div>

@section Scripts {
    <script src="~/js/products.js" asp-append-version="true"></script>
}

Nested Layouts

@* Views/Shared/_LayoutAdmin.cshtml *@
@{
    Layout = "_Layout";
}

<div class="admin-container">
    <aside class="admin-sidebar">
        @await Html.PartialAsync("_AdminNav")
    </aside>
    <div class="admin-content">
        @RenderBody()
    </div>
</div>

@section Scripts {
    <script src="~/js/admin.js" asp-append-version="true"></script>
    @await RenderSectionAsync("AdminScripts", required: false)
}

Partial Views

Rendering Partials

@* Async (recommended) *@
@await Html.PartialAsync("_ProductCard", product)

@* With explicit view path *@
@await Html.PartialAsync("~/Views/Shared/_Header.cshtml")

@* Synchronous (avoid for I/O operations) *@
@Html.Partial("_Sidebar")

@* As tag helper *@
<partial name="_ProductCard" model="product" />

@* With view-data *@
<partial name="_Pagination" model="Model.Pagination" view-data="ViewData" />

Partial View Example

@* Views/Products/_ProductCard.cshtml *@
@model Product

<article class="product-card">
    <a asp-action="Details" asp-route-id="@Model.Id" class="product-card__link">
        <img src="@Model.ImageUrl" 
             alt="@Model.Name" 
             class="product-card__image" />
        <div class="product-card__content">
            <h3 class="product-card__title">@Model.Name</h3>
            <p class="product-card__price">@Model.Price.ToString("C")</p>
            @if (Model.IsOnSale)
            {
                <span class="product-card__badge">Sale</span>
            }
        </div>
    </a>
</article>

View Components

Component Class

// ViewComponents/RecentArticlesViewComponent.cs
public class RecentArticlesViewComponent : ViewComponent
{
    private readonly IArticleService _articleService;

    public RecentArticlesViewComponent(IArticleService articleService)
    {
        _articleService = articleService;
    }

    public async Task<IViewComponentResult> InvokeAsync(int count = 5)
    {
        var articles = await _articleService.GetRecentAsync(count);
        return View(articles);
    }
}

Component View

@* Views/Shared/Components/RecentArticles/Default.cshtml *@
@model IEnumerable<Article>

<section class="recent-articles">
    <h3>Recent Articles</h3>
    <ul>
        @foreach (var article in Model)
        {
            <li>
                <a asp-controller="Articles" 
                   asp-action="Details" 
                   asp-route-slug="@article.Slug">
                    @article.Title
                </a>
                <time datetime="@article.PublishedDate.ToString("yyyy-MM-dd")">
                    @article.PublishedDate.ToString("MMM dd, yyyy")
                </time>
            </li>
        }
    </ul>
</section>

Invoking Components

@* Tag helper syntax (preferred) *@
<vc:recent-articles count="5" />

@* Async syntax *@
@await Component.InvokeAsync("RecentArticles", new { count = 5 })

@* With cache *@
<cache expires-after="@TimeSpan.FromMinutes(10)">
    <vc:recent-articles count="5" />
</cache>

Tag Helpers

Built-in Tag Helpers

@* Anchor tag helper *@
<a asp-controller="Products" 
   asp-action="Details" 
   asp-route-id="@product.Id"
   asp-route-category="@product.Category">
    View Details
</a>

@* Form tag helpers *@
<form asp-controller="Contact" asp-action="Submit" method="post">
    <div class="form-group">
        <label asp-for="Name"></label>
        <input asp-for="Name" class="form-control" />
        <span asp-validation-for="Name" class="text-danger"></span>
    </div>
    
    <div class="form-group">
        <label asp-for="Email"></label>
        <input asp-for="Email" class="form-control" />
        <span asp-validation-for="Email" class="text-danger"></span>
    </div>
    
    <div class="form-group">
        <label asp-for="Category"></label>
        <select asp-for="Category" asp-items="Model.Categories" class="form-control">
            <option value="">Select a category</option>
        </select>
        <span asp-validation-for="Category" class="text-danger"></span>
    </div>
    
    <button type="submit" class="btn btn-primary">Submit</button>
</form>

@* Image with cache busting *@
<img src="~/images/logo.png" asp-append-version="true" alt="Logo" />

@* Environment-specific rendering *@
<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>

Custom Tag Helper

// TagHelpers/EmailTagHelper.cs
[HtmlTargetElement("email")]
public class EmailTagHelper : TagHelper
{
    public string Address { get; set; }
    public string Subject { get; set; }

    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        output.TagName = "a";
        
        var href = $"mailto:{Address}";
        if (!string.IsNullOrEmpty(Subject))
        {
            href += $"?subject={Uri.EscapeDataString(Subject)}";
        }
        
        output.Attributes.SetAttribute("href", href);
        output.Content.SetContent(Address);
    }
}
@* Usage *@
<email address="support@example.com" subject="Help Request" />

Model Binding

Strongly-Typed Views

@model ContactFormViewModel

<form asp-action="Submit" method="post">
    @Html.AntiForgeryToken()
    
    <div class="form-group">
        <label asp-for="Name" class="form-label"></label>
        <input asp-for="Name" class="form-control" placeholder="Your name" />
        <span asp-validation-for="Name" class="text-danger"></span>
    </div>
    
    <div class="form-group">
        <label asp-for="Email" class="form-label"></label>
        <input asp-for="Email" class="form-control" type="email" />
        <span asp-validation-for="Email" class="text-danger"></span>
    </div>
    
    <div class="form-group">
        <label asp-for="Message" class="form-label"></label>
        <textarea asp-for="Message" class="form-control" rows="5"></textarea>
        <span asp-validation-for="Message" class="text-danger"></span>
    </div>
    
    <button type="submit" class="btn btn-primary">Send Message</button>
</form>

@section Scripts {
    <partial name="_ValidationScriptsPartial" />
}

ViewModel with Validation

public class ContactFormViewModel
{
    [Required(ErrorMessage = "Name is required")]
    [StringLength(100, ErrorMessage = "Name cannot exceed 100 characters")]
    [Display(Name = "Full Name")]
    public string Name { get; set; }

    [Required(ErrorMessage = "Email is required")]
    [EmailAddress(ErrorMessage = "Please enter a valid email address")]
    public string Email { get; set; }

    [Required(ErrorMessage = "Message is required")]
    [StringLength(2000, MinimumLength = 10, 
        ErrorMessage = "Message must be between 10 and 2000 characters")]
    public string Message { get; set; }
}

Conditional Rendering

If/Else Statements

@if (Model.Products.Any())
{
    <div class="product-grid">
        @foreach (var product in Model.Products)
        {
            <partial name="_ProductCard" model="product" />
        }
    </div>
}
else
{
    <div class="empty-state">
        <p>No products found.</p>
        <a asp-action="Index" asp-controller="Home" class="btn btn-link">
            Return to Home
        </a>
    </div>
}

Switch Statements

@switch (Model.Status)
{
    case OrderStatus.Pending:
        <span class="badge badge-warning">Pending</span>
        break;
    case OrderStatus.Processing:
        <span class="badge badge-info">Processing</span>
        break;
    case OrderStatus.Shipped:
        <span class="badge badge-primary">Shipped</span>
        break;
    case OrderStatus.Delivered:
        <span class="badge badge-success">Delivered</span>
        break;
    default:
        <span class="badge badge-secondary">Unknown</span>
        break;
}

Null Checking

@* Null conditional *@
<p>Author: @Model.Author?.Name ?? "Unknown"</p>

@* With fallback *@
@if (Model.ImageUrl != null)
{
    <img src="@Model.ImageUrl" alt="@Model.Title" />
}
else
{
    <img src="~/images/placeholder.jpg" alt="No image available" />
}

@* Ternary operator *@
<div class="@(Model.IsActive ? "active" : "inactive")">
    @Model.Title
</div>

Loops and Collections

For Loop

@for (int i = 0; i < Model.Items.Count; i++)
{
    <div class="item @(i % 2 == 0 ? "even" : "odd")">
        <span class="item-number">@(i + 1)</span>
        <span class="item-name">@Model.Items[i].Name</span>
    </div>
}

Foreach with Index

@{ var index = 0; }
@foreach (var item in Model.Items)
{
    <div class="item" data-index="@index">
        @item.Name
    </div>
    index++;
}

Rendering Lists

<ul class="breadcrumb">
    @foreach (var crumb in Model.Breadcrumbs)
    {
        var isLast = crumb == Model.Breadcrumbs.Last();
        <li class="breadcrumb-item @(isLast ? "active" : "")">
            @if (isLast)
            {
                @crumb.Title
            }
            else
            {
                <a href="@crumb.Url">@crumb.Title</a>
            }
        </li>
    }
</ul>

HTML Helpers vs Tag Helpers

Prefer Tag Helpers

@* HTML Helper (older approach) *@
@Html.ActionLink("Details", "Details", "Products", new { id = product.Id }, new { @class = "btn btn-link" })

@* Tag Helper (modern, preferred) *@
<a asp-controller="Products" asp-action="Details" asp-route-id="@product.Id" class="btn btn-link">
    Details
</a>

@* HTML Helper for form *@
@Html.TextBoxFor(m => m.Name, new { @class = "form-control", placeholder = "Enter name" })

@* Tag Helper (cleaner) *@
<input asp-for="Name" class="form-control" placeholder="Enter name" />

When to Use HTML Helpers

@* Complex dynamic attributes *@
@Html.TextBoxFor(m => m.Name, Model.GetInputAttributes())

@* Raw HTML content *@
@Html.Raw(Model.HtmlContent)

@* Display templates *@
@Html.DisplayFor(m => m.CreatedDate)

@* Editor templates *@
@Html.EditorFor(m => m.Address)

ViewData, ViewBag, and TempData

ViewData (Dictionary)

@* In Controller *@
ViewData["Title"] = "Product Details";
ViewData["ShowSidebar"] = true;

@* In View *@
<h1>@ViewData["Title"]</h1>
@if ((bool?)ViewData["ShowSidebar"] == true)
{
    <partial name="_Sidebar" />
}

ViewBag (Dynamic)

@* In Controller *@
ViewBag.Categories = await _categoryService.GetAllAsync();

@* In View *@
<select asp-items="@(new SelectList(ViewBag.Categories, "Id", "Name"))">
    <option value="">All Categories</option>
</select>

TempData (Survives Redirect)

// In Controller
TempData["SuccessMessage"] = "Product saved successfully!";
return RedirectToAction("Index");
@* In View *@
@if (TempData["SuccessMessage"] != null)
{
    <div class="alert alert-success alert-dismissible">
        @TempData["SuccessMessage"]
        <button type="button" class="close" data-dismiss="alert">&times;</button>
    </div>
}

AJAX and Partial Rendering

AJAX Form Submission

<form id="contact-form" asp-action="SubmitAjax" method="post">
    @Html.AntiForgeryToken()
    
    <div class="form-group">
        <input asp-for="Name" class="form-control" />
        <span asp-validation-for="Name" class="text-danger"></span>
    </div>
    
    <button type="submit" class="btn btn-primary">Submit</button>
</form>

<div id="result"></div>

@section Scripts {
    <script>
    $('#contact-form').on('submit', function(e) {
        e.preventDefault();
        
        var $form = $(this);
        var $button = $form.find('button[type="submit"]');
        
        $button.prop('disabled', true).text('Sending...');
        
        $.ajax({
            url: $form.attr('action'),
            type: 'POST',
            data: $form.serialize(),
            success: function(response) {
                $('#result').html(response);
                $form[0].reset();
            },
            error: function(xhr) {
                $('#result').html('<div class="alert alert-danger">An error occurred.</div>');
            },
            complete: function() {
                $button.prop('disabled', false).text('Submit');
            }
        });
    });
    </script>
}

Returning Partial Views from Controller

[HttpGet]
public async Task<IActionResult> LoadMore(int page)
{
    var products = await _productService.GetPagedAsync(page, 10);
    return PartialView("_ProductGrid", products);
}

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SubmitAjax(ContactFormViewModel model)
{
    if (!ModelState.IsValid)
    {
        return PartialView("_ContactFormErrors", model);
    }
    
    await _contactService.ProcessAsync(model);
    return PartialView("_ContactFormSuccess");
}

Accessibility Considerations

@* Semantic HTML with ARIA *@
<nav aria-label="Main navigation">
    <ul class="nav" role="menubar">
        @foreach (var item in Model.NavItems)
        {
            <li role="none">
                <a href="@item.Url" 
                   role="menuitem"
                   aria-current="@(item.IsActive ? "page" : null)">
                    @item.Title
                </a>
            </li>
        }
    </ul>
</nav>

@* Form accessibility *@
<div class="form-group">
    <label asp-for="Email" id="email-label"></label>
    <input asp-for="Email" 
           aria-labelledby="email-label" 
           aria-describedby="email-help email-error" />
    <small id="email-help" class="form-text text-muted">
        We'll never share your email.
    </small>
    <span asp-validation-for="Email" id="email-error" class="text-danger" role="alert"></span>
</div>

@* Skip link for keyboard navigation *@
<a href="#main-content" class="skip-link">Skip to main content</a>
Weekly Installs
9
GitHub Stars
1
First Seen
Mar 1, 2026
Installed on
opencode9
gemini-cli9
github-copilot9
codex9
amp9
cline9