Skip to main content
Published / updated

MVC and Razor Pages

Before you start

You need: routing and controllers (Article 05).

Time: about 50 minutes, plus the practice.

Learning objective

Build a server-rendered CRUD screen with either MVC or Razor Pages, and choose between them for a given feature.

Topics

  • MVC flow
  • Views, layouts and partials
  • Razor syntax essentials
  • Tag helpers
  • Razor Pages and the PageModel
  • Handlers
  • Post-Redirect-Get
  • TempData
  • Choosing between them

MVC flow

Request → Routing → Controller action
→ calls a service
→ builds a view model
→ returns View(model)
→ Razor renders the .cshtml
→ HTML response
public class StudentsController : Controller
{
private readonly IStudentService _studentService;

public StudentsController(IStudentService studentService)
{
_studentService = studentService;
}

[HttpGet]
public async Task<IActionResult> Index(string? term, int page = 1, CancellationToken ct = default)
{
var schoolId = User.GetSchoolId();

var model = new StudentListViewModel
{
SearchTerm = term,
Results = await _studentService.SearchAsync(schoolId, term, page, 20, ct)
};

return View(model);
}
}

Controller rather than ControllerBase — the view-rendering support is what the extra class adds.

View(model) looks for Views/Students/Index.cshtml, then Views/Shared/Index.cshtml. Convention over configuration; View("Other", model) overrides the name.

A view model is not an entity. It carries exactly what the screen renders — including things no table has, such as a dropdown's options or a computed total — and excludes anything the screen does not show.

Views and layouts

Views/
├── _ViewImports.cshtml shared usings and tag helper registration
├── _ViewStart.cshtml sets the default layout
├── Shared/
│ ├── _Layout.cshtml
│ └── _ValidationScriptsPartial.cshtml
└── Students/
├── Index.cshtml
├── Create.cshtml
└── Edit.cshtml
<!-- Views/Shared/_Layout.cshtml -->
<!DOCTYPE html>
<html lang="en-IN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] — NexCoding Academy</title>
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
</head>
<body>
<header>
<nav>
<a asp-controller="Students" asp-action="Index">Students</a>
<a asp-controller="Fees" asp-action="Index">Fees</a>
</nav>
</header>

<main class="container">
@RenderBody()
</main>

<script src="~/js/site.js" asp-append-version="true"></script>
@await RenderSectionAsync("Scripts", required: false)
</body>
</html>
<!-- Views/_ViewStart.cshtml -->
@{
Layout = "_Layout";
}
<!-- Views/_ViewImports.cshtml -->
@using NexCoding.SchoolPortal.Web
@using NexCoding.SchoolPortal.Web.Models
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

Without @addTagHelper, every tag helper is silently inert. asp-for, asp-action and asp-validation-for are emitted into the HTML as unknown attributes with no error — so a form posts nowhere and a link goes to #. When tag helpers appear to do nothing, check _ViewImports.cshtml first.

asp-append-version="true" appends a content hash to the URL, so a deployed CSS change is picked up rather than served from cache.

RenderSectionAsync("Scripts", required: false)required: true throws on any page that omits the section. And a section defined inside a partial never reaches the layout, which is a common source of "why is my script not loading".

Razor essentials

@model StudentListViewModel

@{
ViewData["Title"] = "Students";
}

<h1>Students</h1>

@if (Model.Results.TotalCount == 0)
{
<p class="text-muted">No students match this search.</p>
}
else
{
<table class="table">
<thead>
<tr>
<th scope="col">Roll number</th>
<th scope="col">Name</th>
<th scope="col">Class</th>
</tr>
</thead>
<tbody>
@foreach (var student in Model.Results.Items)
{
<tr>
<td>@student.RollNumber</td>
<td>@student.Name</td>
<td>@student.ClassName - @student.Section</td>
</tr>
}
</tbody>
</table>
}

Razor HTML-encodes every @ expression. A parent name entered as <script>alert(1)</script> renders as visible text, not executable script. That is XSS protection you get for free — and @Html.Raw(value) throws it away, which is why it must never touch user input.

Formatting and null handling:

@student.DateOfBirth.ToString("dd MMM yyyy")
@Model.TotalFees.ToString("N2")
@(Model.TotalFees - Model.PaidAmount)
@(student.Address ?? "Not recorded")

@() is needed when the expression contains characters Razor would otherwise treat as markup, and for a value followed immediately by text.

@* Razor comment *@ is removed at compile time; <!-- HTML comment --> is sent to the browser.

Keep logic out of the view. A @foreach computing totals is untestable and invisible to anyone reading the C#. Compute it in the controller or the service and put the result on the view model.

Tag helpers

<form asp-action="Create" method="post">
<div asp-validation-summary="ModelOnly" class="alert alert-danger"></div>

<div class="field">
<label asp-for="Input.Name" class="control-label"></label>
<input asp-for="Input.Name" class="form-control" />
<span asp-validation-for="Input.Name" class="text-danger"></span>
</div>

<div class="field">
<label asp-for="Input.ClassName"></label>
<select asp-for="Input.ClassName" asp-items="Model.ClassOptions" class="form-control">
<option value="">-- Select class --</option>
</select>
<span asp-validation-for="Input.ClassName" class="text-danger"></span>
</div>

<button type="submit" class="btn btn-primary">Save</button>
<a asp-action="Index" class="btn btn-secondary">Cancel</a>
</form>

@section Scripts {
<partial name="_ValidationScriptsPartial" />
}
Tag helperGenerates
asp-forname, id, value, and data-val-* validation attributes
asp-action, asp-controllerAn href or form action from the route table
asp-route-{name}A route value
asp-pageA Razor Page URL
asp-items<option> elements
asp-validation-forOne field's error message
asp-validation-summaryThe list of errors
asp-append-versionA cache-busting hash

asp-for derives everything from the model property, including the name that model binding needs. Hand-writing name is how binding silently fails — the value arrives, matches nothing, and the property stays at its default.

_ValidationScriptsPartial loads jQuery Validation. Without it the data-val-* attributes are ignored and validation only happens on the server — the page still works, it just makes a round trip for every mistake.

Reusable fragments:

<partial name="_StudentCard" model="student" />

Never Html.Partial — the synchronous version can deadlock under load. Use the tag helper or Html.PartialAsync.

Razor Pages

The same framework, organised by page rather than by controller.

Pages/
├── Shared/_Layout.cshtml
├── _ViewImports.cshtml
├── _ViewStart.cshtml
└── Students/
├── Index.cshtml
├── Index.cshtml.cs
├── Create.cshtml
└── Create.cshtml.cs
@page
@model NexCoding.SchoolPortal.Pages.Students.IndexModel
@{
ViewData["Title"] = "Students";
}

<h1>Students</h1>

<form method="get">
<input asp-for="SearchTerm" class="form-control" />
<button type="submit">Search</button>
</form>
public class IndexModel : PageModel
{
private readonly IStudentService _studentService;

public IndexModel(IStudentService studentService)
{
_studentService = studentService;
}

[BindProperty(SupportsGet = true)]
public string? SearchTerm { get; set; }

public PagedResult<StudentDto> Results { get; set; } = new();

public async Task OnGetAsync(CancellationToken ct)
{
Results = await _studentService.SearchAsync(User.GetSchoolId(), SearchTerm, 1, 20, ct);
}
}

@page must be the first line. Without it the file is not routable and the URL returns 404 — the single most common Razor Pages mistake.

The URL comes from the file path: Pages/Students/Index.cshtml serves /Students. Route parameters go on the directive:

@page "{publicId:guid}"

Handlers

RequestMethod
GET /StudentsOnGet / OnGetAsync
POST /StudentsOnPost / OnPostAsync
POST /Students?handler=DeleteOnPostDelete / OnPostDeleteAsync

Define OnGet or OnGetAsync, never both — the framework throws AmbiguousMatchException at run time.

public async Task<IActionResult> OnPostSaveAsync(CancellationToken ct) { }
public async Task<IActionResult> OnPostDeleteAsync(Guid publicId, CancellationToken ct) { }
<button type="submit" asp-page-handler="Save">Save</button>
<button type="submit" asp-page-handler="Delete" asp-route-publicId="@Model.PublicId">Delete</button>

The Async suffix is dropped in the handler name. OnPostSaveAsync is selected by asp-page-handler="Save"; writing "SaveAsync" silently falls back to the unnamed OnPost or 404s.

BindProperty

[BindProperty]
public StudentInput Input { get; set; } = new();

[BindProperty(SupportsGet = true)]
public string? SearchTerm { get; set; }

[BindProperty] binds on POST, PUT and DELETE — not GET, unless you opt in. That default is deliberate: binding query values into model properties on every GET invites over-posting. Use SupportsGet only for genuine query parameters.

Bind an input model, never the entity:

// Dangerous — binds every property, including ones the form never showed
[BindProperty] public Student Student { get; set; } = new();

An attacker adds SchoolId=7 to the POST body and moves a student to another school. The form never rendered that field; the binder does not care.

public class StudentInput
{
[Required, StringLength(100)]
public string Name { get; set; } = string.Empty;

[Required]
[RegularExpression(@"^NCA-\d{4}-\d{4}$", ErrorMessage = "Format: NCA-2024-0012")]
public string RollNumber { get; set; } = string.Empty;

[Required]
public string ClassName { get; set; } = string.Empty;
}

SchoolId and Status are set server-side from the signed-in user, never from the request.

Post-Redirect-Get

public async Task<IActionResult> OnPostAsync(CancellationToken ct)
{
LoadOptions(); // repopulate before anything can return Page()

if (!ModelState.IsValid)
{
return Page(); // failure — redisplay with errors
}

var schoolId = User.GetSchoolId();

if (await _studentService.RollNumberExistsAsync(schoolId, Input.RollNumber, null, ct))
{
ModelState.AddModelError("Input.RollNumber",
"This roll number is already used by another student.");
return Page();
}

await _studentService.CreateAsync(schoolId, Input, ct);

StatusMessage = $"Student {Input.Name} was added.";

return RedirectToPage("./Index"); // success — redirect
}

Fail → Page(). Succeed → RedirectToPage().

Returning Page() after a successful save leaves the POST in the browser's address bar, so F5 re-submits it. The browser shows a "Confirm Form Resubmission" dialog that users click through without reading, and the result is a duplicate student, a duplicate payment, a duplicate receipt number. This is a real production bug prevented by one line.

Two more rules in that method:

LoadOptions() runs first, before the ModelState check. A POST is a new request, so ClassOptions is empty unless reloaded. Returning Page() without it renders an empty dropdown and the user's selection has vanished.

The duplicate check and the database unique constraint both exist. The check gives a clean field-level message; the constraint wins when two users submit simultaneously.

The equivalent in MVC:

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(StudentInput input, CancellationToken ct)
{
if (!ModelState.IsValid)
{
await LoadOptionsAsync(ct);
return View(input);
}

await _studentService.CreateAsync(User.GetSchoolId(), input, ct);

TempData["StatusMessage"] = "Student added.";

return RedirectToAction(nameof(Index));
}

[ValidateAntiForgeryToken] is required on MVC POST actions. Razor Pages validates automatically.

TempData

A redirect starts a new request, so ordinary properties are gone. TempData survives exactly one.

[TempData]
public string? StatusMessage { get; set; }
@if (!string.IsNullOrEmpty(Model.StatusMessage))
{
<div class="alert alert-success" role="alert">@Model.StatusMessage</div>
}

role="alert" means a screen reader announces it; a purely visual banner is invisible non-visually.

TempData is read-once and is serialised into a cookie by default. Short strings only — never an entity.

Choosing between them

MVCRazor Pages
Organised byControllerPage
Files per screen3, in different folders2, adjacent
URL fromRoute configurationFile path
Entry pointAction methodsOnGet / OnPost
Best forShared logic, complex routingForms, CRUD, page-based sites

Razor Pages for server-rendered CRUD. Everything about one screen is in two adjacent files, and a controller does not grow to 600 lines covering nine unrelated screens.

MVC when several screens genuinely share controller logic, when routing is complex, or when the codebase already uses it.

They mix freely in one project, and both use the same routing, binding, validation, tag helpers and DI. Neither is more capable; the difference is organisation.

For a SPA frontend, neither — that is a Web API, covered in the next articles.

Errors you will hit

MessageCauseFix
InvalidOperationException: The view 'Index' was not foundWrong folder or nameViews live in Views/<Controller>/<Action>.cshtml
The model item passed into the ViewDataDictionary is of type X but requires YPassed the wrong modelMatch @model to what you pass
Tag helpers render as plain attributes_ViewImports.cshtml missing the @addTagHelperAdd it
Form posts nothingInputs missing name, or no asp-forUse asp-for
Page.OnPost never runsHandler name mismatch, or missing antiforgery tokenCheck asp-page-handler
Antiforgery token errorsForm posted without the tokenLet the tag helper add it

Razor views are found by convention. Views/Students/Index.cshtml for StudentsController.Index — a view in the wrong folder produces a runtime error, not a compile one.

Common mistakes

  • Missing @addTagHelper, so every tag helper is silently inert
  • Missing @page, giving a 404 on a page that clearly exists
  • Both OnGet and OnGetAsync
  • asp-page-handler="SaveAsync" with the suffix
  • Hand-written name attributes instead of asp-for
  • Binding the entity rather than an input model — over-posting
  • SupportsGet = true everywhere
  • Returning Page() after a successful save — duplicates on refresh
  • Not reloading select lists before return Page()
  • Html.Raw on user input
  • Missing [ValidateAntiForgeryToken] on an MVC POST
  • A section defined in a partial, never reaching the layout
  • Business logic in the view

Practice

The course exercise is MVC/Razor basics plus a CRUD page.

  1. Build a Razor Pages student list with search, and a create form with validation.
  2. Delete the @page line and confirm the 404. Restore it.
  3. Remove @addTagHelper from _ViewImports.cshtml. Confirm the form posts nowhere and no error appears.
  4. Return Page() after a successful save. Press F5 and confirm the duplicate student.
  5. Change it to RedirectToPage and confirm F5 is now safe.
  6. Remove LoadOptions() from OnPost, fail validation, and confirm the dropdown renders empty.
  7. Bind the Student entity instead of StudentInput. POST an extra SchoolId field with a different value and confirm the student moves school.
  8. Switch to the input model and confirm the extra field is ignored.
  9. Add a named handler for delete. Write asp-page-handler="DeleteAsync" and confirm it fails, then drop the suffix.
  10. Define both OnGet and OnGetAsync. Record the exception.
  11. Set a student name to <script>alert(1)</script> and render it. Confirm it displays as text. Then render it with Html.Raw and confirm it executes.
  12. Add a TempData status message shown after a redirect. Refresh and confirm it is gone.
  13. Build the same list screen as an MVC controller and view. Compare the file count and where each piece of logic lives.
  14. Remove [ValidateAntiForgeryToken] from the MVC POST and explain what it was protecting against.

Exercises 4, 7 and 11 are three real defects — a duplicate record, a tenant breach and an XSS hole.

You can now

  • Build a server-rendered CRUD screen with MVC or Razor Pages
  • Pass a typed model to a view
  • Use tag helpers for forms and links
  • Say where Razor looks for a view
  • Handle antiforgery tokens correctly

Review questions

  1. What happens when @addTagHelper is missing, and why is it hard to spot?
  2. Why must a successful POST redirect rather than return the page?
  3. Why bind an input model rather than the entity?
  4. Why must select lists be repopulated before returning Page()?

Next: Model binding and validation