Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

Blazor Hydra - Hosting Multiple Blazor SPAs on a single Site

0.00/5 (No votes)
25 Feb 2021 1  
How to combine Razor, Blazor WASM and Server SPAs under the same roof
When building a green fields new site with Blazor isn't a option, how do you step migrate your Razor/server Side code and keep it all under the same roof? This article shows you how to do just that. I'll describe how Blazor Server, WASM and Razor sites/applications interact, and how you can run them together on a single AspNetCore web site.

Introduction

Building a green field site with a new framework is great, but we don't all get that luxury. What about those with a classic Razor/Server Page ASPNetCore site wanting to migrate where the Big Bang isn't feasible. You want to run most of your site in classic mode and migrate sections over in bite sized chunks. Maybe run a pilot on a small section first.

This article shows you how to do just that. I'll describe how Blazor Server, WASM and Razor sites/applications interact, and how you can run them together on a single AspNetCore web site.

The first half of this article examines the technical challenges, looking in depth at how some key bits of Blazor work. In the second half, we'll look at a simple practical deployment using the out-of-the-box project templates.

Hydra

Code Repository

Hydra is an implementation of the concepts discussed here and the code will be used extensively in the discussions.

The code is in two repositories:

  • CEC.Blazor.Examples is the main repository. It contains the code for several articles including this one and is the source code repo for the Demo Site on Azure. It uses the methods discussed in this article to host multiple WASM and Server SPAs on one web site. Mongrel is the specific site for this article.
  • Hydra contains the code for the second half of this article. A version of Hydra runs on the Demo Site.

Misconceptions

Blazor Server and Blazor WASM are APPLICATIONS: they're not web sites. Throughout this article, I'll refer to them as SPAs [Single Page Applications]. The Razor site is the only conventional Web Site.

The only true web page in a Blazor SPA is the startup page. Once started, it's an application. It doesn't do posts and gets for pages.

Blazor Pages aren't web pages, they're components.

The Web Server

Look at the code repository and the test site and you'll see a project call Hydra.Web. This is the host web site. It's an out-of-the-box AspNetCore Pages template web site.

Lets look at Startup.cs.

AspNetCore has an Inversion Of Control/Dependency Injection Services container defined by IServiceCollection. If you are unsure what an IOC/DI container is, then do some background reading. We configure this in ConfigureServices.

// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
    services.AddRazorPages();
    services.AddControllersWithViews();
    services.AddServerSideBlazor();
    services.AddServerSideHttpClient();
    services.AddSingleton<IWeatherForecastService, WeatherForecastService>();
    services.AddCECRouting();
}

What you see are a set of services configured directly - such as the WeatherForecastService and calls to a set of ServiceCollectionExtensions like AddServerSideBlazor and AddCECRouting.

To de-mystify these, the code for AddCECRouting is shown below:

public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddCECRouting(this IServiceCollection services)
    {
        services.AddScoped<RouterSessionService>();
        return services;
    }
}

ServiceCollectionExtensions provide a way to configure all the services for a specific function under one roof. They are Extension methods for IServiceCollection. AddServerSideBlazor just adds all the necessary services for Blazor Server, such as NavigationManager and IJSRuntime.

AddServerSideHttpClient is defined in CEC.Blazor.Hydra.Web/Extensions if you want to see a ServiceCollectionExtension implementation.

Configure sets up the Middleware run by the web server. We'll break this down into sections.

The first section is ASPNetCore web server standard code.

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }
    else
    {
        app.UseExceptionHandler("/Error");
        // 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.UseHttpsRedirection();
    app.UseStaticFiles();

Next, we define an IApplicationBuilder.MapWhen for each WASM SPA. I've shown the one for red here. These define the middleware to run for specific site segments - defined by the Url. This is for the Red WASM. Note:

  1. We configure UseBlazorFrameworkFiles with a specific Url segment- we'll look at the significance of this in the WASM section below. The frameworks file path wwwroot/red/_framework/ will be unique providing a unique path for this WASM SPA.
  2. The fallback Page for the segment is /_Red.cshtml.
app.MapWhen(ctx => ctx.Request.Path.StartsWithSegments("/red"), app1 =>
{
    app1.UseBlazorFrameworkFiles("/red");
    app1.UseRouting();
    app1.UseEndpoints(endpoints =>
    {
        endpoints.MapFallbackToPage("/red/{*path:nonfile}", "/_Red");
    });
});

Finally, we define the default segment middleware. Note:

  1. We define MapBlazorHub to map all SignalR traffic to the Blazor Hub.
  2. We define a set of segment specific fallback pages for each Blazor Server SPA with the specific startup page for the SPA.
    app.UseRouting();
    app.UseBlazorFrameworkFiles();
    app.UseEndpoints(endpoints =>
    {
        endpoints.MapBlazorHub();
        endpoints.MapRazorPages();
        endpoints.MapFallbackToPage("/examplesserver/{*path:nonfile}", "/_ExamplesServer");
        endpoints.MapFallbackToPage("/grey/{*path:nonfile}", "/_Grey");
        endpoints.MapFallbackToPage("/blue/{*path:nonfile}", "/_Blue");
        endpoints.MapFallbackToPage("/routing/{*path:nonfile}", "/_Routing");
        endpoints.MapFallbackToPage("/Index");
    });
}

Blazor WASM

Each WASM SPA must have a separate project. <Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly"> declares the project as a WASM SPA and instructs the compiler to build the project as a WASM SPA. There's a single RootComponent defined in Program.

public class Program
{
    public static async Task Main(string[] args)
    {
        var builder = WebAssemblyHostBuilder.CreateDefault(args);
        builder.RootComponents.Add<WASMRedApp>("#app");

        builder.Services.AddScoped(sp => new HttpClient 
        { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });

        await builder.Build().RunAsync();
    }
}

You don't need to stick with app. This declaration defines a different class to App. It basically says render the component of type TComponent in the HTML tag with id of app. Don't be misled by RootComponents and Add. Declare one. You can declare more, but they must all be present on the rendered page, so it's fairly pointless: Layouts achieve the same result with far more flexibility.

The key to multi SPA hosting is specifying a unique StaticWebAssetBasePath in the project definition file.

<PropertyGroup>
  <StaticWebAssetBasePath>red</StaticWebAssetBasePath>
</PropertyGroup>

Remember we used UseBlazorFrameworkFiles("/red") in the middleware map in Startup.cs. This is where things tie together. This is case sensitive!

Mongrel Red Project

The project view above shows all the files in the project.

  1. The Layout and navigation are common to all the Mongrel projects so have moved up to the shared library.
  2. App.razor has moved into Components and been renamed to make it unique. I don't like Apps all over the place.
  3. wwwroot has gone as the startup index.html has moved up to Hydra, as has the CSS.

Once compiled, the bin looks like this:

Mongrel Red Project

Note the _framework directory containing blazor.webaseembly.js and blazor.boot.json. When this project is referenced by Hydra, this directory will be available as red/_framework. Without unique StaticWebAssetBasePaths, all SPAs would be mapped to _framework.

The "Startup" page for the SPA is on Hydra. _Red.cshtml

@page "/spared"
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@{
    Layout = null;
}

<!DOCTYPE html<span class="pl-kos">></span>
<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>Mongrel.WASM.RED</title>
    <base href="/red/" />
    <link href="/css/site.css" rel="stylesheet" />
    <link href="/CEC.Blazor.Hydra.styles.css" rel="stylesheet" />
</head>
<body>
    <div id="app">
        <div class="mt-4" style="margin-right:auto; margin-left:auto; width:100%;">
            <div class="loader"></div>
            <div style="width:100%; text-align:center;"><h4>Web Application Loading</h4></div>
        </div>
    </div>

    <script src="/red/_framework/blazor.webassembly.js"></script>
</body>
</html>

Note:

  1. The base is set to "/red/". Important - You need to leading and trailing slashes.
  2. site.css is a SASS built CSS file.
  3. CEC.Blazor.Hydra.styles.css is the system SASS built CSS incorporating all the reference projects component styles.
  4. We define the specific webassembly script src="/red/_framework/blazor.webassembly.js". It looks for its configuration file in {base}/_framework/blazor.boot.json, where {base} is defined in <base href="">. ./blazor.boot.json is the configuration file for loading the WASM executables. It's important to realise that base needs to have a trailing "/" for this to work.

The final bit of the jigsaw is the mapping in Hydra's Startup.cs. The middleware is configured with the correct BlazorFrameworkFiles and any get requests to /red/ are directed to /_Red.cshtml.

Once the SPA starts, any navigation to known Urls within the application are routed by the SPA. Any external Urls are Http gets. In Hydra and Urls to /red/* are directed to /_Red.cshtml. So /red/counter will start the SPA on the counter page, while /red/nopage will load the SPA but you will see the "Nothing at this Address" message.

You need to be a little careful with the route "/". I prefer to set the SPA home page up with a route "/index" and always reference it that way.

Blazor Server

A Blazor Server SPA is configured a little differently. We don't need a Program.cs with a single entry point. We configure the startup page to load any class implementing IComponent, so our entry point can come from anywhere. For code and route management, define a Razor Library project per SPA: keep everything compartmentalized.

We've already seen the configuration of the main site's Startup.cs, I included the Blazor bit in the discussion above. It:

  1. adds the specific Blazor Services by calling services.AddServerSideBlazor() in ConfigureServices.
  2. adds the Blazor Hub Middleware by calling endpoints.MapBlazorHub() in Configure.

The key bot to understand here is there's one Blazor hub for all the SPAs. Load all the services for each SPA in ConfigureServices. This does place some restrictions and controls on what you can and can't run together, but for most instances, this won't be an issue. Remember Services are only loaded as needed, and cleaned up as they are disposed. Make sure you get the scope of your service right.

Each Server SPA has an endpoint. You don't need to configure these with MapWhen as they all use the same middleware train.Thet're all defined in the default UseEndpoints. You've seen these already in Startup.cs.

The client side of the Server SPA is again defined as a single page. Unlike the WASM page, which is static Html wrapped in a Razor page, the Server SPA page is a razor page. It has a Component entry point that is read by the TagHelper and processed by the server. What Razor does with the page is dictated by rendermode.

@page "/spagrey"
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@{
    Layout = null;
}
<!DOCTYPE html<span class="pl-kos">></span>
<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>Mongrel.Server.GREY</title>
    <base href="/grey" />
    <link href="/css/site.css" rel="stylesheet" />
    <link href="/CEC.Blazor.Hydra.styles.css" rel="stylesheet" />
</head>
<body>
    <component type="typeof(Mongrel.Server.Grey.Components.ServerGreyApp)" 
               render-mode="ServerPrerendered" />

    <script src="_framework/blazor.server.js"></script>
</body>
</html>

Once the browser receives what got produced by the server, it renders the initial static DOM provided and then calls the loaded JavaScript. This where the magic starts. blazor.server.js loads, reads configuration data incorporated in the page and establishes a session with the BlazorHub over SignalR and requests the root IComponent defined on the page by <component> - normally App defined by App.Razor. The Blazor middleware, configured on the server by endpoints.MapBlazorHub() receives the request and establishes the session. The Blazor Hub renders the requested IComponent component tree and passes the changes back over SignalR to the client: on initial re-render, this is the whole DOM. The SPA is now live. Events happen on the page, get passed back to the Hub Session. It handles the events and passes any DOM changes back to the client. Navigation events to known routes get handled by the SPA router: the SPA session persists. A URL outside known routes causes the SPA Router to submit a full http get for the URL: the SPA session ends. I watch the page tab in the browser to see if a request is routed or causes a page refresh.

So what's going on in the SignalR session. The AspNetCore compiled code library for the website contains all the Blazor component code - they're just standard classes. The Service container runs the services, again standard classes within the compiled code library. The heart of the signalR session is the Renderer for the specific SPA session. It builds and rebuilds the DOM from the ComponentTree. Consider a Blazor Server SPA as two entities: one running in the Blazor Hub on the Server doing most of the work; the other bit on the client, intercepting events and passing them back to the server, then re-rendering the page with the DOM changes returned. Events client-to-Server, DOM changes Server-to-Client.

Building the Hydra Site

I've tried to keep this as simple as possible, basing the sites on the out-of-the-box templates. I've made no attempt to consolidate code into shared libraries.

Build the starting solution as follows (all are built from the standard VS 2019 templates):

  1. Hydra.Web - A Razor MVC project. This is the start up project.
  2. Hydra.Grey - A Razor library project
  3. Hydra.Blue - Another Razor library project
  4. Hydra.Steel - A Blazor WASM project (standalone - not NetCore hosted)
  5. Hydra.Red - A Blazor WASM project (standalone - not NetCore hosted)

Initial Project View

Set Hydra.Web as the startup project. If you're working along with me, run the project to check the Razor project compiles and runs.

Clear Out the Two Libraries

Clear the following files from the two libraries:

  • wwwroot and contents
  • Component1.razor
  • ExampleJsInterop.cs

Start with the WASM Projects.

Hydra.Red

Initial Project View

Update the project file. Note StaticWebAssetBasePath is set to red.

<Project Sdk=<span class="pl-pds">"Microsoft.NET.Sdk.BlazorWebAssembly"</span>>

  <PropertyGroup>
    <TargetFramework>net5.0</TargetFramework>
    <StaticWebAssetBasePath>red</StaticWebAssetBasePath>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="5.0.3" />
    <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" 
                      Version="5.0.3" PrivateAssets="all" />
    <PackageReference Include="System.Net.Http.Json" Version="5.0.0" />
  </ItemGroup>

</Project>

Update NavMenu.razor by adding the following link at the top of the links:

<div class="@NavMenuCssClass" @onclick="ToggleNavMenu">
    <ul class="nav flex-column">
        <li class="nav-item px-3">
            <NavLink class="nav-link" href="/Hydra" Match="NavLinkMatch.All">
                <span class="oi oi-home" aria-hidden="true"></span> Hydra
            </NavLink>
        </li>
......

Update MainLayout.razor.css by changing the .sidebar background.

.sidebar {
    background-image: linear-gradient(180deg, #400 0%, #800 70%);
}

Hydra.Steel

Initial Project View

Update the project file. Note the added StaticWebAssetBasePath set to steel.

<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">

  <PropertyGroup>
    <TargetFramework>net5.0</TargetFramework>
    <StaticWebAssetBasePath>steel</StaticWebAssetBasePath>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="5.0.3" />
    <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" 
                      Version="5.0.3" PrivateAssets="all"/>
    <PackageReference Include="System.Net.Http.Json" Version="5.0.0" />
  </ItemGroup>

</Project>

Update NavMenu.razor by adding the following link at the top of the links.

<div class="@NavMenuCssClass" @onclick="ToggleNavMenu">
    <ul class="nav flex-column">
        <li class="nav-item px-3">
            <NavLink class="nav-link" href="/Hydra" Match="NavLinkMatch.All">
                <span class="oi oi-home" aria-hidden="true"></span> Hydra
            </NavLink>
        </li>
......

Update the sidebar in MainLayout.

<div class="sidebar sidebar-steel">
    <NavMenu />
</div>
// Hydra.Steel/Pages/FetchData.razor
....

    protected override async Task OnInitializedAsync()
    {
// Note /sample-data
        forecasts = await Http.GetFromJsonAsync<WeatherForecast[]>
                    ("/sample-data/weather.json");
    }
....

We will use CSS from Hydra.Web for this SPA.

Delete:

  1. The wwwroot folder structure
  2. /Shared/MainLayout.razor.css
  3. /Shared/NavMenu.razor.css

Hydra.Web

Initial Project View

Update the project file. We add in all projects and a package reference to the Blazor WASM Server library.

<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="..\Hydra.Blue\Hydra.Blue.csproj" />
    <ProjectReference Include="..\Hydra.Red\Hydra.Red.csproj" />
    <ProjectReference Include="..\Hydra.Steel\Hydra.Steel.csproj" />
    <ProjectReference Include="..\Razor.Grey\Hydra.Grey.csproj" />
  </ItemGroup>

</Project>

Pull the following folders with files from the Repo:

  1. wwwroot/css
  2. wwwroot/sample-data

wwwroot/css/app.css is a custom built BootStrap distribution.

Add two Razor Pages to the Pages folder:

  1. _Blue.cshtml
  2. _Red.cshtml
  3. _Steel.cshtml
  4. _Grey.cshtml

Delete the associated model cs files associated with these.

// Hydra.Web/Pages/_Red.cshtml
@page "/spared"
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@{
    Layout = null;
}

<!DOCTYPE html<span class="pl-kos">></span>
<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>Hydra.RED</title>
    <base href="/red/" />
    <link href="/red/css/bootstrap/bootstrap.min.css" rel="stylesheet" />
    <link href="/red/css/app.css" rel="stylesheet" />
    <link href="/red/Hydra.Red.styles.css" rel="stylesheet" />
</head>

<body>
    <div id="app">
        <div class="mt-4" style="margin-right:auto; margin-left:auto; width:100%;">
            <div class="loader"></div>
            <div style="width:100%; text-align:center;">
            <h4>Web Application Loading</h4></div>
        </div>
    </div>

    <script src="/red/_framework/blazor.webassembly.js"></script>
</body>

</html>
// Hydra.Web/Pages/_Steel.cshtml
@page "/spasteel"
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@{
    Layout = null;
}

<!DOCTYPE html<span class="pl-kos">></span>
<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>Hydra.Steel</title>
    <base href="/steel/" />
    <link href="/css/app.css" rel="stylesheet" />
    <link href="Hydra.Web.styles.css" rel="stylesheet" />
</head>

<body>
    <div id="app">
        <div class="mt-4" style="margin-right:auto; margin-left:auto; width:100%;">
            <div class="loader"></div>
            <div style="width:100%; text-align:center;">
            <h4>Web Application Loading</h4></div>
        </div>
    </div>

    <script src="/steel/_framework/blazor.webassembly.js"></script>
</body>

</html>
// Hydra.Web/Pages/_Grey.cshtml & _Blue.cshtml_
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@{
    Layout = null;
}
<!DOCTYPE>
<html>
<head>
</head>
<body>
Holding page
</body>
</html>

Startup.cs

We:

  1. add in Support for Razor Pages to the Services.
  2. add a specific Endpoint configuration for the Red and Steel SPAs.
    1. Mounts the _framework files associated with the SPA <StaticWebAssetBasePath>
    2. Sets a Fallback to _Colour.cshtml for all /purple/* URLs
  3. add Blazor Support at the same time.
    1. Add the Blazor Services.
    2. Add the Blazor Hub.
    3. Add the Endpoints for the Blazor Server SPAs.
// Hydra.Web/StartUp.cs
public void ConfigureServices(IServiceCollection services)
{
    services.AddRazorPages();
    services.AddServerSideBlazor();
    // Server Side Blazor doesn't register HttpClient by default
    // Thanks to Robin Sue - Suchiman https://github.com/Suchiman/BlazorDualMode
    if (!services.Any(x => x.ServiceType == typeof(HttpClient)))
    {
        // Setup HttpClient for server side in a client side compatible fashion
        services.AddScoped<HttpClient>(s =>
        {
            <span class="pl-c">// Creating the URI helper needs to wait 
            until the JS Runtime is initialized, so defer it.</span>
            var uriHelper = s.GetRequiredService<NavigationManager>();
            return new HttpClient
            {
                BaseAddress = new Uri(uriHelper.BaseUri)
            };
        });
    }
}

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    ....
    app.UseStaticFiles();

    app.MapWhen(ctx => ctx.Request.Path.StartsWithSegments("/red"), app1 =>
    {
        app1.UseBlazorFrameworkFiles("/red");
        app1.UseRouting();
        app1.UseEndpoints(endpoints =>
        {
            endpoints.MapFallbackToPage("/red/{*path:nonfile}", "/_Red");
        });

    });

    app.MapWhen(ctx => ctx.Request.Path.StartsWithSegments("/steel"), app1 =>
    {
        app1.UseBlazorFrameworkFiles("/steel");
        app1.UseRouting();
        app1.UseEndpoints(endpoints =>
        {
            endpoints.MapFallbackToPage("/steel/{*path:nonfile}", "/_Steel");
        });

    });

    app.UseRouting();

    <span class="pl-c">// default EndPoint Configuration</span>
    app.UseEndpoints(endpoints =>
    {
        endpoints.MapBlazorHub();
        endpoints.MapRazorPages();
        endpoints.MapFallbackToPage("/grey/{*path:nonfile}", "/_Grey");
        endpoints.MapFallbackToPage("/blue/{*path:nonfile}", "/_Blue");
        endpoints.MapFallbackToPage("/Index");
    });
}

Index.cshtml

Update the @page directive and add some buttons for navigation.

@page "/"
....
<div class="text-center">
    <h1 class="display-4">Welcome</h1>
    <p>Learn about <a href="https://docs.microsoft.com/aspnet/core">
                    building Web apps with ASP.NET Core</a>.</p>
    <div class="container">
        <a class="btn btn-danger" href="/red">Hydra Red</a>
        <a class="btn btn-dark" href="/steel">Hydra Steel</a>
        <a class="btn btn-primary" href="/blue">Hydra Blue</a>
        <a class="btn btn-secondary" href="/grey">Hydra Grey</a>
    </div>
</div>

This should all now compile and run.

Hydra.Blue

Initial Project View

This is the library were we'll use to build one of the Blazor Server SPAs.

Project File

Include the following Packages and projects.

<Project Sdk="Microsoft.NET.Sdk.Razor"

  <PropertyGroup>
    <TargetFramework>net5.0</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="5.0.3" />
  </ItemGroup>

</Project>

File Moves and Renames

Copy the Pages and Shared folders from Hydra.Red.

  1. Pages folder
  2. Shared folder
  3. App.razor
  4. _Imports.razor

_Imports.razor

@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using Hydra.Blue
@using Hydra.Blue.Shared
// Hydra.Blue/Pages/Index.razor
@page "/"
@page "/blue"
@page "/blue/index"
....
// Hydra.Blue/Pages/Counter.razor
@page "/counter"
@page "/blue/counter"
....
// Hydra.Blue/Pages/FetchData.razor
@page "/fetchdata"
@page "/blue/fetchdata"
....

    protected override async Task OnInitializedAsync()
    {
// Note /sample-data
        forecasts = await Http.GetFromJsonAsync<WeatherForecast[]>
                    ("/sample-data/weather.json");
    }
....

Update the sidebar in MainLayout.

<div class="sidebar sidebar-blue">
    <NavMenu />
</div>

Update NavMenu.razor:

<div class="@NavMenuCssClass" @onclick="ToggleNavMenu">
    <ul class="nav flex-column">
        <li class="nav-item px-3">
            <NavLink class="nav-link" href="/Hydra" Match="NavLinkMatch.All">
                <span class="oi oi-home" aria-hidden="true"></span> Hydra
            </NavLink>
        </li>
        <li class="nav-item px-3">
            <NavLink class="nav-link" href="blue/index" Match="NavLinkMatch.All">
                <span class="oi oi-home" aria-hidden="true"></span> Home
            </NavLink>
        </li>
        <li class="nav-item px-3">
            <NavLink class="nav-link" href="blue/counter">
                <span class="oi oi-plus" aria-hidden="true"></span> Counter
            </NavLink>
        </li>
        <li class="nav-item px-3">
            <NavLink class="nav-link" href="blue/fetchdata">
                <span class="oi oi-list-rich" aria-hidden="true"></span> Fetch data
            </NavLink>
        </li>
    </ul>
</div>

Update App.razor, changing AppAssembly, which is set to Hydra.Blue.App, so it finds the routes in this assembly.

// Hydra.Blue/App.razor
<Router AppAssembly="@typeof(Hydra.Blue.App).Assembly" PreferExactMatches="@true">
    <Found Context="routeData">
        <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
    </Found>
    <NotFound>
        <LayoutView Layout="@typeof(MainLayout)">
            <p>Sorry, there's nothing at this address.</p>
        </LayoutView>
    </NotFound>
</Router>

Hydra.Grey

Initial Project View

Project File

Include the following Packages and projects.

<Project Sdk="Microsoft.NET.Sdk.Razor">

  <PropertyGroup>
    <TargetFramework>net5.0</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="5.0.3" />
  </ItemGroup>

</Project>

File Moves and Renames

Copy the Pages and Shared folders from Hydra.Blue.

  1. Pages folder
  2. Shared folder
  3. App.razor
  4. _Imports.razor

_Imports.razor

@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using Hydra.Grey
@using Hydra.Grey.Shared
// Hydra.Grey/Pages/Index.razor
@page "/"
@page "/Grey"
@page "/Grey/index"
....
// Hydra.Grey/Pages/Counter.razor
@page "/Grey/counter"
....
// Hydra.Grey/Pages/FetchData.razor
@page "/fetchdata"
@page "/Grey/fetchdata"
....

    protected override async Task OnInitializedAsync()
    {
// Note /sample-data
        forecasts = await Http.GetFromJsonAsync<WeatherForecast[]>
                    ("/sample-data/weather.json");
    }
....
Update the *sidebar* in `MainLayout`.
```html
    <div class="sidebar sidebar-grey">
        <NavMenu />
    </div>

Update NavMenu.razor:

<div class="top-row pl-4 navbar navbar-dark">
    <a class="navbar-brand" href="">Hydra.Grey</a>
    <button class="navbar-toggler" @onclick="ToggleNavMenu">
        <span class="navbar-toggler-icon"></span>
    </button>
</div>

<div class="@NavMenuCssClass" @onclick="ToggleNavMenu">
    <ul class="nav flex-column">
        <li class="nav-item px-3">
            <NavLink class="nav-link" href="/Hydra" Match="NavLinkMatch.All">
                <span class="oi oi-home" aria-hidden="true"></span> Hydra
            </NavLink>
        </li>
        <li class="nav-item px-3">
            <NavLink class="nav-link" href="grey/index" Match="NavLinkMatch.All">
                <span class="oi oi-home" aria-hidden="true"></span> Home
            </NavLink>
        </li>
        <li class="nav-item px-3">
            <NavLink class="nav-link" href="grey/counter">
                <span class="oi oi-plus" aria-hidden="true"></span> Counter
            </NavLink>
        </li>
        <li class="nav-item px-3">
            <NavLink class="nav-link" href="grey/fetchdata">
                <span class="oi oi-list-rich" aria-hidden="true"></span> Fetch data
            </NavLink>
        </li>
    </ul>
</div>

Update App.razor, changing AppAssembly, which is set to Hydra.Grey.App, so it finds the routes in this assembly.

// Hydra.Grey/App.razor
<Router AppAssembly="@typeof(Hydra.Grey.App).Assembly" PreferExactMatches="@true">
    <Found Context="routeData">
        <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
    </Found>
    <NotFound>
        <LayoutView Layout="@typeof(MainLayout)">
            <p>Sorry, there's nothing at this address.</p>
        </LayoutView>
    </NotFound>
</Router>

Hydra.Web

// Hydra.Web/Pages/_Blue.html
@page "/spablue"
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@{
    Layout = null;
}

<!DOCTYPE></span>
<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>Hydra.Blue</title>
    <base href="/blue" />
    <link href="/css/app.css" rel="stylesheet" />
    <link href="/CEC.Blazor.Hydra.styles.css" rel="stylesheet" />
</head>

<body>
    <component type="typeof(Hydra.Blue.App)" render-mode="ServerPrerendered" />

    <script src="_framework/blazor.server.js"></script>
</body>

</html>
// Hydra.Web/Pages/_Grey.html
@page "/spagrey"
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@{
    Layout = null;
}

<!DOCTYPE></span>
<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>Hydra.Grey</title>
    <base href="/grey" />
    <link href="/css/app.css" rel="stylesheet" />
    <link href="/CEC.Blazor.Hydra.styles.css" rel="stylesheet" />
</head>

<body>
    <component type="typeof(Hydra.Grey.App)" render-mode="ServerPrerendered" />

    <script src="_framework/blazor.server.js"></script>
</body>

</html>

This should all now compile and run.

Wrap Up

There's a lot to take in from this article. It took me a while to pull this article together once I'd proven the concepts. Som important concepts to understand:

  1. To write Blazor SPAs, you need to get out of the "Web" paradigm. Think old-school desktop application. A bit retro! If you don't, your SPA could be a bit of a dogs breakfast. Version x will bear little resemblance to version 1.
  2. A WASM SPA is a compiled executable - just like a desktop application. The startup page is a shortcut.
  3. A Server SPA is a pointer to a class in the library running on the web site. The startup page is a server-side shortcut to get it up and running. Once started, the SPA is a two part affair: one half is the browser SPA, the other a session on the Blazor Hub on the server, inexorably joined by a SignalR session.
  4. You need to be very careful in your Url referencing: a missing "/" or a capital letter can blow you out of the water! Stick to the rule that all urls and things that define bits of Urls only used small letters. It's pretty easy to start digging holes I've been there!

Revisions

  • Version 1 - 24th November, 2020 titled Blazor WASM and Server in a Single Project Running on a Single Site
  • Version 2 - 25th February, 2021 - Title change and total rewrite

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here