Skip to main content

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 / OnPost handlers (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 and ModelState.
  • Explain Post-Redirect-Get and use TempData to carry a message across the redirect.
  • Use Razor expressions, code blocks, control structures, directives, layout, sections and partials.
  • Build a form that creates a GameConfiguration and a page that renders a GameState board as an HTML table.
Demo code

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>
Entity or record?

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 nameRuns onChosen how
OnGet()GET /Configs/Createverb only
OnPost()POST /Configs/Createverb only
OnGetAsync()GETsame as OnGet; the Async suffix is ignored for matching
OnPostSaveAsync()POST ...?handler=Saveverb + handler name from the URL
OnPostDeleteAsync()POST ...?handler=Deleteverb + 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.

TemplateMatchesHandler parameter
@page "{id:guid}"/Games/Play/3f2a-...Guid id
@page "{id:int}"/Configs/Edit/7int id
@page "{name}"/Configs/Edit/Classicstring name
@page "{id:guid?}"with or without the idGuid? 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.

danger

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 A7.
  • 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.com is 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​

DirectivePurpose
@pagemakes the file routable; optional route template
@model Ttype of Model
@using Nsnamespace import, as in C#
@inject IService Namepulls 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 call RenderBody() 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 with Layout = null or another name.
  • Pages/_ViewImports.cshtml — directives inherited by every page under that folder: @using, @addTagHelper, @namespace. Add @using GameEngine here once and EGamePiece is 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">&nbsp;</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.

tip

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:

  1. How does Razor Pages choose which handler method to run? — By name: On + verb (Get / Post) + optional handler name from ?handler= or the route + optional Async suffix, which is ignored for matching.
  2. What is the difference between Page(), RedirectToPage() and NotFound()? — Page() renders this page's view with the current model; RedirectToPage() returns 302 and the browser makes a new GET; NotFound() returns 404 with no page.
  3. 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.
  4. 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.
  5. What does ModelState.IsValid tell 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.
  6. 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.
  7. What is TempData and 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.
  8. What do _ViewImports.cshtml and _ViewStart.cshtml do? — _ViewImports supplies @using / @addTagHelper directives to every view in the folder tree; _ViewStart runs code before every page, normally setting the layout.