LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
C# & .NETIntermediate~3 hours

Product Catalog with Razor Pages

Build a browsable product catalog with forms, validation, and a SQL-backed database using MVC.

MVCRazorModel Binding

Overview

A product catalog is where ASP.NET Core MVC's three pieces finally work together end to end: a Product model carrying validation rules as data annotations, a ProductsController handling requests and talking to the database, and Razor .cshtml views rendering HTML that a browser can actually submit a form to. The same [Required]/[Range] attributes you put on Product in Step 1 get used twice — once by EF Core, to decide column constraints in the database, and once by ASP.NET Core's model binder, to validate a submitted form before your controller code ever runs.

By the end of this tutorial you will have a ProductsController with Index, Create, and Edit actions, matching Razor views for each, and an EF Core CatalogDbContext backed by **SQLite** — a real, file-based SQL database (catalog.db), not an in-memory stand-in. That choice matters here specifically because this project is about persistence surviving past a single run: a visitor creates a product through an HTML form, and it needs to still be there the next time the app starts. A production deployment would swap the single UseSqlite(...) call in Step 2 for UseSqlServer(...) or UseNpgsql(...) against SQL Server or PostgreSQL — every controller and view below stays exactly the same.

What You'll Build
  • A Product model with [Required], [Range], and [StringLength] data annotations.
  • A CatalogDbContext backed by SQLite, with migrations applied automatically on startup.
  • A ProductsController with Index (list), Create (GET/POST), and Edit (GET/POST) actions.
  • Razor views using tag helpers (asp-for, asp-action) for model-bound forms and validation messages.
  • Server-side model binding and validation on every submitted form, backed by [ApiController]-free conventional MVC.
  • A redirect-after-POST pattern on every successful save, avoiding duplicate submissions on refresh.

Prerequisites

  • C# fundamentals — classes, properties, and async/await.
  • MVC basics — the idea of a controller action returning a View().
  • HTML forms — <form method="post">, input names, and how a browser packages submitted fields.
  • EF Core basics — DbContext, DbSet<T>, and SaveChangesAsync() (covered in the Task Manager Web API project).
  • Data annotations — attributes like [Required] and [Range] applied to a model's properties.

Project Structure

Running dotnet new mvc -o ProductCatalog scaffolds the conventional ASP.NET Core MVC layout: Program.cs at the root; Controllers/ holding ProductsController.cs; Models/ holding Product.cs; and — the piece a pure Web API project does not have — a Views/ folder, with one subfolder per controller. Every action method in ProductsController that calls the parameterless View() looks for a .cshtml file at Views/Products/{ActionName}.cshtml by convention, so Index() renders Views/Products/Index.cshtml, Create() renders Views/Products/Create.cshtml, and so on — no explicit file path is ever written in the controller code.

This tutorial also adds a Data/ folder for CatalogDbContext.cs, the same convention used in the Task Manager Web API project. Because this project uses a real SQLite file rather than an in-memory database, Program.cs also applies any pending EF Core migrations automatically on startup in Step 2, so catalog.db always matches the current shape of the Product model without a separate manual migration step during local development.

Step 1: Define the Product Model

Every attribute here does double duty. [Required] and [Range] are read by EF Core when it builds the database schema (a [Required] string becomes a NOT NULL column), and the exact same attributes are read again by ASP.NET Core's model binder on every POST to decide whether the submitted data is valid — one set of rules, enforced consistently in two different places. Price is declared as decimal, not double or float, which matters specifically for money: decimal avoids the small binary floating-point rounding errors that make double unsuitable for currency values.

Models/Product.cs
using System.ComponentModel.DataAnnotations;
namespace ProductCatalog.Models;
// Data annotations here are read twice: EF Core uses them to build column
// constraints in the database (Step 2), and ASP.NET Core's model binder uses
// the exact same attributes to validate a submitted form (Steps 4 and 5).
public class Product
{
public int Id { get; set; } // Primary key; EF Core recognizes "Id" by convention
[Required(ErrorMessage = "Product name is required.")]
[StringLength(100, MinimumLength = 2, ErrorMessage = "Name must be 2-100 characters.")]
public string Name { get; set; } = string.Empty;
[Required]
[Range(0.01, 100000, ErrorMessage = "Price must be between 0.01 and 100,000.")]
public decimal Price { get; set; } // decimal, not double/float — the correct C# type for money, avoiding binary rounding error
[Range(0, 10000, ErrorMessage = "Stock cannot be negative.")]
public int StockQuantity { get; set; }
[StringLength(500)]
public string? Description { get; set; } // Nullable: the one genuinely optional field on this model
}

Step 2: Configure the DbContext and Program.cs

AddControllersWithViews() is the MVC equivalent of the Web API project's AddControllers() — it registers everything AddControllers() does, plus Razor view rendering, so a controller action can call View() and have it actually resolve a .cshtml file. app.Database.Migrate() runs inside a DI scope created manually with CreateScope(), since AppDbContext/CatalogDbContext is registered as scoped (one instance per request) but startup code runs before any request exists — CreateScope() is what makes a scoped service resolvable from top-level code like this.

Data/CatalogDbContext.cs
using Microsoft.EntityFrameworkCore;
using ProductCatalog.Models;
namespace ProductCatalog.Data;
public class CatalogDbContext : DbContext
{
public CatalogDbContext(DbContextOptions<CatalogDbContext> options) : base(options)
{
}
public DbSet<Product> Products => Set<Product>();
}
Program.cs
using Microsoft.EntityFrameworkCore;
using ProductCatalog.Data;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews(); // Adds Razor view rendering on top of what AddControllers() alone provides
// SQLite is a real, file-backed SQL database, not an in-memory stand-in — this
// catalog's data survives across restarts. A production deployment would point
// this same call at SQL Server or PostgreSQL instead, changing only the provider
// package and connection string, not any controller or view code below.
builder.Services.AddDbContext<CatalogDbContext>(options =>
options.UseSqlite("Data Source=catalog.db"));
var app = builder.Build();
// Applies any pending EF Core migrations automatically on startup, so catalog.db
// always matches the current Product model. CreateScope() is required because
// CatalogDbContext is registered as scoped, but this code runs before any request exists.
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<CatalogDbContext>();
db.Database.Migrate();
}
app.UseStaticFiles(); // Serves wwwroot/ (CSS and client-side validation scripts referenced by the views in Step 6)
app.UseRouting();
app.MapControllerRoute(
name: "default",
pattern: "{controller=Products}/{action=Index}/{id?}"); // Products/Index becomes this app's default landing page
app.Run();

Before running the app for the first time, create the initial migration from the command line: dotnet ef migrations add InitialCreate (this requires the Microsoft.EntityFrameworkCore.Design and Microsoft.EntityFrameworkCore.Sqlite NuGet packages, plus the dotnet-ef global tool). db.Database.Migrate() in Program.cs then applies that migration to catalog.db automatically every time the app starts.

Step 3: Build the Index Action

The parameterless View(products) call is what makes the controller-to-view connection: it passes products as the view's strongly typed @model, and — because no view name is given explicitly — ASP.NET Core looks for Views/Products/Index.cshtml by convention, matching both the controller name (Products, from ProductsController) and the action name (Index).

Controllers/ProductsController.cs
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using ProductCatalog.Data;
using ProductCatalog.Models;
namespace ProductCatalog.Controllers;
public class ProductsController : Controller // "Controller", not "ControllerBase" — Controller adds View()/PartialView() support
{
private readonly CatalogDbContext _db; // Injected per-request by the DI container configured in Program.cs
public ProductsController(CatalogDbContext db)
{
_db = db;
}
public async Task<IActionResult> Index()
{
var products = await _db.Products.OrderBy(p => p.Name).ToListAsync();
return View(products); // Renders Views/Products/Index.cshtml with products as its @model
}
}

Step 4: Build the Create Action

This is the same GET-shows-a-form, POST-processes-it pattern used by the Contact Form Handler and Blog CMS PHP tutorials, expressed through ASP.NET Core's conventions instead: two methods share the name Create, distinguished by the [HttpPost] attribute on the second one. By the time Create(Product product) runs, model binding has already matched submitted form fields to Product's properties by name and validated every data annotation from Step 1 against the bound values — ModelState.IsValid is simply the yes/no answer to "did all of that validation pass?" [ValidateAntiForgeryToken] requires a hidden token the form tag helper in Step 6 embeds automatically, which is what stops a malicious site from submitting this form on a visitor's behalf (cross-site request forgery).

public IActionResult Create()
{
return View(); // GET: renders Views/Products/Create.cshtml with a blank/default Product
}
[HttpPost]
[ValidateAntiForgeryToken] // Requires the hidden token the form tag helper in Step 6 embeds, blocking cross-site request forgery
public async Task<IActionResult> Create(Product product)
{
// Model binding already ran before this method started: ASP.NET Core matched submitted
// form fields to Product's properties by name, then validated every [Required]/[Range]
// attribute from Step 1 against the bound values.
if (!ModelState.IsValid)
{
return View(product); // Redisplay the form with the same values and validation messages attached
}
_db.Products.Add(product);
await _db.SaveChangesAsync();
return RedirectToAction(nameof(Index)); // Redirect-after-POST avoids a duplicate submission if the visitor refreshes the page
}

Step 5: Build the Edit Action

Edit(int id) follows the same overload pattern as Create, but its GET version first loads the existing product with FindAsync(id) so the form in Step 6 can pre-fill every field with the product's current values. The POST version checks id != product.Id before doing anything else — the route's id and the form's hidden Id field should always agree, and a mismatch here signals something is wrong with the request rather than a normal validation failure.

public async Task<IActionResult> Edit(int id)
{
var product = await _db.Products.FindAsync(id);
if (product is null)
{
return NotFound();
}
return View(product); // Renders Views/Products/Edit.cshtml, pre-filled with the existing product's values
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, Product product)
{
if (id != product.Id) // The route id and the submitted hidden Id field must agree
{
return BadRequest();
}
if (!ModelState.IsValid)
{
return View(product);
}
_db.Update(product); // Marks every property as modified, not just the ones that actually changed
await _db.SaveChangesAsync();
return RedirectToAction(nameof(Index));
}

Step 6: Build the Razor Views

Every view starts with @model declaring exactly what type the controller is expected to pass in — IEnumerable<Product> for the list, Product for the forms — which gives full IntelliSense and compile-time checking inside the .cshtml file itself. Tag helpers like asp-for="Name" and asp-validation-for="Name" read the same data annotations from Step 1 to generate the right <input> attributes and wire up client-side validation automatically, without a single line of validation logic being duplicated by hand in the view.

Views/Products/Index.cshtml
@model IEnumerable<ProductCatalog.Models.Product>
<h1>Product Catalog</h1>
<a asp-action="Create">+ New Product</a>
<table>
<thead>
<tr><th>Name</th><th>Price</th><th>Stock</th><th></th></tr>
</thead>
<tbody>
@foreach (var product in Model)
{
<tr>
<td>@product.Name</td>
<td>@product.Price.ToString("C")</td> <!-- "C" is a standard format string: renders as currency, e.g. $19.99 -->
<td>@product.StockQuantity</td>
<td><a asp-action="Edit" asp-route-id="@product.Id">Edit</a></td>
</tr>
}
</tbody>
</table>
Views/Products/Create.cshtml
@model ProductCatalog.Models.Product
<h1>New Product</h1>
<form asp-action="Create" method="post">
<div asp-validation-summary="ModelOnly"></div> <!-- Lists any validation errors not tied to one specific field -->
<label asp-for="Name"></label>
<input asp-for="Name" />
<span asp-validation-for="Name"></span> <!-- Renders the [Required]/[StringLength] error message for Name, if any -->
<label asp-for="Price"></label>
<input asp-for="Price" />
<span asp-validation-for="Price"></span>
<label asp-for="StockQuantity"></label>
<input asp-for="StockQuantity" />
<span asp-validation-for="StockQuantity"></span>
<label asp-for="Description"></label>
<textarea asp-for="Description"></textarea>
<button type="submit">Save</button>
</form>
Views/Products/Edit.cshtml
@model ProductCatalog.Models.Product
<h1>Edit Product</h1>
<form asp-action="Edit" method="post">
<input type="hidden" asp-for="Id" /> <!-- Round-trips the product's id so the POST action can confirm it matches the route -->
<div asp-validation-summary="ModelOnly"></div>
<label asp-for="Name"></label>
<input asp-for="Name" />
<span asp-validation-for="Name"></span>
<label asp-for="Price"></label>
<input asp-for="Price" />
<span asp-validation-for="Price"></span>
<label asp-for="StockQuantity"></label>
<input asp-for="StockQuantity" />
<span asp-validation-for="StockQuantity"></span>
<label asp-for="Description"></label>
<textarea asp-for="Description"></textarea>
<button type="submit">Save Changes</button>
</form>

Complete Code

Here is the full project across its files, ready to run with dotnet ef migrations add InitialCreate followed by dotnet run after installing the Microsoft.EntityFrameworkCore.Sqlite, Microsoft.EntityFrameworkCore.Design, and Microsoft.EntityFrameworkCore.Tools NuGet packages.

Models/Product.cs
using System.ComponentModel.DataAnnotations;
namespace ProductCatalog.Models;
public class Product
{
public int Id { get; set; }
[Required(ErrorMessage = "Product name is required.")]
[StringLength(100, MinimumLength = 2, ErrorMessage = "Name must be 2-100 characters.")]
public string Name { get; set; } = string.Empty;
[Required]
[Range(0.01, 100000, ErrorMessage = "Price must be between 0.01 and 100,000.")]
public decimal Price { get; set; }
[Range(0, 10000, ErrorMessage = "Stock cannot be negative.")]
public int StockQuantity { get; set; }
[StringLength(500)]
public string? Description { get; set; }
}
Data/CatalogDbContext.cs
using Microsoft.EntityFrameworkCore;
using ProductCatalog.Models;
namespace ProductCatalog.Data;
public class CatalogDbContext : DbContext
{
public CatalogDbContext(DbContextOptions<CatalogDbContext> options) : base(options)
{
}
public DbSet<Product> Products => Set<Product>();
}
Program.cs
using Microsoft.EntityFrameworkCore;
using ProductCatalog.Data;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews();
builder.Services.AddDbContext<CatalogDbContext>(options =>
options.UseSqlite("Data Source=catalog.db"));
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<CatalogDbContext>();
db.Database.Migrate();
}
app.UseStaticFiles();
app.UseRouting();
app.MapControllerRoute(
name: "default",
pattern: "{controller=Products}/{action=Index}/{id?}");
app.Run();
Controllers/ProductsController.cs
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using ProductCatalog.Data;
using ProductCatalog.Models;
namespace ProductCatalog.Controllers;
public class ProductsController : Controller
{
private readonly CatalogDbContext _db;
public ProductsController(CatalogDbContext db)
{
_db = db;
}
public async Task<IActionResult> Index()
{
var products = await _db.Products.OrderBy(p => p.Name).ToListAsync();
return View(products);
}
public IActionResult Create()
{
return View();
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(Product product)
{
if (!ModelState.IsValid)
{
return View(product);
}
_db.Products.Add(product);
await _db.SaveChangesAsync();
return RedirectToAction(nameof(Index));
}
public async Task<IActionResult> Edit(int id)
{
var product = await _db.Products.FindAsync(id);
if (product is null)
{
return NotFound();
}
return View(product);
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, Product product)
{
if (id != product.Id)
{
return BadRequest();
}
if (!ModelState.IsValid)
{
return View(product);
}
_db.Update(product);
await _db.SaveChangesAsync();
return RedirectToAction(nameof(Index));
}
}

Sample Run

Sample Run

Click Run to see what this code prints.

Extend This Project

  • Add a Delete action (GET confirmation view + [HttpPost] DeleteConfirmed) following the same pattern as Edit, guarded by [ValidateAntiForgeryToken].
  • Add a Category model with a one-to-many relationship to Product, and a dropdown in the Create/Edit forms populated from _db.Categories.
  • Add server-side search and pagination to the Index action using .Where()/.Skip()/.Take() before ToListAsync().
  • Add image upload support for each product using IFormFile, saving files under wwwroot/images/ and storing the relative path on Product.
  • Add a custom [Remote] validation attribute that checks product name uniqueness against the database asynchronously as the visitor types.

Summary

You built a complete MVC application where a Product model's data annotations drive both the database schema and form validation, a ProductsController handles the full list/create/edit lifecycle with model binding turning submitted form fields straight into typed objects, and Razor views with tag helpers render forms and validation messages without hand-written HTML-generation logic. The GET-shows-a-form, POST-validates-and-saves pattern you wrote in Create and Edit is the backbone of nearly every data-entry screen you will build in ASP.NET Core MVC from here on.