blog post image
Andrew Lock avatar

Andrew Lock

~12 min read

Enabling prerendering for Blazor WebAssembly apps

In this post I describe the steps to enable server-prerendering for a Blazor WebAssembly application. This post serves as an introduction to some more interesting prerendering approaches I'll be looking at in future posts.

These posts assume that you're already familiar with the basics of Blazor. If not, I suggest looking at the documentation, at the Awesome Blazor resources, or at Chris Sainty's Blazor in Action. Jon Hilton also has a great two-part series covering the steps in this post - he goes into a lot more detail than I do, so it's worth looking into!

What is Prerendering for Blazor WebAssembly?

Blazor WebAssembly allows you to build .NET applications that run entirely in the browser, without a "back-end" application. The application is served entirely as static files in the same way that JavaScript SPAs built with Angular, React, or Vue are.

One of the advantages of hosting an application as static files is that hosting is very cheap (often free). You don't need an Azure App Service or Kubernetes to run your application; you can use free file hosting provided by GitHub Pages or Netlify, for example.

However, there's been an increasing trend back towards rendering code on the server. One of the classic problems with SPAs is the initial load. When you first load your application in the browser, you need to wait for all the required app assets (traditionally JavaScript) to be loaded before the application can do anything. Until it's finished loading, you're stuck with a "loading" screen.

Loading screen

The same is true for Blazor WebAssembly. If you host your Blazor application as static files, then the initial HTML file fetched from the server is just a placeholder; you need to wait for the whole app to be downloaded before anything meaningful is drawn on the screen.

Prerendering side steps the issue by ensuring we build the HTML on the server side, before sending that first HTML page. That means you see the UI immediately, before we've finished downloading the whole app. That can make a significant difference on the perceived speed of your app, as well as making useful information available sooner. Once the app loads, the WebAssembly app takes over, exactly as it would without preloading.

Preloading with WebAssembly and a host app

The main downside to prerendering is that it requires you to run a server application. You can no longer just host your application as static files on GitHub Pages, you have to run an ASP.NET Core application. Whether that trade-off is worth it is something you'll need to consider!

Adding hosting to a static Blazor WebAssembly application

.NET 5.0 introduced Prerendering for Blazor WebAssembly applications. In this post I'm going to start with the basic, static, Blazor WebAssembly template, convert it into a hosted application, and then enable prerendering.

If you're starting from scratch, I suggest you use the hosted option when creating your new Blazor app. This configures hosting for you, so you can skip this step.

Creating the WebAssembly application

Start by creating a new Blazor WebAssembly application. You can do this using the CLI with

dotnet new sln -n BlazorApp1
dotnet new blazorwasm -n BlazorApp1
dotnet sln add BlazorApp1

Alternatively choose "Blazor App" in the Visual Studio New Project dialog. On the Create a new Blazor app screen, select "Blazor WebAssembly App", and leave everything else as is, we'll manually add the hosting app later, just to show what it takes.

The new project dialog

Once Visual Studio has finished, you can build and run your application. It's the default Blazor application you've no doubt seen many times. When you publish this application, you'll get static files that can be hosted on GitHub Pages or Netlify.

For prerendering, we need to host the WebAssembly in an ASP.NET Core application. For now, this application will just serve the static files, so it won't do anything more than we currently have. However, later on we can use it to prerendering the page when it's first requested.

Adding the host application

We're going to create an empty Web Application as the host for our app. Add a new web application using the CLI:

dotnet new web -n BlazorApp1.Server
dotnet sln add BlazorApp1.Server

Or use Visual Studio to create an empty ASP.NET Core Web Application:

Creating a new web application

This creates a very minimal ASP.NET Core web app, which we'll update to host our WebAssembly application.

We'll start by updating the project file. We need to add a <ProjectReference> to the WebAssembly project, and add a reference to the Microsoft.AspNetCore.Components.WebAssembly.Server NuGet package:

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

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

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="5.0.1" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\BlazorApp1\BlazorApp1.csproj" />
  </ItemGroup>

</Project>

Next we'll update the app's Startup class to configure it to host the WebAssembly application:

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace BlazorApp1.Server
{
    public class Startup
    {
        public void ConfigureServices(IServiceCollection services) { }

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

            app.UseRouting();

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapFallbackToFile("index.html");
            });
        }
    }
}

Most of the code in Configure() is standard ASP.NET Core middleware stuff, but there's a few noteworthy points:

  • We're enabling debugging of your WebAssembly application in dev by calling UseWebAssemblyDebugging. For debugging to work correctly, you'll need to update your launchSettings.json file, as shown below.
  • We're telling the ASP.NET Core app to serve the Blazor framework files by calling UseBlazorFrameworkFiles()
  • We're configuring a single "fallback" endpoint, that serves the index.html of the WebAssembly application, no matter which URL is requested. That's a common pattern for SPAs, and ensures that even if the browser requests a path like https://example.com/some/path, the same index.html file is returned. The client-side routing takes care of handling the path once the app is loaded in the browser

The final step is to finish enabling debugging by updating the launchSettings.json for the ASP.NET Core app. Add the inspectUri to your profiles, so that Visual Studio can correctly communicate with the browser during a debug session:

{
  "profiles": {
    "BlazorApp1.Server": {
      "commandName": "Project",
      "dotnetRunMessages": "true",
      "launchBrowser": true,
      // Add this line
      "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
      "applicationUrl": "https://localhost:5001;http://localhost:5000",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    }
  }
}

If you now run your ASP.NET Core application, you should see your Blazor WebAssembly application is served correctly. Now lets look at adding prerendering!

Enabling prerendering for a hosted Blazor WebAssembly app

One of the prerequisites for prerendering a Blazor WebAssembly app in .NET 5.0 is that it is hosted in an ASP.NET Core app, as I showed in the previous section. However, prerendering is not used by default. The HTML file returned when you request a page just contains the bare minimum HTML for the WebAssembly app to hook into.

The HTML doesn't contain much markup

In the image above, you can see that the HTML file only contains a minimal amount of markup - a loading message and a (hidden by default) error message. Once the app loads, it replaces this HTML with the "real" application.

With prerendering, we need to generate the same HTML that the Blazor WebAssembly app will after rendering, and return that in the initial page load. In the example below, which uses prerendering, you can see the HTML contains the menu items, as well as a counter component in the raw HTML:

The HTML contains all the required markup

The preview screen doesn't have any CSS, hence why it looks unstyled, but the HTML is correct, so it will display the same when the browser renders it!

So how do we achieve this? We need to do a few things:

  • Add Razor Page support to our host application
  • Replace the "index.html" page in the WebAssembly app with a "Host" Razor Page in our host application.
  • Pre-render the main WebAssembly app component in the Razor Page
  • Update the Blazor WebAssembly app to use the pre-rendered component
  • Fix dependency injection configuration issues

You can also follow the documentation on Prerendering ASP.NET Core components, but I found that most of the steps there weren't necessary if you're already hosting your Blazor WebAssembly application in an ASP.NET Core app.

1. Add Razor Pages support

The first thing to do is add Razor Pages support to our host app. Update your Startup class to add the Razor Pages services in ConfigureServices(), and map the Razor Page endpoints in Configure(). Finally, update the fall-back endpoint to use a Razor Page (which we'll create shortly) instead of the "index.html" file. Note that you need to change the filename and the method (MapFallbackToPage vs MapFallbackToFile).

public class Startup
{
    public void ConfigureServices(IServiceCollection services) 
    {
        services.AddRazorPages(); // <- Add this
    }

    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        
        //... previous config as before

        app.UseRouting();

        app.UseEndpoints(endpoints =>
        {
            endpoints.MapRazorPages(); // <- Add this
            endpoints.MapFallbackToPage("/_Host"); // <- Change method + file
        });
    }
}

Now we have Razor Pages enabled, we need to add one!

2. Add the Host Razor Page

Create a new Razor Page in your host application inside the Pages folder called _Host.cshtml. You can call the file anything you like, but this appears to be a recommended convention currently. There's no need for a PageModel for the Razor Page, just create a single file in the Pages directory. You can do this with the CLI from the web app root directory using:

dotnet new page -o Pages -n _Host --no-pagemodel

Alternatively, you can use the Visual Studio Add New Item dialog.

Once you have a Razor Page, copy the contents of the wwwroot/index.html file from your WebAssembly app, and paste it in to _Host.cshtml, ensuring that you don't delete the @page directive at the top of the file. Your _Host.cshtml file will look something like this:

@page
<!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>BlazorApp1</title>
        <base href="/" />
        <link href="css/bootstrap/bootstrap.min.css" rel="stylesheet" />
        <link href="css/app.css" rel="stylesheet" />
        <link href="BlazorApp1.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="_framework/blazor.webassembly.js"></script>
    </body>

</html>

You can then delete the wwwroot/index.html file in your WebAssembly project: it's no longer used.

At this point, if you run your application, you should find your WebAssembly application runs exactly as it did before. We haven't enabled prerendering yet, but we've laid the foundations!

3. Configure prerendering in the host app

The _Host.cshtml Razor Page is being returned no matter what URL is requested from the server. We now want to update the Razor Page so that it prerenders the WebAssembly application as part of the request, so that the HTML returned is the same HTML that will be generated on the client.

First, we need to enable Tag Helpers on the Razor Page. Add @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers to the top of the page:

@page
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

<!DOCTYPE html>
<html>
<!--...-->

Next, replace the <div id="app">Loading...</div> element with the following:

<component type="typeof(BlazorApp1.App)" render-mode="WebAssemblyPrerendered" />

This Tag Helper renders the main Blazor WebAssembly component (called App by convention) in the same location that the app would render it.

4. Update your WebAssembly app to use the pre-rendered component

If you run your application now, you'll see the pre-rendered pages, but you'll also immediately see an error in the console, and the Blazor error UI. The error says:

Microsoft.JSInterop.JSException: Could not find any element matching selector '#app'

The problem is that your WebAssembly app is still looking for the <div id="app"> element we removed. Luckily there's a simple fix: delete the line builder.RootComponents.Add<App>("#app"); from Program.cs in your WebAssembly application:

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

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

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

With that, you can view your new app without error. That is, unless you directly request the /fetchdata page (which displays weather forecast data)…

5. Fix dependency injection issues.

The weather forecast page works fine if you load the app first, and then navigate to it client-side:

The Weather forecast page works fine client-side

But if you try to navigate directly to the page, using the host app to trigger server prerendering, then you'll get an error:

Prerendering doesn't work

The problem is that the FetchData.razor component uses an injected HttpClient as a dependency:

@page "/fetchdata"
@inject HttpClient Http

In the WebAssembly app, the HttpClient is configured as a dependency in Program.Main:

public class Program
{
    public static async Task Main(string[] args)
    {
        var builder = WebAssemblyHostBuilder.CreateDefault(args);

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

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

We haven't configured that in the host app, so when the host app tries to create the FetchData.razor component during prerendering, it throws an exception.

You may be able to get away with just registering the same dependencies in both your apps, but that won't always make sense. This post is already getting pretty long though, so I'm just going to hack something together in the host app by registering an HttpClient that points back to itself.

public class Startup
{
    public void ConfigureServices(IServiceCollection services) 
    {
        services.AddRazorPages();
        // register an HttpClient that points to itself
        services.AddSingleton<HttpClient>(sp =>
        {
            // Get the address that the app is currently running at
            var server = sp.GetRequiredService<IServer>();
            var addressFeature = server.Features.Get<IServerAddressesFeature>();
            string baseAddress = addressFeature.Addresses.First();
            return new HttpClient { BaseAddress = new Uri(baseAddress) };
        });
    }
}

This is definitely clumsy, as you're bouncing via the network when you prerender the FetchData page. Jon Hilton shows a much cleaner approach in his post on prerendering - I suggest you take a look if this is something you need!

With the dependency registered, we can now directly load the prerendered /fetchdata page without issue.

Prerendering works again!

Note that you will see a "flash" shortly after loading the /fetchdata page, and the random data changes. This is due to the web assembly app re-fetching data after the initial prerender, so OnInitializedAsync is called twice. Jon Hilton also describes approaches to fix this in his post on prerendering.

And with that, we're done! You can now prerender your WebAssembly app on the server, avoiding the annoying "loading" flash, while still running entirely on the client side as a WebAssembly application.

Prerendering trade-offs

On the face of it prerendering sounds great - it avoids the (sometimes, long) "loading" flash while the application is loaded, and allows users to see the content immediately, without having to wait for the whole app to download. However, there's some downsides.

We've already seen one of them—prerendering can result in data being fetched twice when a page is rendered, as OnInitializedAsync is called twice, once on the server and once on the client. Depending on your requirements that may or may not be a problem. There are approaches to reduce the impact of this effect, but fundamentally it's something your app needs to be able to cope with.

You also need to ensure all the dependencies available to your WebAssembly app are available in the host app during prerender. Again, depending on your application, that may-or-may-not be a problem. If you need to access the browser directly, for example, then you could have difficulties when prerendering. A more common issue will likely be configuration differences, as we saw with the HttpClient in this post.

One subtle issue is that although the app will look like it's ready as soon as the initial HTML file is downloaded, the UI won't actually be responsive until the rest of the WebAssembly assets have been downloaded.

You can see this in action if you set the network speed to 3G in your browser's dev-tools. Clear the website's cache, throttle the network, and then reload the page. Clicking around the UI won't do anything for a few seconds, before the Blazor app boots up.

The app appears ready, but it's not

The other obvious drawback is that you can no longer host your WebAssembly app as static files, you have to run a server application. This is no different to other JavaScript SPAs that use server-side rendering, but it removes one of the big draws of Blazor WebAssembly: simple static-file hosting.

Summary

In this post, I showed how to enable prerendering for a Blazor WebAssembly application. I first showed how to host the application in an ASP.NET Core app, and then showed how to configure the host application to pre-render your Blazor WebAssembly components. This removes the "loading" flash that you see without pre-rendering, though there is still an "unresponsive" period while your app assets are downloaded.

Andrew Lock | .Net Escapades
Want an email when
there's new posts?