Something crazy happened, on Netflix Add version info to your builds in TeamCity

Blazor Server apps in MVC subfolders

Published on Thursday, July 15, 2021 4:00:00 AM UTC in Programming

Were you ever bored and thought to yourself: how could I ruin this perfectly fine evening for me and turn it into a frustrating nightmare? Well, seek no more, I have the best idea for you. Just try to host a Blazor Server app in a sub folder of an existing MVC application, and you're in for some really nerve-wrecking "fun". Admittedly, once you know the solution it's not so hard to understand, but finding a working solution was a rabbit-hole experience for me.

The idea

I have a small application, and I had this idea that it would be a cool opportunity to learn more of Blazor when I migrate the existing SPA backend for administration to Blazor. The containing app is basically a traditional MVC application with some integrated APIs that are used by the separately built traditional SPA, which is deployed in a sub-folder of the app. The simplified structure looks something like:

  • Api: Controllers for the SPA (BFF)
  • Controllers + Views: The MVC views that make the frontend of the actual app
  • wwwroot/admin: Deployment location of the SPA

The SPA is a separate Vue.js/Webpack project I can host in a dev server during development and then tree-shake and minimize into a final build for deployment together with the MVC app.

At first, I was playing with a completely separate Blazor application to see if it's feasible to implement the backend with it. That worked quite well. But when I realized a) that I basically mirror the exact same setup as for the MVC application in terms of what services, DI bindings etc. I'm using, and b) that I'm going to run into issues because the MVC app has some simple local caching which of course has not the slightest idea that another app has changed data in its backend store that now yneeds refreshing, I thought: what if I took the same approach as with the SPA application and simply host the Blazor Server app in the same project as the MVC application? Like:

  • Controllers + Views: The MVC views that make the frontend of the actual app
  • Pages: The Blazor Server app that should run under ~/admin
  • wwwroot: Static files like CSS only, no more SPA

Shouldn't be so hard, right?

Well, I spare you the details of what I went through, but if you've tried to solve this problem yourself, you've very likely gone through a dozen articles to find a working solution, and you've also probably read through the official docs on these topics, just to find that all of this is misleading or outdated, not working at all, and that at some point you've come full circle back to where you started an hour ago. Or, if you're like me, you have to go through this twice to realize you're not getting anywhere.

After carefully looking at the details, partly at the involved sources, I finally found some robust way to achieve this, and in the following give you two different variants you can pick from. They both use the same setup but differ in how you stash away the files of your Blazor Server app so they're cleanly out of the way of the actual frontend MVC application.

Variant 1

The folder structure this setup uses is:

  • wwwroot/admin: static files specific to the Blazor Server app
  • Pages/Admin: All pages, components and shared files for the Blazor Server app

To enable this, you need to basically do two things in your Startup class:

// "ConfigureServices" method
services.AddRazorPages();
services.AddServerSideBlazor();

// "Configure" method
app.MapWhen(ctx => ctx.Request.Path.StartsWithSegments("/admin"), admin =>
{
    admin.UsePathBase("/admin");
    admin.UseStaticFiles();
    admin.UseRouting();
    admin.UseEndpoints(endpoints =>
    {
        // Blazor
        endpoints.MapBlazorHub();
        endpoints.MapFallbackToPage("/Admin/_Host");
    });
});

Make sure to use the correct parameter name (here: admin) inside the configuration callback of the MapWhen. I've been told *cough* that it's easy to accidentally use the outer app parameter instead and then spend half an hour debugging why your pipeline is acting strange.

Explanation:

  • This basically forks your request pipeline to behave differently when the request path starts with "admin". Make sure to put this block of code after the middlewares you want to use for both your Blazor Server app and the existing MVC frontend, but before any specifics that only apply to your MVC app.
  • UseBasePath is more or less a transparent global request path prefix to all the middlewares that follow, including the Blazor endpoints. This removes the requirement to apply any changes to the relative routes in your Blazor Server app at all. It's also important to make sure this is put before the static files middleware, because only then are built-in urls like the one to blazor.server.js or your dynamic CSS styles bundle correctly functioning in combination with actual physical resources.
  • The fallback to your main page (/Admin/_Host) works because the default root directory for Razor Pages is /Pages, so this fallback is resolved to /Pages/Admin/_Host, which is exactly where you put your Blazor app:

image-1.png

This of course is just a sample structure. You are free to organize your files as you like; there's no magic in their discovery other than that they must be in the mentioned root directory /Pages, and that you must make sure your imports and usings in these files are correct.

Again: You do not need to fiddle in any way with the @page directives of the Blazor app, or with the base element in your _Host.cshtml. They just stay at their defaults, and everything will work as expected - not only locally when running in Visual Studio, but also when hosting the final build in IIS. Unnecessary changes to these locations, as recommended in dozens of articles around the net, is part of the "rabbit-hole experience" you want to avoid.

The only quirk you may run into is when you take a closer look at the links to your CSS (and JS) files in your _Host.cshtml. They need to look like the following:

<link rel="stylesheet" href="admin/css/bootstrap/bootstrap.min.css" />
<link href="admin/css/site.css" rel="stylesheet" />
<link href="Blog.styles.css" rel="stylesheet" />

<!-- Rest of the page -->

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

The interesting detail is that some of these links point to physical files (Bootstrap, site.css), while others like your styles bundle and the Blazor JavaScript point to virtual files that are served from outside wwwroot, or files that are only put in wwwroot once you publish a production build, respectively. When you then look at how these files are requested and resolved in the browser, you see something potentially confusing like the following:

image-2.png

Setting the PathBase as described above has the side effect that those dynamically generated or served files look as you would expect, whereas the actual phyical files have a suspicious duplicate "admin" in their path. But that's correct: one is the virtual base path and the other is the actual folder name they reside in. It's just a matter of aesthetics, everything is working and resolved as expected. I personally rather live with this glitch than having a blazor.server.js and other resources bleed into my main app outside the /admin virtual path.

Variant 2

The other folder structure you may want to use is something like:

  • wwwroot/admin: static files specific to the Blazor Server app
  • Admin/*: All pages, components and shared files for the Blazor Server app

So, instead of grouping the parts of the Blazor Server application below the /Pages root, you opt for stashing everything for that app away under a separate top-level Admin folder.

To enable this, you need the following setup in your Startup class:

// "ConfigureServices" method
services.AddRazorPages(options => options.RootDirectory = "/Admin/Pages");
services.AddServerSideBlazor();

// "Configure" method
app.MapWhen(ctx => ctx.Request.Path.StartsWithSegments("/admin"), admin =>
{
    admin.UsePathBase("/admin");
    admin.UseStaticFiles();
    admin.UseRouting();
    admin.UseEndpoints(endpoints =>
    {
        // Blazor
        endpoints.MapBlazorHub();
        endpoints.MapFallbackToPage("/_Host");
    });
});

Explanation:

  • Note that this time we specify a different root directory for the Razor pages search algorithm, which enables putting the pages under the Admin folder instead of the default Pages root.
  • This change is also reflected in the page fallback configuration, which can now point to _Host because this now sits in the logical root (again, just as with the standard template).

The benefit of putting everything in an Admin subfolder might at some point turn into a drawback: because we changed to pages root to that folder, you cannot have additional pages outside it when you decide you want to use Razor pages for other situations as well. The good thing is that once again you do not need to change any details of the @page directives of the Blazor app, or with the base element in your _Host.cshtml, meaning you can easily move the whole app to a different location later, if necessary.

Because we use the same setup for PathBase, you'll see the same slightly confusing behavior for static assets as described in the previous variant. But again it's just an aesthetic issue; the solution is fully functional and successfully tested also when hosted in IIS.

I personally prefer variant 1 because it creates a similar isolation and coherence of everything related to the Blazor app as variant 2, but leaves the possibility to add Razor pages for other features later with zero effort, including the option to add multiple additional Blazor apps simply by configuring more similar pipeline forks using MapWhen.

Tags: Blazor