Mert Özen Further With Every Line
Backend ASP.NET Core MVC and Razor Pages — #03

Razor Syntax and Layouts: @ Expressions, HTML Encoding, _Layout, Sections, _ViewStart and _ViewImports

Mert Özen Nov 8, 2026 11 min 3 views
Razor Syntax and Layouts: @ Expressions, HTML Encoding, _Layout, Sections, _ViewStart and _ViewImports

Razor HTML-encodes every value written with @ by default; that shuts out XSS but also turns non-ASCII letters such as Turkish ı into entities like ı, fixed with WebEncoderOptions. With real output we cover implicit vs explicit expressions, the danger of Html.Raw and of embedding '@Model' in JavaScript, Json.Serialize, sections, the _ViewStart/_ViewImports hierarchy and the lost-section error in nested layouts.

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

Bookstore's book details view contains these three lines:

<p>Member price: @Model.Price * 0.9m TL</p>
<p>Member price: @(Model.Price * 0.9m) TL</p>
<p>Questions? Write to support@bookstore.example or follow @@bookstore</p>

The book costs 310. How do these three lines look in the HTML (HyperText Markup Language) sent to the browser? And think about this: an editor saved the book's title from the admin panel as Haritaların Kısa Tarihi <script>alert('hi')</script> (Turkish for "A Short History of Maps"). Does an alert pop up when the page opens? If not, what does the letter "ı" look like in the source?

Razor's Single Rule: the @ Sign

Razor is the syntax for placing C# inside HTML. The rule is simple: the @ sign switches to C#, Razor decides where the expression ends on its own, and returns to HTML. Knowing how that decision is made explains most of the surprising output in Razor. The real output of the lines above (the "A Short History of Maps" page, priced at 310):

<p>Member price: 310 * 0.9m TL</p>
<p>Member price: 279.0 TL</p>
<p>Questions? Write to support@bookstore.example or follow @bookstore</p>

The first line is an implicit expression: @Model.Price is read as an identifier and a chain of property access, and it ends at the space. The rest, * 0.9m TL, is plain text. No compile error, no warning; the page says "310 * 0.9m". The second line is an explicit expression: @( ... ) evaluates everything inside the parentheses as a single C# expression. The rule: anything that contains a calculation, an operator or a space needs parentheses.

The third line has two subtleties. Because support@bookstore.example looks like an email address, Razor sees a letter before the @ and does not treat it as a switch to C#. @@ is the escape sequence and prints a single @; you need it for social media handles or @media rules in CSS (Cascading Style Sheets).

Implicit expressions have one more limit: generic calls. If you write @ToList<string>(), Razor thinks <string> is an HTML tag. Wrap every call that has a generic type parameter in @( ... ). @await, on the other hand, works implicitly: a line such as @await Component.InvokeAsync("CartSummary") needs no parentheses.

Code Blocks, Control Flow and Directives

The full details view:

@model BookDetailsViewModel
@{
    ViewData["Title"] = Model.Title;
    string stockText = Model.InStock ? $"{Model.Stock} in stock" : "Out of stock";
}
<h1>@Model.Title</h1>
<p>by @Model.AuthorName · @Model.Genre · @Model.PublishedOn.Year</p>
<p>Price: @Model.Price TL</p>
<p>Member price: @Model.Price * 0.9m TL</p>
<p>Member price: @(Model.Price * 0.9m) TL</p>
<p>@stockText</p>
<p>Questions? Write to support@bookstore.example or follow @@bookstore</p>
@if (Model.Status == BookStatus.OutOfPrint)
{
    @:This edition is out of print.
}
@* <p>Supplier: @Model.SupplierName</p> *@
<!-- visible in page source -->
@section Sidebar {
    <p>More by @Model.AuthorName</p>
}
@section Scripts {
    <script>
        const titleUnsafe = '@Model.Title';
        const book = @Json.Serialize(new { Model.Id, Model.Title });
    </script>
}

@{ ... } is a code block: every line inside it is C# and nothing is written to the output. The stockText defined here can be used in the rest of the view. @if, @foreach, @switch, @using and @try are for control flow; inside the braces Razor switches back to HTML mode, so a line starting with <p> is written as HTML. When you want to print plain text that does not start with an HTML tag, use @: (single line) or <text>...</text> (multiple lines). The <text> tag itself is not written to the output.

The real output of the same request inside <main>:

<h1>A Short History of Maps</h1>
<p>by Grace Hill · History · 2015</p>
<p>Price: 310 TL</p>
<p>Member price: 310 * 0.9m TL</p>
<p>Member price: 279.0 TL</p>
<p>Out of stock</p>
<p>Questions? Write to support@bookstore.example or follow @bookstore</p>
    This edition is out of print.
<!-- visible in page source -->

Note the difference between the two comments. @* ... *@ is a Razor comment: it is discarded at compile time, the @Model.SupplierName inside it never runs and it leaves no trace in the output. <!-- ... --> is plain HTML: it goes to the browser as is and anyone who views the source reads it. What is more, @ expressions inside an HTML comment do run. A cost or supplier field you "commented out" inside an HTML comment is still rendered and leaks into the page source.

Directives sit at the top of the view. @model binds the view's model type. @using adds a namespace. @inject IBookCatalog Catalog takes a service from the DI (Dependency Injection) container and exposes it as a property in the view. @section defines a block of content to send to the layout. @addTagHelper enables tag helpers, covered in the 5th post. Most of these are written not in individual views but in the _ViewImports.cshtml we will see shortly.

Default HTML Encoding: the First Defense Against XSS

Now the second part of the guess. I wrote the editor's title into the database and opened the page. The real output:

<title>Haritalar&#x131;n K&#x131;sa Tarihi &lt;script&gt;alert(&#x27;hi&#x27;)&lt;/script&gt; - Bookstore</title>
<h1>Haritalar&#x131;n K&#x131;sa Tarihi &lt;script&gt;alert(&#x27;hi&#x27;)&lt;/script&gt;</h1>

No alert. Razor passes every value written with @ through HtmlEncoder: < becomes &lt;, a single quote becomes &#x27;, and the browser displays it as text rather than a tag. The most common form of XSS (Cross-Site Scripting), user input being interpreted as HTML, is off by default. We did nothing for this; the danger starts where you knowingly or unknowingly switch this protection off.

But the output holds another surprise: the letter "ı" was written as &#x131;. The browser displays it correctly, so the user does not notice. Who notices is you reading the page source, the HTML size, and a test that searches with curl | grep "Haritaların". The reason is that ASP.NET Core's HTML encoder by default treats only the Basic Latin range as safe and turns every other character into a numeric entity. On a site with Turkish (or any non-English) content you need to widen this setting:

using System.Text.Encodings.Web;
using System.Text.Unicode;
using Microsoft.Extensions.WebEncoders;

builder.Services.AddControllersWithViews();
builder.Services.Configure<WebEncoderOptions>(options =>
    options.TextEncoderSettings = new TextEncoderSettings(UnicodeRanges.All));

The new output of the same page:

<title>Haritaların Kısa Tarihi &lt;script&gt;alert(&#x27;hi&#x27;)&lt;/script&gt; - Bookstore</title>
<h1>Haritaların Kısa Tarihi &lt;script&gt;alert(&#x27;hi&#x27;)&lt;/script&gt;</h1>

The Turkish characters are now written as they are, but <, > and quotes are still encoded. This setting is no concession on security; it only changes which letters count as "harmless". Characters that mean something in HTML are encoded in every case.

@Html.Raw and Embedding Data in JavaScript

The shortest way to switch encoding off is @Html.Raw. Printing the title with <h1>@Html.Raw(Model.Title)</h1> gives this real output:

<h1>Haritaların Kısa Tarihi <script>alert('hi')</script></h1>

This is now a script that runs in the browser of everyone who opens the page. Text written by an editor in the admin panel runs in every visitor's session context in the public catalog. Use Html.Raw only where the content is produced entirely by your own code (a fixed SVG (Scalable Vector Graphics) image, a fragment generated on the server that contains no user input). If you must display rich text from users (a WYSIWYG (What You See Is What You Get) editor for a book description, say), you need to pass it through an allow-list based HTML sanitizer when saving or before rendering. The general security discussion is in the Web Security (OWASP Top 10) series post "OWASP A05: Injection (2)", which covers XSS and Razor encoding; OWASP stands for Open Worldwide Application Security Project.

The second dangerous place is inside a <script> block. The real output of the two lines in the view:

const titleUnsafe = 'Haritaların Kısa Tarihi &lt;script&gt;alert(&#x27;hi&#x27;)&lt;/script&gt;';
const book = {"id":3,"title":"Haritalar\u0131n K\u0131sa Tarihi \u003Cscript\u003Ealert(\u0027hi\u0027)\u003C/script\u003E"};

On the titleUnsafe line Razor did HTML encoding again. But inside <script> the browser does not decode HTML entities; the JavaScript variable really holds the characters &lt;script&gt; and that is how they show on screen. More importantly, the HTML encoder does not know the rules of a JavaScript string: it was not designed for this context and breaks the string on characters like line breaks or backslashes. The right tool is @Json.Serialize: it converts the value to JSON (JavaScript Object Notation) and escapes characters such as <, > and quotes as \u003C. The output is a valid JavaScript literal and the value is the original title itself. The WebEncoderOptions setting does not affect this serializer, so "ı" is still \u0131; in JavaScript that is the same character, no problem.

Layout, RenderBody and Sections

Every view produces only its own part; the page's skeleton is in the layout. We added a Sidebar area to the template's Views/Shared/_Layout.cshtml, shown only when the page asks for it:

<div class="container">
    <main role="main" class="pb-3">
        @RenderBody()
    </main>
    @if (IsSectionDefined("Sidebar"))
    {
        <aside class="border-top pt-2">
            @await RenderSectionAsync("Sidebar")
        </aside>
    }
</div>
...
<script src="~/lib/jquery/dist/jquery.min.js"></script>
<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<script src="~/js/site.js" asp-append-version="true"></script>
@await RenderSectionAsync("Scripts", required: false)

RenderBody() prints all of the view's output outside sections and must be called exactly once in the layout. RenderSectionAsync("Scripts", required: false) prints the view's Scripts if it defined one, and nothing otherwise. IsSectionDefined is for making the wrapping HTML around the section conditional too: a page without a sidebar is not left with an empty <aside>. Rendering the Scripts section at the very end of the layout, after jQuery, is deliberate as well: page-specific scripts depend on the libraries.

required: true is a contract: the layout says "every page must provide this section". When I added @await RenderSectionAsync("Breadcrumbs", required: true) to the layout and opened the application, the home page returned 500:

System.InvalidOperationException: The layout page '/Views/Shared/_Layout.cshtml' cannot find the section 'Breadcrumbs' in the content page '/Views/Home/Index.cshtml'.

The error comes not at compile time but when the page in question is first rendered. In an application with many pages, adding required: true turns every untested page into a potential 500. That is why I almost always prefer the combination of required: false and IsSectionDefined.

_ViewStart, _ViewImports and Nested Layouts

The reason we do not write Layout = "_Layout"; at the top of every view is _ViewStart.cshtml: code that runs before every view in the same folder and its subfolders. _ViewImports.cshtml is the place for directives to add to every view. Bookstore has two copies of each:

Views/_ViewStart.cshtml               Layout = "_Layout"
Views/_ViewImports.cshtml             @using + @addTagHelper
Areas/Admin/Views/_ViewStart.cshtml   Layout = "_AdminLayout"
Areas/Admin/Views/_ViewImports.cshtml @using + @addTagHelper
@using Bookstore.Web
@using Bookstore.Web.Models
@using Bookstore.Web.ViewModels
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

Both work hierarchically. _ViewStart files run in order from the root towards the view's folder, so the setting of the closest one wins. _ViewImports directives accumulate: a _ViewImports in a subfolder does not override the @usings above it, it adds to them. For singular directives such as @model, the closest one applies. As we saw in the previous post, since the Areas folder is not under Views, the area needs its own files.

The Admin area's layout is a nested layout that sits inside the main layout. A layout that defines its own layout:

@{
    Layout = "_Layout";
}
<nav class="alert alert-secondary py-2">
    Admin panel ·
    <a asp-area="Admin" asp-controller="Books" asp-action="Index">Books</a>
</nav>
@RenderBody()

_AdminLayout is under Areas/Admin/Views/Shared; the name "_Layout" inside it resolves to Views/Shared/_Layout.cshtml through the view lookup rule. On the first try the admin book list returned 500. The admin list defined a Scripts section and the real error was:

System.InvalidOperationException: The following sections have been defined but have not been rendered by the page at '/Areas/Admin/Views/Shared/_AdminLayout.cshtml': 'Scripts'. To ignore an unrendered section call IgnoreSection("sectionName").

Sections only travel one level up. The view's Scripts reaches _AdminLayout, but since _AdminLayout does not render it, it never arrives at the top-level _Layout. The fix is to redefine the section in the intermediate layout and render whatever comes from below inside it:

@RenderBody()
@section Scripts {
    @await RenderSectionAsync("Scripts", required: false)
}

After the fix the end of the page is as expected:

<script src="/js/site.js?v=hRQyftXiu1lLX2P9Ly9xa4gHJgLeR1uGN5qegUobtGo"></script>
<script>console.log("admin books loaded");</script>
</body>

In Production

Make the encoding setting at the start of the application, before the first view is written. If you change it later, tests that compare HTML as strings, cached pages and logs searching for &#x131; break all at once. Measuring its effect is easy too: on a page heavy with Turkish text, every "ı", "ş" and "ğ" was sent as a 6-8 byte entity; now it goes as a 2-byte character encoded in Unicode Transformation Format (UTF-8).

Views are compiled at build time; a C# error in a view breaks dotnet build. But section and layout contracts, ViewBag accesses and partial names are resolved at runtime. That is why a simple smoke test that renders every page at least once (an integration test that sends a request to each route and expects 200) is, for its cost, the test that catches the most bugs in MVC (Model-View-Controller) applications.

Finally, flag uses of Html.Raw separately in code review. For every line of grep -rn "Html.Raw" Views/ output, the answer to "does this value contain user input?" should be clear.

Common Pitfalls

Writing a calculation without parentheses. @Model.Price * 0.9m compiles and the page says "310 * 0.9m". Any expression with an operator, a space or a generic needs @( ... ).

Business logic and database calls in the view. Writing @inject BookstoreDbContext Db and calling @Db.Books.Count(...) in the view technically works. But the query is sent during rendering, synchronously, from a place the controller knows nothing about; inside a foreach it becomes a separate query per row. Everything the view needs should arrive ready in the ViewModel. We will measure the partial-view version of this in the next post.

Printing user input with Html.Raw, or embedding it in JavaScript as '@Model.X'. The first is plain XSS; the second is broken data and encoding for the wrong context. Data goes into JavaScript with @Json.Serialize, and into HTML attributes with a tag helper or a normal @.

"Hiding" secrets with an HTML comment. <!-- @Model.InternalNote --> runs and is written to the page source. To comment something out on the Razor side, use @* *@.

Not forwarding sections in a nested layout. If the view defines a section that the intermediate layout does not, the page returns 500; the error shows up only on pages that use that section.

A Small Experiment

★☆☆ Add the lines <p>Published @Model.PublishedOn.ToString("dd.MM.yyyy")</p> and <p>Published @Model.PublishedOn.ToString("dd MMMM yyyy")</p> to the details view. Why does the second one work without parentheses, while @Model.PublishedOn.AddYears(1).Year + 1 does not give the result you expect?

★★☆ Remove the WebEncoderOptions setting and measure the size of a page with a Turkish title using curl -s -o /dev/null -w "%{size_download}"; put the setting back and measure again. How big is the difference?

★★★ Add a second section for _AdminLayout, PageActions, to the Admin area and render it in the admin layout's nav bar. Then define this section only in the admin list view. Should the main _Layout know about this section? Why?

Our views now encode correctly and sit on a shared skeleton. But we want to use the book row from the catalog list in the "featured" box on the home page as well, and we need a cart summary that appears in the top right of every page. In the next post we compare partial views, view components that fetch their own data, and rendering a Razor component inside an MVC view.