12 January 2023

A unified template for a Blazor server app to handle both Blazor and Microsoft Identity pages

Blazor uses legacy .NET Core authentication pages, rather than a native Blazor implementation. This makes it difficult to use the same template for both... but not impossible.

05 Nov 2023 - This blog post was originally written for .NET 6 Blazor. Thankfully, with .NET 8 we get an updated identity system implemented with .razor controls and MainLayout.razor, so we no longer need to hack around the issue in this way.

Pretty much any web application you're likely to build will need some kind of user management system; one that allows for users to create and manage their login, and that authenticates users and then decides what they can and cannot do on the application.

Fortunately Microsoft has a fairly comprehensive system for doing this in .NET core which you can choose to install when creating a new Blazor app. The catch is that it is not really Blazor. If you install the default Blazor server project from Visual Studio, including the Authentication option, you will get this when you start the app:

The default Blazor project screenshot

Unfortunately, when you click "Register" at the top, you get taken to a page with a completely different (and significantly naffer) design:

The default Blazor project identity pages

Ideally, we want all our front end pages - Blazor ones, and the Microsoft Identity scaffolding - to look similar, like they belong in the same site. And we want to minimize maintenance and reduce duplication in design work by getting both to use the same template.

The Blazor pages parts

When you look in more detail at how a Blazor site is built, you can see the structure looks like this for Blazor pages:

  • /Pages/_Host.cshtml - this is the base of all Blazor pages, it's the root page implented as a Razor Page.
  • /Pages/_Layout.cshtml - this page layout runs within the _Host and holds references to important files like CSS and the Blazor javascript library.
  • /Shared/MainLayout.razor - this is rendered where the @RenderBody() in the _Layout is, and has the basic page structure as well as tags corresponding to razor controls like Shared/NavMenu.razor and /Shared/LoginDisplay.razor. A @Body() statement is where each page's individual content will be rendered.

The Microsoft Identity pages parts

These have a separate structure.

  • /Pages/Shared/_Layout.cshtml - this is the default layout file which is specified for the identity pages in /Areas/Identity/Pages/_ViewStart.cshtml. The page is rendered where the @RenderBody() statement is. There will be a reference to a _LoginPartial control.

While these files are largely separate from each other, we can see that both sets of pages use a _Layout file. So the plan is to try to get both systems to use the same _Layout file and then move as much of the template as possible into that, so we can share that code between both sides.

Starting off...

First move is to change the _Layout for the identity pages by editing /Areas/Identity/Pages/_ViewStart.cshtml. Unfortunately this blows up with an error.

InvalidOperationException: The following sections have been defined but have not been rendered by the page at '/Pages/_Layout.cshtml': 'Scripts'. To ignore an unrendered section call IgnoreSection("sectionName").

This turns out to be fairly easy to fix. We need to copy code from /Pages/Shared/_Layout.cshtml that renders the scripts into our Blazor /Pages/_Layout.cshtml, just above the close body tag.

@await RenderSectionAsync("Scripts", required: false)

Now our code runs, and our Register page is served through the same _Layout as our main Blazor pages. Unfortunately, it looks largely unstyled and is missing important parts like the NavMenu and LoginDisplay.

The register page using main Blazor layout

This is because in the Blazor pages part, it is the MainLayout which holds the NavMenu and LoginDisplay, but the Identity pages do not use (and cannot use) the MainLayout.

The only way around this is to move elements from our /Shared/MainLayout.razor into the /Pages/_Layout.cshtml, so they will be available in both Blazor and Identity pages.

At present, our /Pages/_Layout.cshtml looks like this:

 @using Microsoft.AspNetCore.Components.Web
@namespace BlazorApp1.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <base href="~/" />
    <link rel="stylesheet" href="css/bootstrap/bootstrap.min.css" />
    <link href="css/site.css" rel="stylesheet" />
    <link href="BlazorApp1.styles.css" rel="stylesheet" />
    <component type="typeof(HeadOutlet)" render-mode="ServerPrerendered" />
</head>
<body>
    @RenderBody()

    <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>
    @await RenderSectionAsync("Scripts", required: false)
</body>
</html>

You can see that aside from the Blazor error dialog which is normally hidden, there is nothing really to display in this page. Just the render statement.

Our /Shared/MainLayout.razor looks like this:

 @inherits LayoutComponentBase

<PageTitle>BlazorApp1</PageTitle>

<div class="page">
    <div class="sidebar">
        <NavMenu />
    </div>

    <main>
        <div class="top-row px-4 auth">
            <LoginDisplay />
            <a href="https://docs.microsoft.com/aspnet/" target="_blank">About</a>
        </div>

        <article class="content px-4">
            @Body
        </article>
    </main>
</div>

You can see this holds the layout of Blazor pages, including the topbar, sidebar, main content section and also the NavMenu and LoginDisplay controls. Let's copy this structure into our /Pages/_Layout.cshtml and then replace the NavMenu and LoginDisplay with Razor Pages style component references, and also put the @RenderBody() where the @Body() was.

 @using Microsoft.AspNetCore.Components.Web
@namespace BlazorApp1.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <base href="~/" />
    <link rel="stylesheet" href="css/bootstrap/bootstrap.min.css" />
    <link href="css/site.css" rel="stylesheet" />
    <link href="BlazorApp1.styles.css" rel="stylesheet" />
    <component type="typeof(HeadOutlet)" render-mode="ServerPrerendered" />
</head>
<body>
    <div class="page">
        <div class="sidebar">
            <component type="typeof(Shared.NavMenu)" render-mode="Static" />
        </div>

        <main>
            <div class="top-row px-4 auth">
                <component type="typeof(Shared.LoginDisplay)" render-mode="ServerPrerendered" />
                <a href="https://docs.microsoft.com/aspnet/" target="_blank">About</a>
            </div>

            <article class="content px-4">
                @RenderBody()
            </article>
        </main>
    </div>

    <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>
    @await RenderSectionAsync("Scripts", required: false)
</body>
</html>

The /Shared/MainLayout.razor is now much simpler, since most of it was moved out.

 @inherits LayoutComponentBase

<PageTitle>BlazorApp1</PageTitle>

@Body

We're getting close, but now we're getting an error because of the /Shared/LoginDisplay.razor.

InvalidOperationException: Authorization requires a cascading parameter of type Task. Consider using CascadingAuthenticationState to supply this

Cascading authentication

When this LoginDisplay control was in the /Shared/MainLayout.razor, it had the AuthenticationState cascading down to it, via the App.razor.

 <CascadingAuthenticationState>
    <Router AppAssembly="@typeof(App).Assembly">
        <Found Context="routeData">
            <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
            <FocusOnNavigate RouteData="@routeData" Selector="h1" />
        </Found>
        <NotFound>
            <PageTitle>Not found</PageTitle>
            <LayoutView Layout="@typeof(MainLayout)">
                <p role="alert">Sorry, there's nothing at this address.</p>
            </LayoutView>
        </NotFound>
    </Router>
</CascadingAuthenticationState>

So let's apply a cascading authentication state to the LoginDisplay control by adding those tags around the existing code:

 <CascadingAuthenticationState>
    <AuthorizeView>
        <Authorized>
            <a href="Identity/Account/Manage">Hello, @context.User.Identity?.Name!</a>
            <form method="post" action="Identity/Account/LogOut">
                <button type="submit" class="nav-link btn btn-link">Log out</button>
            </form>
        </Authorized>
        <NotAuthorized>
            <a href="Identity/Account/Register">Register</a>
            <a href="Identity/Account/Login">Log in</a>
        </NotAuthorized>
    </AuthorizeView>
</CascadingAuthenticationState>

When we run the page now, we're tantalizingly close. Both the home (Blazor) and register (Identity) pages look very similar, and have the menu systems visible. We just seem to be lacking some styling.

The register page using main Blazor layout and visible menus

The issue here is that the majority of the page layout CSS was in the isolated CSS file MainLayout.razor.css, which is not available outside of the MainLayout where most of our layout code now sits. So we need to move that CSS into the main site CSS, so it is available to all pages. This is pretty simple - just move ALL the CSS that is in MainLayout.razor.css to the end of /wwwroot/css/site.css.

Success!

When we run it now, we have a nice looking Blazor home page, with everything working:

The home page, just like new

When we click on the register link, we no longer get jarringly dumped into fugly Identity pages, but instead see the page formatted in the same template as the Blazor pages!

Identity pages matching the Blazor pages

Now everything uses the same template, we can make CSS and other changes there and they feed through to the Blazor pages and the Microsoft Identity account pages too!

You can grab the full code here:

cactusoft/BlazorUnifiedTemplate

Ooops. Some kind of error occurred. We have logged it.