This article will provide an insight into how Blazor SPAs work and the real differences between a Server and WASM Blazor SPA.
Code Repository
The code repository for the article is at https://github.com/ShaunCurtis/AllinOne.
The Solution and Projects
Create a new solution called Blazor using the Blazor WebAssembly
template. Don't choose to host it on Aspnetcore. You will get a single project called Blazor.
Now add a second project to the solution using the ASP.NET Core Web App template. Call it Blazor.Web
. Set it as the startup project.
The solution should now look like this:
Blazor Project Changes
The solution runs the WASM context in a sub-directory on the web site. To get this working, there are a few modifications that need to be made to the Blazor project.
- Move the contents of wwwroot to Blazor.Web and delete everything in wwwroot.
- Add a
StaticWebAssetBasePath
entry to the project file set to wasm
. This is case sensitive in the context in which it is used, so stick to small letters. - Add the necessary packages.
The project file should look like this:
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<StaticWebAssetBasePath>wasm</StaticWebAssetBasePath>
<TargetFramework>net5.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly"
Version="5.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer"
Version="5.0.4" PrivateAssets="all" />
<PackageReference Include="System.Net.Http.Json" Version="5.0.0" />
</ItemGroup>
<ItemGroup>
<Folder Include="wwwroot\" />
</ItemGroup>
</Project>
MainLayout
MainLayout
needs to be modified to handle both contexts. The solution changes the colour scheme for each context. WASM Teal and Server Steel.
@inherits LayoutComponentBase
<div class="page">
@**@
<div class="@_sidebarCss">
<NavMenu />
</div>
<div class="main">
<div class="top-row px-4">
<a href="https://docs.microsoft.com/aspnet/" target="_blank">About</a>
</div>
<div class="content px-4">
@Body
</div>
</div>
</div>
@code {
[Inject] NavigationManager NavManager { get; set; }
private bool _isWasm => NavManager?.Uri.Contains
("wasm", StringComparison.CurrentCultureIgnoreCase) ?? false;
private string _sidebarCss => _isWasm ? "sidebar sidebar-teal" : "sidebar sidebar-steel";
}
Add the following CSS styles to the component CSS file below .sidebar
.
.sidebar {
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
}
.sidebar-teal {
background-image: linear-gradient(180deg, rgb(0, 64, 128) 0%, rgb(0,96,192) 70%);
}
.sidebar-steel {
background-image: linear-gradient(180deg, #2a3f4f 0%, #446680 70%);
}
NavMenu
Add code and markup - it adds a link to switch between contexts.
<div class="top-row pl-4 navbar navbar-dark">
@**@
<a class="navbar-brand" href="">Blazor</a>
<button class="navbar-toggler" @onclick="ToggleNavMenu">
<span class="navbar-toggler-icon">
</button>
</div>
<div class="@NavMenuCssClass" @onclick="ToggleNavMenu">
<ul class="nav flex-column">
@**@
<li class="nav-item px-3">
<NavLink class="nav-link"
href="@_otherContextUrl" Match="NavLinkMatch.All">
<span class="oi oi-home" aria-hidden="true"> @_otherContextLinkName
</NavLink>
</li>
<li class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="oi oi-home" aria-hidden="true"> Home
</NavLink>
</li>
<li class="nav-item px-3">
<NavLink class="nav-link" href="counter">
<span class="oi oi-plus" aria-hidden="true"> Counter
</NavLink>
</li>
<li class="nav-item px-3">
<NavLink class="nav-link" href="fetchdata">
<span class="oi oi-list-rich" aria-hidden="true"> Fetch data
</NavLink>
</li>
</ul>
</div>
@code {
[Inject] NavigationManager NavManager { get; set; }
private bool _isWasm => NavManager?.Uri.Contains
("wasm", StringComparison.CurrentCultureIgnoreCase) ?? false;
private string _otherContextUrl => _isWasm ? "/" : "/wasm";
private string _otherContextLinkName => _isWasm ? "Server Home" : "WASM Home";
private string _title => _isWasm ? "AllinOne WASM" : "AllinOne Server";
private bool collapseNavMenu = true;
private string NavMenuCssClass => collapseNavMenu ? "collapse" : null;
private void ToggleNavMenu()
{
collapseNavMenu = !collapseNavMenu;
}
}
FetchData.razor
Update the Url for getting forecasts by adding a /
at the start, the file is now in the root and not in wasm
.
protected override async Task OnInitializedAsync()
{
forecasts = await Http.GetFromJsonAsync<WeatherForecast[]>("/sample-data/weather.json");
}
Blazor.Web
Update the project file:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference
Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="5.0.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Blazor\Blazor.csproj" />
</ItemGroup>
</Project>
Add a Razor Page to Pages called WASM.cshtml - the launch page for the WASM SPA.
@page "/wasm"
@{
Layout = null;
}
<!DOCTYPE html<span class="pl-kos">>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,
initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Blazor</title>
@**@
<base href="/wasm/" />
@**@
<link href="/css/bootstrap/bootstrap.min.css" rel="stylesheet" />
<link href="/css/app.css" rel="stylesheet" />
<link href="/wasm/Blazor.styles.css" rel="stylesheet" />
</head>
<body>
<div id="app">Loading...</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
@**@
<script src="/wasm/_framework/blazor.webassembly.js"></script>
</body>
</html>
Add a second Razor Page to Pages called Server.cshtml - the launch page for the Servr SPA.
@page "/"
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@{
Layout = null;
}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,
initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Blazor</title>
<base href="/" />
<link href="/css/bootstrap/bootstrap.min.css" rel="stylesheet" />
<link href="/css/site.css" rel="stylesheet" />
<link href="/wasm/Blazor.styles.css" rel="stylesheet" />
</head>
<body>
<component type="typeof(Blazor.App)" render-mode="ServerPrerendered" />
<div id="blazor-error-ui">
<environment include="Staging,Production">
An error has occurred. This application may no longer respond until reloaded.
</environment>
<environment include="Development">
An unhandled exception has occurred. See browser dev tools for details.
</environment>
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
<script src="_framework/blazor.server.js"></script>
</body>
</html>
Index.cshtml
Update the @page
directive to @page "/index"
.
Startup.cs
Update Startup
to handle WASM and Server middleware paths.
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
public void ConfigureServices(IServiceCollection services)
{
services.AddRazorPages();
services.AddServerSideBlazor();
if (!services.Any(x => x.ServiceType == typeof(HttpClient)))
{
services.AddScoped<HttpClient>(s =>
{
var uriHelper = s.GetRequiredService<NavigationManager>();
return new HttpClient
{
BaseAddress = new Uri(uriHelper.BaseUri)
};
});
}
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Error");
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.MapWhen(ctx => ctx.Request.Path.StartsWithSegments("/wasm"), app1 =>
{
app1.UseBlazorFrameworkFiles("/wasm");
app1.UseRouting();
app1.UseEndpoints(endpoints =>
{
endpoints.MapFallbackToPage("/wasm/{*path:nonfile}", "/wasm");
});
});
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
endpoints.MapBlazorHub();
endpoints.MapRazorPages();
endpoints.MapFallbackToPage("/Server");
});
}
}
Run the Application
The application should now run. It will start in the Server context. Switch to the WASM context via the link in the left menu. You should see the colour change as you switch between contexts.
Adding a DataService
While the above configuration works, it needs some demo code to show how it handles more conventional data services. We'll modify the solution to work with a very basic data services to show the DI and interface concepts that should be used.
Add Data and Services folders to the Blazor project.
WeatherForecast.cs
Add a WeatherForecast
class to Data.
public class WeatherForecast
{
public DateTime Date { get; set; }
public int TemperatureC { get; set; }
public string Summary { get; set; }
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}
IWeatherForecastService.cs
Add a IWeatherForecastService
interface to Services.
public interface IWeatherForecastService
{
public Task<List<WeatherForecast>> GetRecordsAsync();
}
WeatherForecastServerService.cs
Add a WeatherForecastServerService
class to Services. Normally, this would interface to a database, but here we're just creating a set of dummy records.
public class WeatherForecastServerService : IWeatherForecastService
{
private static readonly string[] Summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool",
"Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
private List<WeatherForecast> records = new List<WeatherForecast>();
public WeatherForecastServerService()
=> this.GetForecasts();
public void GetForecasts()
{
var rng = new Random();
records = Enumerable.Range(1, 10).Select(index => new WeatherForecast
{
Date = DateTime.Now.AddDays(index),
TemperatureC = rng.Next(-20, 55),
Summary = Summaries[rng.Next(Summaries.Length)]
}).ToList();
}
public Task<List<WeatherForecast>> GetRecordsAsync()
=> Task.FromResult(this.records);
}
WeatherForecastAPIService.cs
Add a WeatherForecastAPIService
class to Services.
public class WeatherForecastAPIService : IWeatherForecastService
{
protected HttpClient HttpClient { get; set; }
public WeatherForecastAPIService(HttpClient httpClient)
=> this.HttpClient = httpClient;
public async Task<List<WeatherForecast>> GetRecordsAsync()
=> await this.HttpClient.GetFromJsonAsync<List<WeatherForecast>>
($"/api/weatherforecast/list");
}
WeatherForecastController.cs
Finally, add a WeatherForecastController
class to the Blazor.Web project in a Controller folder.
using System.Collections.Generic;
using System.Threading.Tasks;
using Blazor.Data;
using Microsoft.AspNetCore.Mvc;
using <span class="pl-en">MVC = Microsoft.AspNetCore.Mvc;
using Blazor.Services;
namespace Blazor.Web.APIControllers
{
[ApiController]
public class WeatherForecastController : ControllerBase
{
protected IWeatherForecastService DataService { get; set; }
public WeatherForecastController(IWeatherForecastService dataService)
=> this.DataService = dataService;
[MVC.Route("/api/weatherforecast/list")]
[HttpGet]
public async Task<List<WeatherForecast>> GetList() =>
await DataService.GetRecordsAsync();
}
}
Blazor Project Program.cs
Add the API service to Program.cs in the Blazor project, declaring it through its IWeatherForecastService
.
public class Program
{
public static async Task Main(string[] args)
{
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.Services.AddScoped(sp => new HttpClient
{ BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
builder.Services.AddScoped<IWeatherForecastService, WeatherForecastAPIService>();
await builder.Build().RunAsync();
}
}
Blazor.Web Startup.cs
Add the server service to Startup.cs in the Blazor.Web project, again through its IWeatherForecastService
.
public void ConfigureServices(IServiceCollection services)
{
services.AddRazorPages();
services.AddServerSideBlazor();
services.AddScoped<IWeatherForecastService, WeatherForecastServerService>();
.....
}
Building and Run the Project
The solution should now build and run.
How Does It Work?
Fundamentally, the difference between a Blazor Server and a Blazor WASM Application is the context in which it's run. In the solution, all SPA code is built in the Web Assembly project, and used by both the WASM and Server contexts. There's no "shared" code library code, because it's exactly the same front end code with the same entrypoint - App.razor. The different between the two contexts, is the provider of the backend services.
The web assembly project is declared <Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
. It builds both a standard Blazor.dll file and the WASM specific code including the Web Assembly "boot configuration file" blazor.boot.json.
In the web assembly context, the initial page loads blazor.webassembly.js. This loads blazor.boot.json which tells blazor.webassembly.js how to "boot" the Web assembly code in the browser. It runs Program
which builds the WebAssemblyHost
, loads the defined services, and starts the Renderer
which replaces the app html element with the root component specified in Program
. This loads the router, which reads the Url, gets the appropriate component, loads it into the specified layout, and begins the rendering process. SPA up and running.
In the Server context, the server side code picks up the component reference in the initial load page and statically renders it. It passes the rendered page to the client. This loads and runs blazor.server.js, which calls back to the server SignalR Hub and gets the dynamically rendered app root component. SPA up and running. The services container and renderer are in the Blazor Hub - started by calling services.AddServerSideBlazor()
in Startup when the web server starts.
The data services we implemented demonstrate Dependency injection and interfaces. The UI components - in our case FetchData
consume the IWeatherForcastService
service registered in Services. In the WASM context, the services container starts WeatherForecastAPIService
, while in the Server context, the services container starts WeatherForecastServerService
. Two different services, conforming to the same interface and consumed by the UI components using the interface. The UI components don't care which service they consume, it just needs to implement IWeatherForcastService
.
Wrap Up
Hopefully, this article has provided an insight into how Blazor SPAs work and the real differences between a Server and WASM Blazor SPA.
If you are reading this well into the future, the most recent version of this article will be here.
History
- 4th April, 2021: Initial version