Skip to content
wannacode

Views with Razor

Problem

HTML glued together from strings

In the previous lesson, Details returned a view with three lines. But the catalog is a table with four products, every page has the same menu, and the order form must not accept an empty name or a quantity of −3.

Gluing all of this together with "<tr><td>" + product.Name + ... is slow and easy to get wrong. It is also dangerous: if someone gives a product the name <script>…</script>, the string puts it into the page as real code, and it runs in the browser of every shopper. This attack is called XSS (cross-site scripting).

See it

Razor: HTML with C# inside

A view is a .cshtml file. It is HTML, and @ switches to C#. The controller passes the list and a title:

public class ProductsController : Controller
{
private readonly IProductService products;
public ProductsController(IProductService products) => this.products = products;
public IActionResult Index()
{
ViewData["Title"] = "Catalog";
return View(products.All());
}
}

The view Views/Products/Index.cshtml:

@model List<Product>
<h1>@ViewData["Title"]</h1>
<table>
<tr><th>Product</th><th>Price</th><th></th></tr>
@foreach (Product product in Model)
{
<tr>
<td>@product.Name</td>
<td>€@product.Price</td>
@if (product.Price > 10)
{
<td>featured</td>
}
else
{
<td></td>
}
</tr>
}
</table>
<p>Products: @Model.Count</p>
The response
<h1>Catalog</h1>
<table>
<tr><th>Product</th><th>Price</th><th></th></tr>
<tr>
<td>Ethiopia Yirgacheffe</td>
<td>€14.90</td>
<td>featured</td>
</tr>
<tr>
<td>Colombia Supremo</td>
<td>€12.50</td>
<td>featured</td>
</tr>
<tr>
<td>Sencha</td>
<td>€8.40</td>
<td></td>
</tr>
<tr>
<td>Earl Grey</td>
<td>€6.90</td>
<td></td>
</tr>
</table>
<p>Products: 4</p>
  • @model List<Product> says what kind of object the controller passes. In the view, it is Model. That makes the view strongly typed: a mistake like @Model.Cont is caught already at dotnet build.
  • @product.Name inserts a value. @foreach and @if are ordinary C#, and the HTML inside them is repeated or shown depending on the condition.
  • ViewData["Title"] is a dictionary the controller uses to send small things to the view, besides the model.
  • Every value is encoded: < becomes &lt;. A name like <script> is shown as text and does not run. This is the protection from XSS, without you doing anything.

The view's template language is called Razor. There is no special syntax for the end of the C#: Razor recognizes by itself when an HTML tag starts.

The same thing in a shorter view, product by product:

@model List<Product>
<h1>Catalog</h1>
<ul>
@foreach (Product product in Model)
{
<li>@product.Name: €@product.Price</li>
}
</ul>
<p>Products: @Model.Count</p>
Step by step, in words
  1. Plain HTML goes into the response as it is.
  2. The controller passes a list of 4 products. In the view, it is Model.
  3. @foreach takes the first product. @product.Name becomes "Ethiopia Yirgacheffe", and @product.Price becomes 14.90.
  4. The next product: the same <li> line with "Colombia Supremo" and 12.50.
  5. The next product: the same <li> line with "Sencha" and 8.40.
  6. The next product: the same <li> line with "Earl Grey" and 6.90.
  7. The loop is over. @Model.Count inserts 4. The browser gets finished HTML, without a single @.
More precisely, if you are curious
  • An expression with spaces or operators goes in parentheses: @(product.Price * 2). A block of C# with no output is @{ ... }, for example @{ ViewData["Title"] = "Catalog"; } at the start of the view.
  • If you really want to insert ready-made HTML without encoding, there is @Html.Raw(text). Use it only for text you wrote yourself, never for text a user typed in.
  • @DateTime.Now.Year inserts the current year: handy for the "© 2026" at the bottom that you should not have to change every year.
  • Why AddWebEncoders with UnicodeRanges.All is in Program.cs is explained in the MVC lesson: without it, letters outside basic Latin come out as codes like &#xE9;. The browser shows them correctly either way.

Try it

What will be shown

The Cheap action passes all four products. What does the view return?

public class ProductsController : Controller
{
private readonly IProductService products;
public ProductsController(IProductService products) => this.products = products;
public IActionResult Cheap() => View(products.All());
}
@model List<Product>
<ul>
@foreach (Product product in Model)
{
if (product.Price < 10)
{
<li>@product.Name: €@product.Price</li>
}
}
</ul>
<p>@Model.Count(p => p.Price < 10) of @Model.Count</p>
Answer

The idea

A shared layout

The menu and the footer are on every page. Instead of copying them, you put them in a layout: a frame the page goes into. It lives in Views/Shared/_Layout.cshtml:

<!DOCTYPE html>
<html lang="en">
<head>
<title>@ViewData["Title"] | Coffee and Tea</title>
</head>
<body>
<nav>
<a asp-controller="Home" asp-action="Index">Home</a>
<a asp-controller="Products" asp-action="Index">Products</a>
</nav>
<main>
@RenderBody()
</main>
<footer>Coffee and Tea, 12 Shipka St.</footer>
</body>
</html>

@RenderBody() is where the page goes. Which layout to use is set in Views/_ViewStart.cshtml, which runs before every view:

@{
Layout = "_Layout";
}

<a asp-controller="Products" asp-action="Index"> is a tag helper: the asp-… attributes run on the server and build the address from the route. In the response it becomes <a href="/Products">. You turn the helpers on once for all views in Views/_ViewImports.cshtml:

@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
More precisely, if you are curious
  • The page title comes from ViewData["Title"], which the view or the controller fills in. That way the layout is shared, but the title is different.
  • A view can do without a layout with @{ Layout = null; }, or pick a different one, such as "_AdminLayout".
  • A layout can also have sections: @RenderSection("Scripts", required: false) is a place the page fills in with @section Scripts { ... }, for example with a script just for that page.

See it

Partial views, ViewData and ViewBag

The product row will be needed in the catalog, in the cart and in the order. So it goes in a partial view, Views/Shared/_ProductRow.cshtml:

@model Product
<tr><td>@Model.Name</td><td>€@Model.Price</td></tr>

The catalog calls it for each product with <partial>:

@model List<Product>
<h1>@ViewData["Title"]</h1>
<p>@ViewBag.Message</p>
<table>
@foreach (Product product in Model)
{
<partial name="_ProductRow" model="product" />
}
</table>

The controllers put the title in ViewData and the message in ViewBag:

public class HomeController : Controller
{
public IActionResult Index()
{
ViewData["Title"] = "Home";
return View();
}
}
public class ProductsController : Controller
{
private readonly IProductService products;
public ProductsController(IProductService products) => this.products = products;
public IActionResult Index()
{
ViewData["Title"] = "Products";
ViewBag.Message = "Free delivery over €50";
return View(products.All());
}
}

The response for /Products: the layout, and in <main>, the page with the four rows:

The response
<!DOCTYPE html>
<html lang="en">
<head>
<title>Products | Coffee and Tea</title>
</head>
<body>
<nav>
<a href="/">Home</a>
<a href="/Products">Products</a>
</nav>
<main>
<h1>Products</h1>
<p>Free delivery over €50</p>
<table>
<tr><td>Ethiopia Yirgacheffe</td><td>€14.90</td></tr>
<tr><td>Colombia Supremo</td><td>€12.50</td></tr>
<tr><td>Sencha</td><td>€8.40</td></tr>
<tr><td>Earl Grey</td><td>€6.90</td></tr>
</table>
</main>
<footer>Coffee and Tea, 12 Shipka St.</footer>
</body>
</html>
  • ViewData["Message"] and ViewBag.Message are the same storage. ViewData is a dictionary with string keys, and ViewBag is the same thing, but with a dot. Neither is checked at compile time: a typo in the key gives you an empty spot. For the main data, use the model.
  • By convention, the name of a partial view starts with _, so you can see it is not a whole page.
More precisely, if you are curious
  • In older projects a partial view is called with @Html.Partial("_ProductRow", product). That still works today, but it waits synchronously, and the compiler warns about it. The new way is <partial> or @await Html.PartialAsync(...).
  • A partial view with its own logic (for example, the cart in the menu that counts its products by itself) is a View Component: a class plus a view, called from the layout.
  • The blank lines between the <tr>s come from <partial> being on its own line in the loop. The browser ignores them.

Try it

Where the page goes

What does the layout call where the page goes in?

<body>
    <nav>…</nav>
    <main>
        @()
    </main>
</body>

The idea

Forms, binding and validation

An order is a class. The attributes above the properties say what is allowed:

public class Order
{
public int Id { get; set; }
[Required(ErrorMessage = "Enter a name.")]
public string Name { get; set; }
[Required(ErrorMessage = "Enter an email.")]
[EmailAddress(ErrorMessage = "That is not an email address.")]
public string Email { get; set; }
[Range(1, 20, ErrorMessage = "The quantity must be from 1 to 20.")]
public int Quantity { get; set; }
}

The form is a view with an Order model. asp-for makes a field for the property with the right name, id and type, and asp-validation-for is a place for its error:

@model Order
<form asp-action="Create" method="post">
<label asp-for="Name">Name</label>
<input asp-for="Name" />
<span asp-validation-for="Name"></span>
<label asp-for="Email">Email</label>
<input asp-for="Email" />
<span asp-validation-for="Email"></span>
<label asp-for="Quantity">Quantity</label>
<input asp-for="Quantity" />
<span asp-validation-for="Quantity"></span>
<button>Order</button>
</form>

The controller has two Create actions: with GET it shows the empty form, and with POST it receives it:

public class OrdersController : Controller
{
private readonly OrderBook book;
public OrdersController(OrderBook book) => this.book = book;
[HttpGet]
public IActionResult Create() => View(new Order { Quantity = 1 });
[HttpPost]
public IActionResult Create(Order order)
{
if (!ModelState.IsValid) return View(order);
int id = book.Add(order);
return RedirectToAction("Thanks", new { id });
}
public IActionResult Thanks(int id)
{
Order order = book.Find(id);
if (order == null) return NotFound();
return View(order);
}
}
  • Create(Order order) gets a ready-made object. ASP.NET Core takes the fields from the form by their names (Name, Email, Quantity) and puts them into the properties. This is called model binding.
  • While binding, it checks the attributes. The result is in ModelState: IsValid is false if any rule is broken.
  • On an error, you return the same form with View(order): what was typed stays, and each field has its error next to it.
  • On success, you save and redirect. new { id } is an object with one property, id, from which the address /Orders/Thanks/1 is built. The pattern is called POST, redirect, GET (PRG): reloading the "Thank you" page does not send the order a second time.

See it

The form in action

The client opens the empty form, sends a bad one and then a good one:

var http = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false });
string site = "http://localhost:6244";
string empty = await http.GetStringAsync(site + "/Orders/Create");
Console.WriteLine(empty.Split('\n').First(line => line.Contains("id=\"Name\"")).Trim());
var bad = new FormUrlEncodedContent(new Dictionary<string, string> { ["Name"] = "", ["Email"] = "ana@", ["Quantity"] = "0" });
HttpResponseMessage rejected = await http.PostAsync(site + "/Orders/Create", bad);
Console.WriteLine("Bad form: " + (int)rejected.StatusCode);
string page = await rejected.Content.ReadAsStringAsync();
foreach (string line in page.Split('\n').Where(line => line.Contains("field-validation-error") || line.Contains("value=\"ana@\"")))
{
Console.WriteLine(line.Trim());
}
var good = new FormUrlEncodedContent(new Dictionary<string, string> { ["Name"] = "Ana", ["Email"] = "ana@example.bg", ["Quantity"] = "2" });
HttpResponseMessage accepted = await http.PostAsync(site + "/Orders/Create", good);
Console.WriteLine("Good form: " + (int)accepted.StatusCode + ", Location: " + accepted.Headers.Location);
Console.Write(await http.GetStringAsync(site + accepted.Headers.Location));
Output
<input type="text" data-val="true" data-val-required="Enter a name." id="Name" name="Name" value="" />
Bad form: 200
<span class="field-validation-error" data-valmsg-for="Name" data-valmsg-replace="true">Enter a name.</span>
<input type="email" class="input-validation-error" data-val="true" data-val-email="That is not an email address." data-val-required="Enter an email." id="Email" name="Email" value="ana@" />
<span class="field-validation-error" data-valmsg-for="Email" data-valmsg-replace="true">That is not an email address.</span>
<span class="field-validation-error" data-valmsg-for="Quantity" data-valmsg-replace="true">The quantity must be from 1 to 20.</span>
Good form: 302, Location: /Orders/Thanks/1
<p>Thank you, Ana! Order #1 for 2 items has been received.</p>
<p>You will get a confirmation at ana@example.bg.</p>
  • The first line is the field that asp-for made from Name. data-val-required carries the message to the browser: with the JavaScript validation library, it shows the error even before the form is sent.
  • The bad form returns 200: the same page, with the field-validation-error class and the messages. The email keeps what was typed, ana@.
  • The good form is a 302 to /Orders/Thanks/1, and there is the confirmation from the Thanks.cshtml view:
@model Order
<p>Thank you, @Model.Name! Order #@Model.Id for @Model.Quantity items has been received.</p>
<p>You will get a confirmation at @Model.Email.</p>
More precisely, if you are curious
  • The check in the browser is for convenience. The check you rely on is ModelState.IsValid on the server: anyone can send a POST without a browser, just like this program.
  • More attributes: [StringLength(50)], [Phone], [Compare("Password")] (two fields must match), [RegularExpression(...)]. [DataType(DataType.Password)] checks nothing; it tells asp-for to make type="password".
  • <form asp-action="Create" method="post"> also adds a hidden __RequestVerificationToken field. With [ValidateAntiForgeryToken] above the POST action, the server accepts only forms it gave out itself. That way another site cannot send an order in the name of the signed-in user (the attack is called CSRF).
  • Quantity is an int, so it is required even without [Required]: an empty field cannot become a number.

Try it

The order goes through without a name

A form with an empty name is saved, and the user sees "Thank you, !". And a good form comes back with errors. Which line is the problem on?

Pick the line with the bug.

Exercise

The shop's pages

Solve these in a project from dotnet new mvc. The products and IProductService are from the MVC lesson.

1. A product table. Index passes the products sorted by price. The view is a table with columns for product, category and price, and the last row is the total with @Model.Sum(...):

The response for /Products
<table>
<tr><th>Product</th><th>Category</th><th>Price</th></tr>
<tr><td>Earl Grey</td><td>tea</td><td>€6.90</td></tr>
<tr><td>Sencha</td><td>tea</td><td>€8.40</td></tr>
<tr><td>Colombia Supremo</td><td>coffee</td><td>€12.50</td></tr>
<tr><td>Ethiopia Yirgacheffe</td><td>coffee</td><td>€14.90</td></tr>
<tr><td colspan="2">Total</td><td>€42.70</td></tr>
</table>

2. A shared layout with a menu. HomeController has Index and Contacts. The layout has a menu made of tag helpers and a title from ViewData["Title"]. Contacts sends the phone number with ViewBag.Phone:

The response for /Home/Contacts
<!DOCTYPE html>
<html lang="en">
<head>
<title>Contacts | Coffee and Tea</title>
</head>
<body>
<nav>
<a href="/">Home</a>
<a href="/Home/Contacts">Contacts</a>
</nav>
<h1>Contacts</h1>
<p>Phone: 02 123 4567</p>
</body>
</html>

3. Booking a tasting. The Booking form has a name ([Required]), an email ([Required], [EmailAddress]) and a number of seats from 1 to 8 ([Range]). On an error, the form comes back, and on success it redirects to Done, which says how many seats were booked and how many there are in total so far. The client sends three forms: a bad one (an email without @ and 12 seats) and two good ones. For a bad one it prints the number of messages, and for a good one, the page it reaches after the redirect:

Output
Errors: 2
Seats booked: 3. Total for the tasting: 3.
Seats booked: 2. Total for the tasting: 5.

Test

Check yourself

1. What does @product.Name do in a Razor view?
2. Where in _Layout.cshtml does the content of the specific page go?
3. The form is sent with an empty name, and Name has [Required]. What is ModelState.IsValid?
4. Why does the action return View(order) for a bad form, and not RedirectToAction?
5. What does <a asp-controller="Products" asp-action="Index"> give you?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·