Your First Blazor Web App: Template Options, Project Structure, App.razor, Routes.razor and Hot Reload
The --interactivity option of dotnet new blazor puts the code that runs in the browser into a separate .Client project for WebAssembly or Auto; two registrations in Program.cs and MapRazorComponents<App> produce the endpoints, and App.razor and Routes.razor set up the page skeleton and routing. We add Bookstore.Admin to Bookstore.slnx with central package management and show route parameters and constraints, hot reload with dotnet watch and the .NET 11 template changes with real output.
Version note: The code in this post was built and run with .NET 10 (SDK 10.0.404, ASP.NET Core 10.0.15); the template comparison in the .NET 11 section was made with the .NET 11 SDK 11.0.100.
Predict First
In the previous post we created Bookstore.Admin with a single command: dotnet new blazor -int Auto. How many projects would the same command have produced with -int Server? If we had added --all-interactive, would the template's sample Counter.razor page contain a @rendermode line? And one more: the first line of Program.cs in Bookstore.Admin is using Bookstore.Admin.Client.Pages;. What happens if you delete the template's sample pages and build? Write your answers down; we will see all of them with real output in this post.
Template Options
The output of dotnet new blazor --help has three Blazor-specific options: -int|--interactivity (None, Server, WebAssembly, Auto; default Server), -ai|--all-interactive and -e|--empty. To see what each one produces, I created all of them in an empty folder with --no-restore and compared them:
| Command | Projects | Interactive registrations in Program.cs | Difference |
|---|---|---|---|
-int None | 1 | none, only AddRazorComponents() | no Counter and no ReconnectModal; everything is Static SSR |
-int Server | 1 | AddInteractiveServerComponents() | @rendermode InteractiveServer in Counter.razor |
-int WebAssembly | 2 (with .Client) | AddInteractiveWebAssemblyComponents() | Counter in .Client; no ReconnectModal because there is no circuit |
-int Auto | 2 (with .Client) | both | Counter in .Client, @rendermode InteractiveAuto |
-int Server --all-interactive | 1 | AddInteractiveServerComponents() | <Routes @rendermode="InteractiveServer" /> in App.razor, no @rendermode on pages |
-int Auto --empty | 2 | both | no NavMenu, Counter, Weather or Bootstrap-based sample styling |
Two decisions come out of the table. The first is the .Client project: if you choose WebAssembly or Auto, the code that will be downloaded to the browser lives in a separate project, because that project is compiled with a different SDK (Microsoft.NET.Sdk.BlazorWebAssembly) and its output is served to the browser under _framework/. The server project references .Client; the opposite is not possible. That split is also a security boundary: if a database connection, a secret or a server-only service gets into .Client, it is downloaded to the browser. The second decision is where interactivity starts: in the default template, pages are Static SSR and a page or component that should be interactive chooses its own @rendermode. --all-interactive puts the render mode at the very top, on the Routes component, and the whole app becomes interactive. In Bookstore.Admin we follow the first path; the panel's list and detail pages work well enough with Static SSR, and interactivity will come only where it is really needed. The details of that decision are in post 04.
Adding It to the Existing Solution
The template is written for an empty folder; the Bookstore repository has its own rules. When we ran the command in the repository root in 01, restore stopped with this error (paths shortened):
src/Bookstore.Admin/Bookstore.Admin.csproj : error NU1008: The following PackageReference items cannot define a value for Version: Microsoft.AspNetCore.Components.WebAssembly.Server. Projects using Central Package Management must define a Version value on a PackageVersion item. For more information, visit https://aka.ms/nuget/cpm/gettingstarted
src/Bookstore.Admin.Client/Bookstore.Admin.Client.csproj : error NU1008: The following PackageReference items cannot define a value for Version: Microsoft.AspNetCore.Components.WebAssembly. Projects using Central Package Management must define a Version value on a PackageVersion item. For more information, visit https://aka.ms/nuget/cpm/gettingstarted
Restore failed.
Bookstore uses central package management, keeping package versions in a single Directory.Packages.props file, and with it switched on a PackageReference in a csproj cannot have its own Version attribute. The template, however, writes the version into the csproj. The fix is what we did in 01: move the version to the central file; dotnet new does not know about it, so this step is done by hand for every new project. TreatWarningsAsErrors from the repository's Directory.Build.props also applies to the new projects; the template's code compiles without warnings so nothing broke, but every warning in our own code will now break the build.
The template also produced src/Bookstore.Admin.sln. Bookstore's solution is Bookstore.slnx; we do not want a second solution file. We delete the .sln and add the two projects to the existing solution:
dotnet sln Bookstore.slnx add src/Bookstore.Admin/Bookstore.Admin.csproj src/Bookstore.Admin.Client/Bookstore.Admin.Client.csproj
Project `src/Bookstore.Admin/Bookstore.Admin.csproj` added to the solution.
Project `src/Bookstore.Admin.Client/Bookstore.Admin.Client.csproj` added to the solution.
<Solution>
<Folder Name="/src/">
<Project Path="src/Bookstore.Admin.Client/Bookstore.Admin.Client.csproj" />
<Project Path="src/Bookstore.Admin/Bookstore.Admin.csproj" />
<Project Path="src/Bookstore.Api/Bookstore.Api.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/Bookstore.Api.Tests/Bookstore.Api.Tests.csproj" />
</Folder>
</Solution>
Now dotnet build Bookstore.slnx compiles the API, the tests and the panel together. When I deleted the template's sample pages (Counter.razor, Weather.razor) and ran the first build, the answer to the third question arrived:
src/Bookstore.Admin/Program.cs(1,30): error CS0234: The type or namespace name 'Pages' does not exist in the namespace 'Bookstore.Admin.Client' (are you missing an assembly reference?) [src/Bookstore.Admin/Bookstore.Admin.csproj]
The Bookstore.Admin.Client.Pages namespace existed only because of Counter.razor; when the file went, the namespace went with it and the using in Program.cs stopped compiling. A Razor component's namespace comes from its folder, and if no component is left in that folder, the namespace is gone too. Deleting the using line is enough.
Program.cs: Two Registrations, One Endpoint
The panel's current Program.cs is what the template generated plus a typed HttpClient for the Bookstore API:
using Bookstore.Admin.Components;
using Bookstore.Admin.Services;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents()
.AddInteractiveWebAssemblyComponents();
string apiBaseUrl = builder.Configuration["BookstoreApi:BaseUrl"]
?? throw new InvalidOperationException("BookstoreApi:BaseUrl is missing (see appsettings.json).");
builder.Services.AddHttpClient<BookstoreApiClient>(client => client.BaseAddress = new Uri(apiBaseUrl));
WebApplication app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseWebAssemblyDebugging();
}
else
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
app.UseHsts();
}
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
app.UseHttpsRedirection();
app.UseAntiforgery();
app.MapStaticAssets();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode()
.AddInteractiveWebAssemblyRenderMode()
.AddAdditionalAssemblies(typeof(Bookstore.Admin.Client._Imports).Assembly);
app.Run();
Three pieces belong to Blazor. AddRazorComponents() registers the services needed to render Razor components on the server; on its own it is enough for Static SSR. AddInteractiveServerComponents() adds the circuit infrastructure, meaning the SignalR hub and circuit management; AddInteractiveWebAssemblyComponents() adds what is needed to prerender WebAssembly components and send them to the browser. On the pipeline side, MapRazorComponents<App>() produces an endpoint for every routable component in the app (every .razor file with @page) and uses App as the root component. AddInteractiveServerRenderMode() and AddInteractiveWebAssemblyRenderMode() in the chain enable those modes on these endpoints; the first one, for example, maps the /_blazor hub. If you register the service and forget to add the render mode, the app starts, but the request fails with a 500 when the first component in that mode is rendered. This is the exception in the log when I deleted the AddInteractiveServerRenderMode() line and requested /diagnostics/render-modes:
System.InvalidOperationException: A component of type 'Bookstore.Admin.Client.Diagnostics.RenderModeProbe' has render mode 'InteractiveServerRenderMode', but the required endpoints are not mapped on the server. When calling 'MapRazorComponents', add a call to 'AddInteractiveServerRenderMode'. For example, 'builder.MapRazorComponents<...>.AddInteractiveServerRenderMode()'
The message also tells you what to do; Blazor's error messages are usually this explicit.
The AddAdditionalAssemblies line makes sure pages in the .Client assembly are found as endpoints too; /diagnostics/render-modes is in the server project, but RenderModeProbe from 01 is in .Client. UseAntiforgery() validates the CSRF token on form posts; we will see it with form posts in 05. MapStaticAssets() provides the fingerprinted, compressed file serving we covered in the static assets post of the ASP.NET Core MVC and Razor Pages series; since .NET 10, blazor.web.js is also served that way, as a static web asset.
The UseStatusCodePagesWithReExecute("/not-found", ...) line works together with the NotFound.razor page that ships with the .NET 10 template: a request that matches no endpoint renders the /not-found page with a 404 status code. UseWebAssemblyDebugging() runs only in Development, so that you can debug the WebAssembly code in the browser from the IDE.
App.razor, Routes.razor and the Layout
What _Layout.cshtml is in MVC, App.razor is in a Blazor Web App: the HTML skeleton of the page:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/en/" />
<ResourcePreloader />
<link rel="stylesheet" href="@Assets["lib/bootstrap/dist/css/bootstrap.min.css"]" />
<link rel="stylesheet" href="@Assets["app.css"]" />
<link rel="stylesheet" href="@Assets["Bookstore.Admin.styles.css"]" />
<ImportMap />
<link rel="icon" type="image/png" href="favicon.png" />
<HeadOutlet />
</head>
<body>
<Routes />
<ReconnectModal />
<script src="@Assets["_framework/blazor.web.js"]"></script>
</body>
</html>
App is always Static SSR; the render modes documentation states clearly that making the root component interactive isn't supported. That is why --all-interactive gives the render mode not to App but to Routes and HeadOutlet inside it. HeadOutlet moves the content pages write with <PageTitle> and <HeadContent> into <head>. ResourcePreloader (.NET 10) produces preload links for the framework files WebAssembly will need; ImportMap writes the import map for fingerprinted JavaScript modules; the @Assets["..."] expression returns a file's fingerprinted name. ReconnectModal is the dialog that appears when the Interactive Server connection drops; it ships with the .NET 10 template with its own .razor.css and .razor.js files, and because it does not inject its styles inline, it is compatible with a Content Security Policy.
<Router AppAssembly="typeof(Program).Assembly" AdditionalAssemblies="new[] { typeof(Client._Imports).Assembly }" NotFoundPage="typeof(Pages.NotFound)">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>
Router finds the component that matches the request's address and renders it with RouteView; if the page did not pick its own layout with @layout, DefaultLayout, that is MainLayout, is used. AdditionalAssemblies points to .Client here as well; the line in Program.cs is for endpoints, this one is for interactive routing. The NotFoundPage parameter arrived in .NET 10 and replaced the old <NotFound> render fragment. FocusOnNavigate moves focus to the h1 on every page change; that is how a screen reader user learns they are on a new page.
MainLayout.razor is a LayoutComponentBase: the page's content goes where @Body is. The blazor-error-ui block below it is shown when there is an unhandled exception in an interactive component. _Imports.razor applies its @using lines to every .razor file in its folder and subfolders; it is the counterpart of _ViewImports.cshtml in Razor Pages. In Bookstore.Admin I added Bookstore.Admin.Client.Contracts and Bookstore.Admin.Services there.
Routing with @page
The only thing that makes a component a page is the @page directive. The panel's book details page is a component that takes a route parameter:
@page "/books/{Isbn}"
@inject BookstoreApiClient Api
<PageTitle>@(book?.Title ?? "Book")</PageTitle>
@if (book is null)
{
<h1>Book not found</h1>
<p>No book with ISBN <code>@Isbn</code>.</p>
}
else
{
<h1>@book.Title</h1>
<dl>
<dt>Author</dt><dd>@book.Author</dd>
<dt>ISBN</dt><dd>@book.Isbn</dd>
<dt>Price</dt><dd>@book.Price.ToString("N2") TRY</dd>
<dt>Available</dt><dd>@book.Available</dd>
</dl>
}
@code {
private BookDetails? book;
[Parameter]
public string Isbn { get; set; } = "";
protected override async Task OnParametersSetAsync() => book = await Api.GetBookAsync(Isbn);
}
The {Isbn} segment binds to the [Parameter] property with the same name; the match is case insensitive. I load the data in OnParametersSetAsync, because when the user moves to another book on an interactive page, the component is not created again, only its parameter changes; OnInitializedAsync does not run again in that case. The full lifecycle is the topic of the next post. The order page has a route constraint:
@page "/orders/{OrderId:int}"
@inject BookstoreApiClient Api
<PageTitle>Order @OrderId</PageTitle>
<h1>Order @OrderId</h1>
@if (order is null)
{
<p>No order with this number.</p>
}
else
{
<p>@order.Status, placed @order.PlacedAtUtc.ToString("u")</p>
<ul>
@foreach (OrderLineView line in order.Items)
{
<li>@line.Quantity x @line.Title = @line.LineTotal.ToString("N2") @order.Currency</li>
}
</ul>
<p>Total: <strong>@order.Total.ToString("N2") @order.Currency</strong></p>
}
@code {
private OrderView? order;
[Parameter]
public int OrderId { get; set; }
protected override async Task OnParametersSetAsync() => order = await Api.GetOrderAsync(OrderId);
}
{OrderId:int} requires the segment to be an integer and converts the value to int. The constraints Blazor supports are type constraints: bool, datetime, decimal, double, float, guid, int, long and nonfile. Constraints such as minlength or regex from MVC are not there; you check whether an ISBN has 13 digits inside the page. Optional parameters ({Text?}) and catch-all parameters that capture the rest of the URL ({*Path}) are supported.
I created order number 1 in the local copy of the API and sent requests to the panel's addresses with curl:
GET / -> 200
GET /books -> 200
GET /books/9780441172719 -> 200
GET /books/123 -> 200
GET /orders/1 -> 200
GET /orders/abc -> 404
GET /nope -> 404
<h1>Dune</h1>
<dl><dt>Author</dt><dd>Frank Herbert</dd>
<dt>ISBN</dt><dd>9780441172719</dd>
<dt>Price</dt><dd>489.00 TRY</dd>
<dt>Available</dt><dd>23</dd></dl>
/orders/abc does not satisfy the constraint, so it matched no endpoint; UseStatusCodePagesWithReExecute rendered NotFound.razor with a 404 status code. /books/123, however, returned 200: the route matched, the page was rendered and it showed the API's 404 as a "Book not found" message. For a search engine or for monitoring that is wrong; the address of a book that does not exist should return 404. We will fix it in 05 with NavigationManager.NotFound() from .NET 10.
Connecting to the API
The panel's data comes from the Bookstore API. I put the records that mirror the JSON the API returns into the .Client project, in a Contracts folder; in 04 a component running in the browser will need the same types:
namespace Bookstore.Admin.Client.Contracts;
// Mirrors the JSON returned by Bookstore.Api; kept in .Client so WebAssembly components can use it too.
public sealed record BookSummary(string Isbn, string Title, string Author, string Category, decimal Price, int PublishedYear);
public sealed record BookDetails(
string Isbn, string Title, string Author, string Category, decimal Price, int PublishedYear, string Description, int Available);
public sealed record PagedResult<T>(IReadOnlyList<T> Items, int Total, int Page, int PageSize);
On the server side there is a typed HttpClient. BaseAddress comes from configuration; appsettings.json holds the API's default address from the README, http://localhost:5080/, and the BookstoreApi__BaseUrl environment variable is enough for a different address:
using System.Net;
using Bookstore.Admin.Client.Contracts;
namespace Bookstore.Admin.Services;
// Typed HttpClient for Bookstore.Api; the base address comes from configuration (BookstoreApi:BaseUrl).
public sealed class BookstoreApiClient(HttpClient http)
{
public async Task<PagedResult<BookSummary>> GetBooksAsync(int page, int pageSize, CancellationToken ct = default) =>
await http.GetFromJsonAsync<PagedResult<BookSummary>>($"api/books?page={page}&pageSize={pageSize}", ct)
?? new PagedResult<BookSummary>([], 0, page, pageSize);
public Task<BookDetails?> GetBookAsync(string isbn, CancellationToken ct = default) =>
GetOrNullAsync<BookDetails>($"api/books/{Uri.EscapeDataString(isbn)}", ct);
public Task<OrderView?> GetOrderAsync(int orderId, CancellationToken ct = default) =>
GetOrNullAsync<OrderView>($"api/orders/{orderId}", ct);
private async Task<T?> GetOrNullAsync<T>(string uri, CancellationToken ct) where T : class
{
using HttpResponseMessage response = await http.GetAsync(uri, ct);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<T>(ct);
}
}
A component gets this class with @inject BookstoreApiClient Api; we do not new up an HttpClient inside the component. For now the client can only be used in code that runs on the server. We will deal with how a component running in the browser reaches the Bookstore API in 04, and with a shared abstraction for Server and WebAssembly in 09.
Hot Reload
When you run the app with dotnet watch instead of dotnet run during development, the changes you save are applied to the running app without a restart:
dotnet watch --project src/Bookstore.Admin
The watch output when I changed the text in Home.razor and saved it:
dotnet watch 🔥 Hot reload enabled. For a list of supported edits, see https://aka.ms/dotnet/hot-reload.
dotnet watch 💡 Press Ctrl+R to restart.
dotnet watch ⌚ Loading projects ...
dotnet watch ⌚ Loaded 2 project(s) in 0.5s.
dotnet watch ⌚ Waiting for changes
dotnet watch ⌚ File updated: ./src/Bookstore.Admin/Components/Pages/Home.razor
dotnet watch 🔥 C# and Razor changes applied in 108ms.
The change was applied to the running process in 108 ms and the next request returned the new text. Hot reload handles most changes to markup, method bodies and CSS. If you change code that runs only once at startup, such as Program.cs, the change compiles but has no visible effect; as the hint in the output says, restart the app with Ctrl+R. A change made while an Interactive Server page is open also reaches the page in the browser, while the state in the circuit is kept; sometimes old state and new code do not fit together and give odd results, and reloading the page is the shortest way out.
What Changed in the Template in .NET 11?
This section is about .NET 11; the series' code stays on .NET 10 LTS. I ran the same command with the .NET 11 SDK (11.0.100) and compared the Program.cs files:
--- net10/Program.cs
+++ net11/Program.cs
@@ -11,20 +11,14 @@
var app = builder.Build();
// Configure the HTTP request pipeline.
-if (app.Environment.IsDevelopment())
+if (!app.Environment.IsDevelopment())
{
- app.UseWebAssemblyDebugging();
-}
-else
-{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
app.UseHttpsRedirection();
-
-app.UseAntiforgery();
app.MapStaticAssets();
app.MapRazorComponents<App>()
Two lines are gone. UseAntiforgery() is no longer in the template; according to the .NET 11 release notes, CSRF protection is on by default through an automatically added middleware, and an explicit call is only needed for special cases. UseWebAssemblyDebugging() has also left the template. In App.razor, <base href="/en/" /> is replaced by the new <BasePath /> component, which writes the app's base path itself. The inline onclick handler in NavMenu.razor has been removed as well, and the code that opens and closes the menu moved into a collocated NavMenu.razor.js module; again the goal is not having to add an exception for inline script to the CSP. A project that stays on .NET 10 does not have to take these changes, but if you use a CSP, you can move the inline handler in NavMenu to a module yourself.
In Production
The template's launchSettings.json picks a random port for every project (for us http://localhost:5037) and also has an https profile. This file is not used in production; addresses come from ASPNETCORE_URLS or Kestrel configuration. The panel's address for the API is also in configuration; providing separate values for Development and Production through environment variables causes fewer mistakes than duplicating appsettings.json per environment.
UseStatusCodePagesWithReExecute routes every 404 to the /not-found page; if you add API-like endpoints, you do not want their 404s turned into an HTML page. Think early about which requests this middleware should apply to. Finally, the wwwroot/lib/bootstrap folder enters the repository with the template: an 8.4 MB folder with its map files and a 230 KB bootstrap.min.css loaded by every page. If the panel is going to use its own design system, removing it at the start is easier than cleaning up later.
Common Pitfalls
Giving a page component the same name as the record it uses. My first attempt at the details page was BookDetails.razor; since the file name is the class name, the component shadowed the BookDetails record from Contracts:
src/Bookstore.Admin/Components/Pages/Books/BookDetails.razor(28,68): error CS0029: Cannot implicitly convert type 'Bookstore.Admin.Client.Contracts.BookDetails' to 'Bookstore.Admin.Components.Pages.Books.BookDetails'
The error message gives both full names, but it is hard to understand at first reading. Giving pages a Page suffix (BookDetailsPage, BookListPage) prevents the clash up front.
Writing the route not only in @page but also in links. <a href="books/@book.Isbn"> is a relative address and works correctly thanks to <base href="/en/" />. Absolute addresses that start with / break when the app is published under a sub-path such as /admin.
Loading data in OnInitializedAsync on a Static SSR page and assuming the same code will run again with the new parameter on an interactive page. With Static SSR every request is a new component, so you do not see the difference; once the same page is interactive, a parameter change only triggers OnParametersSet{Async}.
Putting something that belongs to the server into the .Client project. BookstoreApiClient is in the server project; if an API key ever goes into it, that key must not be downloaded to the browser. The contracts are in .Client on purpose, because they are already the public shape of the API.
A Small Experiment
★☆☆ Create a project with dotnet new blazor -int Server --all-interactive -o Demo, run it and move between pages in the browser. In the Network tab of the developer tools, do you see a new HTML request on every page change? Try the same without --all-interactive.
★★☆ Add a second route to OrderDetailsPage, @page "/orders/{OrderId:int}/{Section?}", and show only the lines or only the total depending on Section. Try /orders/1/items and /orders/1.
★★★ Change the route in BookDetailsPage to "/books/{Isbn:long}" and turn the Isbn property into a long. Which ISBNs no longer open? (Hint: ISBN-13s start with 978 or 979, but in ISBN-10s the last character can be X.) Then do the same with a string parameter and a check inside the page; which one produces a more accurate 404?
The panel's skeleton is ready: it is in the solution, connected to the API, has three pages and all of them are Static SSR. In the next post we move to interactivity: while writing the BookList, BookRow and low-stock warning components on the stock screen, we will look at parameters, EventCallback, data binding and the component lifecycle together with their Angular counterparts.