12.1 - Razor Pages: PageModel, Razor Syntax, Forms
Recap
In 11.1 - HTTP and ASP.NET Core Basics you created WebApp, saw how Program.cs builds the pipeline, and wrote a first page whose OnGet filled a property that the .cshtml printed. This lecture is about everything between the request arriving and the HTML leaving: handler methods, routing, binding form fields to C#, validation, and the Razor syntax you need to render a board.
By the end of this lecture you should be able to:
- Write
OnGet/OnPosthandlers (sync and async), named handlers, and choose the right return value (Page(),RedirectToPage(),NotFound()). - Bind route values, query strings and form fields to handler parameters and
[BindProperty]properties, and validate them with data annotations andModelState. - Explain Post-Redirect-Get and use
TempDatato carry a message across the redirect. - Use Razor expressions, code blocks, control structures, directives, layout, sections and partials.
- Build a form that creates a
GameConfigurationand a page that renders aGameStateboard as an HTML table.
Lecture demos: csharp-2026-fall
The PageModel
Every routable page gets one PageModel instance per request. It is created by the DI container, so its constructor can ask for anything registered in Program.cs — a repository, the DbContext, a logger. The framework picks a handler method by HTTP verb, runs it, and renders the view with Model pointing at the instance.
Services in, data out
Registrations in Program.cs are the same lines as in ConsoleUI (lecture 08.1), only with SQLite from configuration:
using DAL.EF;
using GameEngine;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
var connectionString = builder.Configuration.GetConnectionString("Default")
?? "Data Source=game.db";
builder.Services.AddDbContext<AppDbContext>(o => o.UseSqlite(connectionString));
builder.Services.AddScoped<IConfigRepository, ConfigRepositoryEf>();
builder.Services.AddScoped<IGameRepository, GameRepositoryEf>();
builder.Services.AddRazorPages();
var app = builder.Build();
// ... pipeline exactly as in lecture 11.1 ...
app.Run();
A page that lists configurations straight from the database — Pages/Configs/Index.cshtml.cs:
using DAL.EF;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
namespace WebApp.Pages.Configs;
public class IndexModel(AppDbContext db) : PageModel
{
public List<Configuration> Configs { get; private set; } = [];
public async Task OnGetAsync()
{
Configs = await db.Configurations.OrderBy(c => c.Name).ToListAsync();
}
}
The C# 14 primary constructor (IndexModel(AppDbContext db)) replaces the field-plus-constructor boilerplate; db is usable in every method. Properties are what the view reads, so they are public; private set keeps the view from writing them.
Output in HTML
@page
@model WebApp.Pages.Configs.IndexModel
@{
ViewData["Title"] = "Configurations";
}
<h1>Configurations</h1>
<p>@Model.Configs.Count configuration(s) in the database.</p>
<table class="table">
@foreach (var config in Model.Configs)
{
<tr>
<td>@config.Name</td>
<td>@config.BoardWidth × @config.BoardHeight, win @config.WinLength</td>
<td><a asp-page="./Edit" asp-route-id="@config.Id">Edit</a></td>
</tr>
}
</table>
<a asp-page="./Create" class="btn btn-primary">New configuration</a>
Configuration is the EF entity from lecture 06.1 (it has an Id); GameConfiguration is the engine's record. ConfigRepositoryEf maps between them. Pages that only list or edit rows may use the entity through AppDbContext — this is also what scaffolding generates in lecture 65. Anything that feeds the engine (GameBrain, GameState) goes through IConfigRepository / IGameRepository, so console and web share one code path.
Handler methods
The framework matches handlers by name, not by attributes: On + HTTP verb + optional handler name + optional Async.
| Method name | Runs on | Chosen how |
|---|---|---|
OnGet() | GET /Configs/Create | verb only |
OnPost() | POST /Configs/Create | verb only |
OnGetAsync() | GET | same as OnGet; the Async suffix is ignored for matching |
OnPostSaveAsync() | POST ...?handler=Save | verb + handler name from the URL |
OnPostDeleteAsync() | POST ...?handler=Delete | verb + handler name |
Return types: void / Task always render the page; IActionResult / Task<IActionResult> let you decide — Page() renders, RedirectToPage(...) sends a 302, NotFound() a 404, BadRequest() a 400.
Use the Async variants whenever the handler awaits something — an EF Core query, an async repository call. Never mark a method async without an await inside: with warnings as errors that is a build failure, and the fix is to drop the keyword, not to add a fake await.
Named handlers
One page, several buttons: Save and Delete on the edit page. asp-page-handler on the button (or on the form) adds ?handler=Save to the URL, and the framework routes to OnPostSaveAsync.
<form method="post">
...inputs...
<button type="submit" asp-page-handler="Save" class="btn btn-primary">Save</button>
<button type="submit" asp-page-handler="Delete" class="btn btn-outline-danger"
onclick="return confirm('Delete this configuration?')">Delete</button>
</form>
public async Task<IActionResult> OnPostSaveAsync(Guid id) { /* update entity, SaveChangesAsync, redirect */ }
public async Task<IActionResult> OnPostDeleteAsync(Guid id) { /* remove entity, SaveChangesAsync, redirect */ }
Prefer the handler name in the path instead of the query string? Add it to the route template: @page "{id:guid}/{handler?}" gives /Configs/Edit/3f2a.../Save.
Routing and binding
Route templates
@page accepts a route template appended to the file-based route. Segments in braces become route values; a constraint after the colon restricts what matches.
| Template | Matches | Handler parameter |
|---|---|---|
@page "{id:guid}" | /Games/Play/3f2a-... | Guid id |
@page "{id:int}" | /Configs/Edit/7 | int id |
@page "{name}" | /Configs/Edit/Classic | string name |
@page "{id:guid?}" | with or without the id | Guid? id |
A request that does not satisfy the constraint (/Games/Play/abc) never reaches your handler — it is a 404. That is one fewer if in your code.
Where a value comes from
For every handler parameter and every bound property the binder looks, in this order, in the form body, the route values and the query string, matching by name (case-insensitive). So OnPostMove(Guid id, int row, int col) on Play.cshtml (@page "{id:guid}") receives id from the path and row / col from ?row=1&col=2, and you never parse a string yourself. If a value is missing or unparsable, the parameter keeps its default and ModelState records the error.
[BindProperty]
Handler parameters are fine for a couple of values. A form with five fields is better bound to a property:
[BindProperty]
public ConfigInput Input { get; set; } = new();
[BindProperty]binds on POST only. For GET (search filters, paging) use[BindProperty(SupportsGet = true)] public string? Filter { get; set; }— the value then comes from the query string.[BindProperties]on the class binds every public property — convenient, but a crafted request can then set any property. Prefer explicit[BindProperty].- The bound object's property names must match the form field names:
Input.Name,Input.BoardWidth. The tag helpers in lecture 12.2 generate exactly those names.
Validation
Put the rules on the bound class with attributes from System.ComponentModel.DataAnnotations. The binder runs them and fills ModelState; you check ModelState.IsValid and re-render on failure.
using System.ComponentModel.DataAnnotations;
using GameEngine;
namespace WebApp.Models;
public class ConfigInput
{
[Required, StringLength(32, MinimumLength = 2)]
public string Name { get; set; } = "";
[Range(3, 20)]
public int BoardWidth { get; set; } = 3;
[Range(3, 20)]
public int BoardHeight { get; set; } = 3;
[Range(3, 20)]
public int WinLength { get; set; } = 3;
public GameConfiguration ToConfiguration() => new(Name, BoardWidth, BoardHeight, WinLength);
}
The complete Pages/Configs/Create.cshtml.cs:
public class CreateModel(IConfigRepository configs) : PageModel
{
[BindProperty]
public ConfigInput Input { get; set; } = new();
public void OnGet()
{
}
public IActionResult OnPost()
{
// a rule that involves several fields: add it by hand
if (Input.WinLength > Math.Max(Input.BoardWidth, Input.BoardHeight))
{
ModelState.AddModelError("Input.WinLength", "Win length cannot exceed the board size.");
}
if (!ModelState.IsValid)
{
return Page(); // re-render: inputs keep their values, errors are shown
}
configs.Save(Input.ToConfiguration());
TempData["Message"] = $"Configuration '{Input.Name}' created.";
return RedirectToPage("./Index");
}
}
return Page() on failure is important: the same PageModel instance, with the user's values still in Input and the errors in ModelState, renders the form again. The client-side validation you get from _ValidationScriptsPartial is a courtesy for the user — the server check is the one that counts, because anyone can post anything with curl.
Never trust a bound value just because the form had a dropdown for it. Validate on the server, and let the engine (GameBrain.GetLegalMoves) have the final word on whether a move is legal.
Post-Redirect-Get and TempData
After a successful POST, do not render — redirect. If OnPost returned HTML directly, the address bar would stay on the POST; pressing F5 would re-submit the form and create a second configuration. RedirectToPage("./Index") sends 302 + Location; the browser follows with a harmless GET, and refreshing repeats the GET.
The redirect is a new request with a new PageModel, so properties set before it are gone. TempData is a small dictionary that survives exactly one following request (it rides in a cookie):
TempData["Message"] = "Configuration saved."; // before the redirect
@* in _Layout.cshtml, so every page can show it *@
@if (TempData["Message"] is string message)
{
<div class="alert alert-info">@message</div>
}
A typed alternative: [TempData] public string? Message { get; set; } on the PageModel reads and writes the same slot. For anything larger than a message, do not use TempData — put the id in the URL and load the data again.
Razor syntax essentials
Default language: HTML. @ switches to C# for one expression; the parser figures out where it ends.
- Implicit expression —
<td>@config.Name</td>,<p>@DateTime.Now</p>. No spaces inside. - Explicit expression —
@(config.BoardWidth * config.BoardHeight)when the expression has spaces or operators. - Encoding — the result is
ToString()-ed and HTML-encoded:@("<b>x</b>")prints the angle brackets literally. That is what stops a configuration named<script>from running in someone's browser.@Html.Raw(...)bypasses it; you will not need that in A6. - Code block —
@{ var size = board.Length; }— statements, no output. HTML inside a block switches back to HTML; a single line of text inside a block needs@:, a multi-line chunk<text>...</text>. - Control structures —
@if / else,@switch,@for,@foreach,@while. The braces are C#, the body is HTML. - Comments —
@* razor comment, never sent to the browser *@;<!-- html comment, sent -->. - Literal
@—user@example.comis fine (Razor only transitions before an identifier,(or a brace); write@@when you really need one.
@if (Model.Configs.Count == 0)
{
<p>No configurations yet.</p>
}
else
{
@for (var i = 0; i < Model.Configs.Count; i++)
{
@:@(i + 1). @Model.Configs[i].Name
<br />
}
}
Directives
| Directive | Purpose |
|---|---|
@page | makes the file routable; optional route template |
@model T | type of Model |
@using Ns | namespace import, as in C# |
@inject IService Name | pulls a service from DI into the view (rarely needed — the PageModel should do the work) |
@section Name { } | content for a RenderSection placeholder in the layout |
@functions { } | C# members local to this view — small formatting helpers |
ViewData is the loosely typed sibling of Model: a string → object dictionary shared between page and layout. The template uses it for the page title; use Model for everything else, because ViewData["X"] gives you no IntelliSense and a cast on every read.
Layout, sections, _ViewImports, _ViewStart
Pages/Shared/_Layout.cshtml— the frame:<html>,<head>, navbar,@RenderBody(), scripts. Every layout must callRenderBody()once.- Sections — the layout declares placeholders with
@await RenderSectionAsync("Scripts", required: false); a page fills one with@section Scripts { ... }. The template has exactly one,Scripts, at the end of<body>, which is where the validation scripts go. Pages/_ViewStart.cshtml— code that runs before every page; the whole file is@{ Layout = "_Layout"; }. A page can still override withLayout = nullor another name.Pages/_ViewImports.cshtml— directives inherited by every page under that folder:@using,@addTagHelper,@namespace. Add@using GameEnginehere once andEGamePieceis known in every view.
@using WebApp
@using GameEngine
@namespace WebApp.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
Both files are hierarchical: a Pages/Games/_ViewImports.cshtml adds to, and can override, the one in Pages/.
Partial views
A partial is a .cshtml fragment rendered inside another view — the board, a status line, a list row. It has no @page, no PageModel, and does not pick up _ViewStart. Name it with a leading underscore and put it in Pages/Shared/ (or next to the pages that use it).
@* Pages/Shared/_Board.cshtml — read-only board preview *@
@model GameState
<table class="board board-small">
@for (var r = 0; r < Model.Board.Length; r++)
{
<tr>
@for (var c = 0; c < Model.Board[r].Length; c++)
{
<td class="cell">@(Model.Board[r][c] == EGamePiece.Empty ? "" : Model.Board[r][c].ToString())</td>
}
</tr>
}
</table>
@* Pages/Games/Index.cshtml — a preview for every saved game *@
@foreach (var game in Model.Games)
{
<partial name="_Board" model="game" />
<a asp-page="./Play" asp-route-id="@game.Id">Continue</a>
}
<partial name="_Board" model="Model.State" /> is the tag helper form; name is looked up in the current folder, then in Shared/. Use the tag helper, not the old @Html.Partial(...) — the synchronous helper is obsolete and blocks the thread.
Putting it together
Creating a GameConfiguration
Pages/Configs/Create.cshtml — one field group per property; the asp-* attributes are tag helpers, explained in lecture 61.
@page
@model WebApp.Pages.Configs.CreateModel
@{
ViewData["Title"] = "New configuration";
}
<h1>New configuration</h1>
<form method="post">
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<div class="mb-3">
<label asp-for="Input.Name" class="form-label"></label>
<input asp-for="Input.Name" class="form-control" />
<span asp-validation-for="Input.Name" class="text-danger"></span>
</div>
@* BoardWidth, BoardHeight, WinLength: the same three lines each *@
<button type="submit" class="btn btn-primary">Create</button>
<a asp-page="./Index">Cancel</a>
</form>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}
GET renders the empty form; POST binds Input, validates, saves through IConfigRepository and redirects to the list, which shows the TempData message.
Rendering the board
Pages/Games/Play.cshtml.cs — the game id is in the URL, every move is a POST, every request loads and saves through the repository. No state lives in the web app between requests.
using GameEngine;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace WebApp.Pages.Games;
public class PlayModel(IGameRepository games) : PageModel
{
public GameState State { get; private set; } = default!;
public EGamePiece Winner { get; private set; }
public bool IsDraw { get; private set; }
public IActionResult OnGet(Guid id)
{
var state = games.Get(id);
if (state == null) return NotFound();
State = state;
var brain = new GameBrain(State);
Winner = brain.CheckWin();
IsDraw = brain.IsDraw();
return Page();
}
public IActionResult OnPostMove(Guid id, int row, int col)
{
var state = games.Get(id);
if (state == null) return NotFound();
var brain = new GameBrain(state);
if (brain.MakeMove(row, col))
{
games.Save(state);
}
else
{
TempData["Message"] = "That move is not legal.";
}
return RedirectToPage(new { id });
}
}
Pages/Games/Play.cshtml — nested @for loops over the jagged array; empty cells become one-button forms that post the move.
@page "{id:guid}"
@model WebApp.Pages.Games.PlayModel
@{
ViewData["Title"] = Model.State.Config.Name;
var board = Model.State.Board;
var gameOver = Model.Winner != EGamePiece.Empty || Model.IsDraw;
}
<h1>@Model.State.Config.Name</h1>
@if (Model.Winner != EGamePiece.Empty)
{
<p class="alert alert-success">@Model.Winner wins!</p>
}
else if (Model.IsDraw)
{
<p class="alert alert-secondary">Draw.</p>
}
else
{
<p>Next move: <strong>@Model.State.NextMoveBy</strong></p>
}
<table class="board">
@for (var r = 0; r < board.Length; r++)
{
<tr>
@for (var c = 0; c < board[r].Length; c++)
{
<td class="cell">
@if (board[r][c] == EGamePiece.Empty && !gameOver)
{
<form method="post" asp-page-handler="Move"
asp-route-id="@Model.State.Id" asp-route-row="@r" asp-route-col="@c">
<button type="submit" class="cell-button"> </button>
</form>
}
else
{
@Symbol(board[r][c])
}
</td>
}
</tr>
}
</table>
@functions {
private static string Symbol(EGamePiece piece) => piece switch
{
EGamePiece.X => "X",
EGamePiece.O => "O",
_ => ""
};
}
Each empty cell posts to /Games/Play/3f2a...?row=1&col=2&handler=Move — id sits in the path because the route template claims it, row and col fall through to the query string, and the antiforgery token is added as a hidden field automatically. The handler makes the move, saves, and redirects back to the same URL; the next GET renders the new board. Open the page in two browser windows and play against yourself: each window is just a client sending POSTs to the same game id.
A 20 × 20 board of one-button forms is still a few kilobytes of HTML — fine. Style the table in wwwroot/css/site.css (table-layout: fixed, square cells, a border) rather than fighting Bootstrap. Highlighting the last move is Model.State.Moves[^1] plus one extra CSS class on that cell.
Self preparation QA
Be prepared to explain topics like these:
- How does Razor Pages choose which handler method to run? — By name:
On+ verb (Get/Post) + optional handler name from?handler=or the route + optionalAsyncsuffix, which is ignored for matching. - What is the difference between
Page(),RedirectToPage()andNotFound()? —Page()renders this page's view with the current model;RedirectToPage()returns302and the browser makes a new GET;NotFound()returns404with no page. - Where can a bound value come from, and in what order is it searched? — Form body, route values, query string; matched by name, case-insensitive.
- Why does
[BindProperty]not bind on GET by default, and how do you enable it? — GET should not carry state-changing input, so binding is opt-in with[BindProperty(SupportsGet = true)], used for filters and paging. - What does
ModelState.IsValidtell you and what do you do when it is false? — Whether binding and all data-annotation checks passed; if not,return Page()so the form re-renders with the user's values and the error messages. - What problem does Post-Redirect-Get solve? — Refreshing after a POST would re-submit the form; redirecting to a GET makes refresh harmless and keeps a clean URL.
- What is
TempDataand how long does it live? — A small dictionary, stored in a cookie, that survives exactly one following request — ideal for a "saved" message across the redirect. - What do
_ViewImports.cshtmland_ViewStart.cshtmldo? —_ViewImportssupplies@using/@addTagHelperdirectives to every view in the folder tree;_ViewStartruns code before every page, normally setting the layout.