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 helper | Generates |
|---|---|
asp-for | name, id, value, and data-val-* validation attributes |
asp-action, asp-controller | An href or form action from the route table |
asp-route-{name} | A route value |
asp-page | A Razor Page URL |
asp-items | <option> elements |
asp-validation-for | One field's error message |
asp-validation-summary | The list of errors |
asp-append-version | A 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
| Request | Method |
|---|---|
GET /Students | OnGet / OnGetAsync |
POST /Students | OnPost / OnPostAsync |
POST /Students?handler=Delete | OnPostDelete / 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
| MVC | Razor Pages | |
|---|---|---|
| Organised by | Controller | Page |
| Files per screen | 3, in different folders | 2, adjacent |
| URL from | Route configuration | File path |
| Entry point | Action methods | OnGet / OnPost |
| Best for | Shared logic, complex routing | Forms, 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
| Message | Cause | Fix |
|---|---|---|
InvalidOperationException: The view 'Index' was not found | Wrong folder or name | Views live in Views/<Controller>/<Action>.cshtml |
The model item passed into the ViewDataDictionary is of type X but requires Y | Passed the wrong model | Match @model to what you pass |
| Tag helpers render as plain attributes | _ViewImports.cshtml missing the @addTagHelper | Add it |
| Form posts nothing | Inputs missing name, or no asp-for | Use asp-for |
Page.OnPost never runs | Handler name mismatch, or missing antiforgery token | Check asp-page-handler |
| Antiforgery token errors | Form posted without the token | Let 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
OnGetandOnGetAsync asp-page-handler="SaveAsync"with the suffix- Hand-written
nameattributes instead ofasp-for - Binding the entity rather than an input model — over-posting
SupportsGet = trueeverywhere- Returning
Page()after a successful save — duplicates on refresh - Not reloading select lists before
return Page() Html.Rawon 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.
- Build a Razor Pages student list with search, and a create form with validation.
- Delete the
@pageline and confirm the 404. Restore it. - Remove
@addTagHelperfrom_ViewImports.cshtml. Confirm the form posts nowhere and no error appears. - Return
Page()after a successful save. Press F5 and confirm the duplicate student. - Change it to
RedirectToPageand confirm F5 is now safe. - Remove
LoadOptions()fromOnPost, fail validation, and confirm the dropdown renders empty. - Bind the
Studententity instead ofStudentInput. POST an extraSchoolIdfield with a different value and confirm the student moves school. - Switch to the input model and confirm the extra field is ignored.
- Add a named handler for delete. Write
asp-page-handler="DeleteAsync"and confirm it fails, then drop the suffix. - Define both
OnGetandOnGetAsync. Record the exception. - Set a student name to
<script>alert(1)</script>and render it. Confirm it displays as text. Then render it withHtml.Rawand confirm it executes. - Add a
TempDatastatus message shown after a redirect. Refresh and confirm it is gone. - Build the same list screen as an MVC controller and view. Compare the file count and where each piece of logic lives.
- 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
- What happens when
@addTagHelperis missing, and why is it hard to spot? - Why must a successful POST redirect rather than return the page?
- Why bind an input model rather than the entity?
- Why must select lists be repopulated before returning
Page()?